ميزة: إضافة مكوني ProcedureSelection و StepProgress لأداة مخططات التدفق بصيغة PDF
- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة. - إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي. - تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts. - إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء. - تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app import create_app
|
||||
|
||||
|
||||
@@ -7,12 +10,22 @@ from app import create_app
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
os.environ['FLASK_ENV'] = 'testing'
|
||||
app = create_app()
|
||||
app = create_app('testing')
|
||||
app.config.update({
|
||||
'TESTING': True,
|
||||
'UPLOAD_FOLDER': '/tmp/test_uploads',
|
||||
'OUTPUT_FOLDER': '/tmp/test_outputs',
|
||||
})
|
||||
# Create temp directories
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
|
||||
|
||||
yield app
|
||||
|
||||
# Cleanup temp directories
|
||||
shutil.rmtree(app.config['UPLOAD_FOLDER'], ignore_errors=True)
|
||||
shutil.rmtree(app.config['OUTPUT_FOLDER'], ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
@@ -24,3 +37,72 @@ def client(app):
|
||||
def runner(app):
|
||||
"""Flask test CLI runner."""
|
||||
return app.test_cli_runner()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: Create realistic test files with valid magic bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_pdf_bytes() -> bytes:
|
||||
"""Create minimal valid PDF bytes for testing."""
|
||||
return (
|
||||
b"%PDF-1.4\n"
|
||||
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\n"
|
||||
b"xref\n0 4\n"
|
||||
b"0000000000 65535 f \n"
|
||||
b"0000000009 00000 n \n"
|
||||
b"0000000058 00000 n \n"
|
||||
b"0000000115 00000 n \n"
|
||||
b"trailer<</Root 1 0 R/Size 4>>\n"
|
||||
b"startxref\n190\n%%EOF"
|
||||
)
|
||||
|
||||
|
||||
def make_png_bytes() -> bytes:
|
||||
"""Create minimal valid PNG bytes for testing."""
|
||||
# 1x1 white pixel PNG
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n" # PNG signature
|
||||
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x02\x00\x00\x00\x90wS\xde"
|
||||
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05"
|
||||
b"\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def make_jpeg_bytes() -> bytes:
|
||||
"""Create minimal valid JPEG bytes for testing."""
|
||||
# Minimal JPEG header
|
||||
return (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01"
|
||||
b"\x00\x01\x00\x00\xff\xd9"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_file():
|
||||
"""Create a PDF file-like object for upload testing."""
|
||||
return io.BytesIO(make_pdf_bytes()), 'test.pdf'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def png_file():
|
||||
"""Create a PNG file-like object for upload testing."""
|
||||
return io.BytesIO(make_png_bytes()), 'test.png'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_celery_task():
|
||||
"""Mock a Celery AsyncResult for task dispatch tests."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-id-12345'
|
||||
return mock_task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_magic():
|
||||
"""Mock python-magic to return expected MIME types."""
|
||||
with patch('app.utils.file_validator.magic') as mock_m:
|
||||
yield mock_m
|
||||
|
||||
74
backend/tests/test_compress_service.py
Normal file
74
backend/tests/test_compress_service.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for PDF compression service."""
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from app.services.compress_service import compress_pdf, PDFCompressionError
|
||||
|
||||
|
||||
class TestCompressService:
|
||||
def test_compress_pdf_invalid_quality_defaults(self, app):
|
||||
"""Invalid quality should default to medium."""
|
||||
with app.app_context():
|
||||
with patch('app.services.compress_service.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
# Create temp input file
|
||||
input_path = '/tmp/test_compress_input.pdf'
|
||||
output_path = '/tmp/test_compress_output.pdf'
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test')
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 compressed')
|
||||
|
||||
result = compress_pdf(input_path, output_path, quality="invalid")
|
||||
# Should have used "medium" default (/ebook)
|
||||
cmd_args = mock_run.call_args[0][0]
|
||||
assert any('/ebook' in str(arg) for arg in cmd_args)
|
||||
|
||||
# Cleanup
|
||||
os.unlink(input_path)
|
||||
os.unlink(output_path)
|
||||
|
||||
def test_compress_pdf_returns_stats(self, app):
|
||||
"""Should return original_size, compressed_size, reduction_percent."""
|
||||
with app.app_context():
|
||||
input_path = '/tmp/test_stats_input.pdf'
|
||||
output_path = '/tmp/test_stats_output.pdf'
|
||||
|
||||
# Create input (100 bytes)
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4' + b'\x00' * 92)
|
||||
|
||||
with patch('app.services.compress_service.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
# Create smaller output (50 bytes)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4' + b'\x00' * 42)
|
||||
|
||||
result = compress_pdf(input_path, output_path, 'medium')
|
||||
assert 'original_size' in result
|
||||
assert 'compressed_size' in result
|
||||
assert result['original_size'] == 100
|
||||
assert result['compressed_size'] == 50
|
||||
|
||||
os.unlink(input_path)
|
||||
os.unlink(output_path)
|
||||
|
||||
def test_compress_pdf_gs_failure_raises(self, app):
|
||||
"""Should raise PDFCompressionError when Ghostscript fails."""
|
||||
with app.app_context():
|
||||
input_path = '/tmp/test_fail_input.pdf'
|
||||
output_path = '/tmp/test_fail_output.pdf'
|
||||
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test')
|
||||
|
||||
with patch('app.services.compress_service.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stderr='Error processing PDF'
|
||||
)
|
||||
with pytest.raises(PDFCompressionError):
|
||||
compress_pdf(input_path, output_path, 'medium')
|
||||
|
||||
os.unlink(input_path)
|
||||
74
backend/tests/test_compress_tasks.py
Normal file
74
backend/tests/test_compress_tasks.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for PDF compression Celery tasks."""
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestCompressTaskRoute:
|
||||
def test_compress_pdf_no_file(self, client):
|
||||
"""POST /api/compress/pdf without file should return 400."""
|
||||
response = client.post('/api/compress/pdf')
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()['error'] == 'No file provided.'
|
||||
|
||||
def test_compress_pdf_with_quality(self, client, monkeypatch):
|
||||
"""Should accept quality parameter (low/medium/high)."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'compress-task-id'
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.generate_safe_path',
|
||||
lambda ext, folder_type: ('compress-task-id', '/tmp/test.pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.compress_pdf_task.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'quality': 'high',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/compress/pdf',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.get_json()['task_id'] == 'compress-task-id'
|
||||
|
||||
def test_compress_pdf_invalid_quality_defaults(self, client, monkeypatch):
|
||||
"""Invalid quality should default to medium."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'compress-default-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.generate_safe_path',
|
||||
lambda ext, folder_type: ('id', '/tmp/test.pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.compress_pdf_task.delay',
|
||||
mock_delay,
|
||||
)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'quality': 'ultra', # invalid
|
||||
}
|
||||
response = client.post(
|
||||
'/api/compress/pdf',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
# The route defaults invalid quality to "medium"
|
||||
call_args = mock_delay.call_args[0]
|
||||
assert call_args[3] == 'medium'
|
||||
72
backend/tests/test_convert_tasks.py
Normal file
72
backend/tests/test_convert_tasks.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for file conversion Celery task routes."""
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestConvertTaskRoutes:
|
||||
def test_pdf_to_word_success(self, client, monkeypatch):
|
||||
"""Should return 202 with task_id for valid PDF upload."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'convert-pdf-word-id'
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.convert.validate_file',
|
||||
lambda f, allowed_types: ('document.pdf', 'pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.convert.generate_safe_path',
|
||||
lambda ext, folder_type: ('convert-pdf-word-id', '/tmp/test.pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.convert.convert_pdf_to_word.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
|
||||
data = {'file': (io.BytesIO(b'%PDF-1.4'), 'document.pdf')}
|
||||
response = client.post(
|
||||
'/api/convert/pdf-to-word',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
body = response.get_json()
|
||||
assert body['task_id'] == 'convert-pdf-word-id'
|
||||
assert 'message' in body
|
||||
|
||||
def test_word_to_pdf_success(self, client, monkeypatch):
|
||||
"""Should return 202 with task_id for valid Word upload."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'convert-word-pdf-id'
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.convert.validate_file',
|
||||
lambda f, allowed_types: ('report.docx', 'docx'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.convert.generate_safe_path',
|
||||
lambda ext, folder_type: ('convert-word-pdf-id', '/tmp/test.docx'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.convert.convert_word_to_pdf.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
|
||||
data = {'file': (io.BytesIO(b'PK\x03\x04'), 'report.docx')}
|
||||
response = client.post(
|
||||
'/api/convert/word-to-pdf',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
body = response.get_json()
|
||||
assert body['task_id'] == 'convert-word-pdf-id'
|
||||
|
||||
def test_pdf_to_word_no_file(self, client):
|
||||
"""Should return 400 when no file provided."""
|
||||
response = client.post('/api/convert/pdf-to-word')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_word_to_pdf_no_file(self, client):
|
||||
"""Should return 400 when no file provided."""
|
||||
response = client.post('/api/convert/word-to-pdf')
|
||||
assert response.status_code == 400
|
||||
49
backend/tests/test_download.py
Normal file
49
backend/tests/test_download.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Tests for file download route."""
|
||||
import os
|
||||
|
||||
|
||||
class TestDownload:
|
||||
def test_download_nonexistent_file(self, client):
|
||||
"""Should return 404 for missing file."""
|
||||
response = client.get('/api/download/some-task-id/output.pdf')
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_download_path_traversal_task_id(self, client):
|
||||
"""Should reject task_id with path traversal characters."""
|
||||
response = client.get('/api/download/../etc/output.pdf')
|
||||
# Flask will handle this — either 400 or 404
|
||||
assert response.status_code in (400, 404)
|
||||
|
||||
def test_download_path_traversal_filename(self, client):
|
||||
"""Should reject filename with path traversal characters."""
|
||||
response = client.get('/api/download/valid-id/../../etc/passwd')
|
||||
assert response.status_code in (400, 404)
|
||||
|
||||
def test_download_valid_file(self, client, app):
|
||||
"""Should serve file if it exists."""
|
||||
task_id = 'test-download-id'
|
||||
filename = 'output.pdf'
|
||||
|
||||
# Create the file in the output directory
|
||||
output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
file_path = os.path.join(output_dir, filename)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test content')
|
||||
|
||||
response = client.get(f'/api/download/{task_id}/{filename}')
|
||||
assert response.status_code == 200
|
||||
assert response.data == b'%PDF-1.4 test content'
|
||||
|
||||
def test_download_with_custom_name(self, client, app):
|
||||
"""Should use the ?name= parameter as download filename."""
|
||||
task_id = 'test-name-id'
|
||||
filename = 'output.pdf'
|
||||
|
||||
output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(os.path.join(output_dir, filename), 'wb') as f:
|
||||
f.write(b'%PDF-1.4')
|
||||
|
||||
response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf')
|
||||
assert response.status_code == 200
|
||||
108
backend/tests/test_file_validator.py
Normal file
108
backend/tests/test_file_validator.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for file validation utility."""
|
||||
import io
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app.utils.file_validator import validate_file, FileValidationError
|
||||
import pytest
|
||||
|
||||
|
||||
class TestFileValidator:
|
||||
def test_no_file_raises(self, app):
|
||||
"""Should raise when no file provided."""
|
||||
with app.app_context():
|
||||
with pytest.raises(FileValidationError, match="No file provided"):
|
||||
validate_file(None, allowed_types=["pdf"])
|
||||
|
||||
def test_empty_filename_raises(self, app):
|
||||
"""Should raise when filename is empty."""
|
||||
with app.app_context():
|
||||
mock_file = MagicMock()
|
||||
mock_file.filename = ''
|
||||
with pytest.raises(FileValidationError, match="No file provided"):
|
||||
validate_file(mock_file, allowed_types=["pdf"])
|
||||
|
||||
def test_wrong_extension_raises(self, app):
|
||||
"""Should raise when file extension is not allowed."""
|
||||
with app.app_context():
|
||||
mock_file = MagicMock()
|
||||
mock_file.filename = 'test.exe'
|
||||
with pytest.raises(FileValidationError, match="not allowed"):
|
||||
validate_file(mock_file, allowed_types=["pdf"])
|
||||
|
||||
def test_empty_file_raises(self, app):
|
||||
"""Should raise when file is empty (0 bytes)."""
|
||||
with app.app_context():
|
||||
content = io.BytesIO(b'')
|
||||
mock_file = MagicMock()
|
||||
mock_file.filename = 'test.pdf'
|
||||
mock_file.seek = content.seek
|
||||
mock_file.tell = content.tell
|
||||
mock_file.read = content.read
|
||||
with pytest.raises(FileValidationError, match="empty"):
|
||||
validate_file(mock_file, allowed_types=["pdf"])
|
||||
|
||||
def test_valid_pdf_passes(self, app):
|
||||
"""Should accept valid PDF file with correct magic bytes."""
|
||||
with app.app_context():
|
||||
pdf_bytes = b'%PDF-1.4 test content' + b'\x00' * 8192
|
||||
content = io.BytesIO(pdf_bytes)
|
||||
|
||||
mock_file = MagicMock()
|
||||
mock_file.filename = 'document.pdf'
|
||||
mock_file.seek = content.seek
|
||||
mock_file.tell = content.tell
|
||||
mock_file.read = content.read
|
||||
|
||||
with patch('app.utils.file_validator.magic') as mock_magic:
|
||||
mock_magic.from_buffer.return_value = 'application/pdf'
|
||||
filename, ext = validate_file(mock_file, allowed_types=["pdf"])
|
||||
|
||||
assert filename == 'document.pdf'
|
||||
assert ext == 'pdf'
|
||||
|
||||
def test_mime_mismatch_raises(self, app):
|
||||
"""Should raise when MIME type doesn't match extension."""
|
||||
with app.app_context():
|
||||
content = io.BytesIO(b'not a real pdf' + b'\x00' * 8192)
|
||||
|
||||
mock_file = MagicMock()
|
||||
mock_file.filename = 'fake.pdf'
|
||||
mock_file.seek = content.seek
|
||||
mock_file.tell = content.tell
|
||||
mock_file.read = content.read
|
||||
|
||||
with patch('app.utils.file_validator.magic') as mock_magic:
|
||||
mock_magic.from_buffer.return_value = 'text/plain'
|
||||
with pytest.raises(FileValidationError, match="does not match"):
|
||||
validate_file(mock_file, allowed_types=["pdf"])
|
||||
|
||||
def test_file_too_large_raises(self, app):
|
||||
"""Should raise when file exceeds size limit."""
|
||||
with app.app_context():
|
||||
# Create a file larger than the PDF size limit (20MB)
|
||||
large_content = io.BytesIO(b'%PDF-1.4' + b'\x00' * (21 * 1024 * 1024))
|
||||
|
||||
mock_file = MagicMock()
|
||||
mock_file.filename = 'large.pdf'
|
||||
mock_file.seek = large_content.seek
|
||||
mock_file.tell = large_content.tell
|
||||
mock_file.read = large_content.read
|
||||
|
||||
with pytest.raises(FileValidationError, match="too large"):
|
||||
validate_file(mock_file, allowed_types=["pdf"])
|
||||
|
||||
def test_dangerous_pdf_raises(self, app):
|
||||
"""Should raise when PDF contains dangerous patterns."""
|
||||
with app.app_context():
|
||||
pdf_bytes = b'%PDF-1.4 /JavaScript evil_code' + b'\x00' * 8192
|
||||
content = io.BytesIO(pdf_bytes)
|
||||
|
||||
mock_file = MagicMock()
|
||||
mock_file.filename = 'evil.pdf'
|
||||
mock_file.seek = content.seek
|
||||
mock_file.tell = content.tell
|
||||
mock_file.read = content.read
|
||||
|
||||
with patch('app.utils.file_validator.magic') as mock_magic:
|
||||
mock_magic.from_buffer.return_value = 'application/pdf'
|
||||
with pytest.raises(FileValidationError, match="unsafe"):
|
||||
validate_file(mock_file, allowed_types=["pdf"])
|
||||
50
backend/tests/test_image_service.py
Normal file
50
backend/tests/test_image_service.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for image processing service."""
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from app.services.image_service import convert_image, ImageProcessingError
|
||||
|
||||
|
||||
class TestImageService:
|
||||
def test_convert_invalid_format_raises(self, app):
|
||||
"""Should raise for unsupported output format."""
|
||||
with app.app_context():
|
||||
with pytest.raises(ImageProcessingError, match="Unsupported"):
|
||||
convert_image('/tmp/test.png', '/tmp/out.bmp', 'bmp')
|
||||
|
||||
def test_convert_image_success(self, app, tmp_path):
|
||||
"""Should convert an image and return stats."""
|
||||
from PIL import Image as PILImage
|
||||
|
||||
with app.app_context():
|
||||
# Create real test image
|
||||
input_path = str(tmp_path / 'input.png')
|
||||
output_path = str(tmp_path / 'output.jpg')
|
||||
|
||||
img = PILImage.new('RGB', (100, 100), color='red')
|
||||
img.save(input_path, 'PNG')
|
||||
|
||||
result = convert_image(input_path, output_path, 'jpg', quality=85)
|
||||
|
||||
assert result['width'] == 100
|
||||
assert result['height'] == 100
|
||||
assert result['format'] == 'jpg'
|
||||
assert result['original_size'] > 0
|
||||
assert result['converted_size'] > 0
|
||||
assert os.path.exists(output_path)
|
||||
|
||||
def test_convert_rgba_to_jpeg(self, app, tmp_path):
|
||||
"""Should handle RGBA to JPEG conversion (strip alpha)."""
|
||||
from PIL import Image as PILImage
|
||||
|
||||
with app.app_context():
|
||||
input_path = str(tmp_path / 'input_rgba.png')
|
||||
output_path = str(tmp_path / 'output.jpg')
|
||||
|
||||
img = PILImage.new('RGBA', (50, 50), color=(255, 0, 0, 128))
|
||||
img.save(input_path, 'PNG')
|
||||
|
||||
result = convert_image(input_path, output_path, 'jpg', quality=85)
|
||||
assert result['format'] == 'jpg'
|
||||
assert os.path.exists(output_path)
|
||||
115
backend/tests/test_image_tasks.py
Normal file
115
backend/tests/test_image_tasks.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Tests for image processing Celery task routes."""
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestImageTaskRoutes:
|
||||
def test_convert_image_success(self, client, monkeypatch):
|
||||
"""Should return 202 with task_id for valid image conversion request."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'img-convert-id'
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.validate_file',
|
||||
lambda f, allowed_types: ('photo.png', 'png'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.generate_safe_path',
|
||||
lambda ext, folder_type: ('img-convert-id', '/tmp/test.png'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.convert_image_task.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x89PNG\r\n'), 'photo.png'),
|
||||
'format': 'jpg',
|
||||
'quality': '85',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/image/convert',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.get_json()['task_id'] == 'img-convert-id'
|
||||
|
||||
def test_convert_image_invalid_format(self, client):
|
||||
"""Should return 400 for unsupported output format."""
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x89PNG\r\n'), 'photo.png'),
|
||||
'format': 'bmp',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/image/convert',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert 'format' in response.get_json()['error'].lower()
|
||||
|
||||
def test_resize_image_success(self, client, monkeypatch):
|
||||
"""Should return 202 with task_id for valid resize request."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'img-resize-id'
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.validate_file',
|
||||
lambda f, allowed_types: ('photo.jpg', 'jpg'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.generate_safe_path',
|
||||
lambda ext, folder_type: ('img-resize-id', '/tmp/test.jpg'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.resize_image_task.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'),
|
||||
'width': '800',
|
||||
'height': '600',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/image/resize',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.get_json()['task_id'] == 'img-resize-id'
|
||||
|
||||
def test_resize_image_no_dimensions(self, client, monkeypatch):
|
||||
"""Should return 400 when both width and height are missing."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.validate_file',
|
||||
lambda f, allowed_types: ('photo.jpg', 'jpg'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'),
|
||||
}
|
||||
response = client.post(
|
||||
'/api/image/resize',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert 'width' in response.get_json()['error'].lower() or 'height' in response.get_json()['error'].lower()
|
||||
|
||||
def test_resize_image_invalid_width(self, client, monkeypatch):
|
||||
"""Should return 400 for width out of range."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.image.validate_file',
|
||||
lambda f, allowed_types: ('photo.jpg', 'jpg'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'),
|
||||
'width': '20000',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/image/resize',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
207
backend/tests/test_load.py
Normal file
207
backend/tests/test_load.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Concurrent / load tests — verify the API handles multiple simultaneous
|
||||
requests without race conditions or resource leaks.
|
||||
|
||||
These tests do NOT require Redis or Celery; every external call is mocked.
|
||||
"""
|
||||
import io
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rapid sequential requests — baseline stability
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestRapidSequential:
|
||||
def test_100_health_requests(self, client):
|
||||
"""100 back-to-back /health requests must all return 200."""
|
||||
for _ in range(100):
|
||||
r = client.get('/api/health')
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_rapid_no_file_errors_are_safe(self, client):
|
||||
"""50 rapid requests that each produce a 400 must not leak state."""
|
||||
for _ in range(50):
|
||||
r = client.post('/api/compress/pdf')
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()['error']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrent requests — 10 simultaneous threads, each with its own client
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConcurrentRequests:
|
||||
def test_10_concurrent_health(self, app):
|
||||
"""10 threads hitting /health simultaneously must all get 200."""
|
||||
results: list[int] = []
|
||||
errors: list[Exception] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
with app.test_client() as c:
|
||||
r = c.get('/api/health')
|
||||
with lock:
|
||||
results.append(r.status_code)
|
||||
except Exception as exc:
|
||||
with lock:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert not errors, f"Threads raised: {errors}"
|
||||
assert results.count(200) == 10
|
||||
|
||||
def test_concurrent_compress_uploads(self, app):
|
||||
"""5 concurrent compress requests each return 202 without deadlocks.
|
||||
Patches are applied ONCE outside threads to avoid thread-safety issues
|
||||
with unittest.mock's global state."""
|
||||
task_ids: list[str] = []
|
||||
errors: list[Exception] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
# Use a counter-based side_effect so the shared mock returns distinct ids
|
||||
counter = [0]
|
||||
|
||||
def make_task():
|
||||
with lock:
|
||||
n = counter[0]
|
||||
counter[0] += 1
|
||||
t = MagicMock()
|
||||
t.id = f'task-thread-{n}'
|
||||
return t
|
||||
|
||||
# Apply all patches BEFORE threads start — avoids concurrent patch/unpatch
|
||||
with patch('app.routes.compress.validate_file', return_value=('t.pdf', 'pdf')), \
|
||||
patch('app.routes.compress.generate_safe_path',
|
||||
side_effect=lambda ext, folder_type: (f'tid-x', '/tmp/up/t.pdf')), \
|
||||
patch('werkzeug.datastructures.file_storage.FileStorage.save'), \
|
||||
patch('app.routes.compress.compress_pdf_task.delay',
|
||||
side_effect=lambda *a, **kw: make_task()):
|
||||
|
||||
def worker():
|
||||
try:
|
||||
with app.test_client() as c:
|
||||
r = c.post(
|
||||
'/api/compress/pdf',
|
||||
data={'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf')},
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
with lock:
|
||||
if r.status_code == 202:
|
||||
task_ids.append(r.get_json()['task_id'])
|
||||
else:
|
||||
errors.append(
|
||||
AssertionError(
|
||||
f"expected 202, got {r.status_code}: {r.data}"
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
with lock:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=15)
|
||||
|
||||
assert not errors, f"Errors in threads: {errors}"
|
||||
assert len(task_ids) == 5
|
||||
assert len(set(task_ids)) == 5, "task_ids must be unique per request"
|
||||
|
||||
def test_concurrent_pdf_tools_requests(self, app):
|
||||
"""3 concurrent split-PDF requests must not interfere with each other.
|
||||
Patches applied once outside threads for thread safety."""
|
||||
statuses: list[int] = []
|
||||
errors: list[Exception] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
with patch('app.routes.pdf_tools.validate_file', return_value=('t.pdf', 'pdf')), \
|
||||
patch('app.routes.pdf_tools.generate_safe_path',
|
||||
side_effect=lambda ext, folder_type: ('split-x', '/tmp/up/t.pdf')), \
|
||||
patch('werkzeug.datastructures.file_storage.FileStorage.save'), \
|
||||
patch('app.routes.pdf_tools.split_pdf_task.delay',
|
||||
return_value=MagicMock(id='split-task')):
|
||||
|
||||
def worker():
|
||||
try:
|
||||
with app.test_client() as c:
|
||||
r = c.post(
|
||||
'/api/pdf-tools/split',
|
||||
data={
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'mode': 'all',
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
with lock:
|
||||
statuses.append(r.status_code)
|
||||
except Exception as exc:
|
||||
with lock:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(3)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=15)
|
||||
|
||||
assert not errors, f"Errors in threads: {errors}"
|
||||
assert all(s == 202 for s in statuses), f"Got statuses: {statuses}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-size enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestFileSizeLimits:
|
||||
def test_compress_rejects_oversized_request(self, client, app):
|
||||
"""Requests exceeding MAX_CONTENT_LENGTH must be rejected (413)."""
|
||||
original = app.config['MAX_CONTENT_LENGTH']
|
||||
try:
|
||||
# Set 1-byte limit so any real file triggers it
|
||||
app.config['MAX_CONTENT_LENGTH'] = 1
|
||||
oversized = io.BytesIO(b'%PDF-1.4' + b'x' * 2048)
|
||||
r = client.post(
|
||||
'/api/compress/pdf',
|
||||
data={'file': (oversized, 'huge.pdf')},
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert r.status_code in (400, 413), (
|
||||
f"Expected 400 or 413 for oversized file, got {r.status_code}"
|
||||
)
|
||||
finally:
|
||||
app.config['MAX_CONTENT_LENGTH'] = original
|
||||
|
||||
def test_normal_size_file_is_accepted(self, client, monkeypatch):
|
||||
"""A file within the size limit reaches the route logic."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.validate_file',
|
||||
lambda f, allowed_types: ('t.pdf', 'pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.generate_safe_path',
|
||||
lambda ext, folder_type: ('tid', '/tmp/test_uploads/tid/t.pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'werkzeug.datastructures.file_storage.FileStorage.save',
|
||||
lambda self, dst, buffer_size=16384: None,
|
||||
)
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'size-ok-task'
|
||||
monkeypatch.setattr(
|
||||
'app.routes.compress.compress_pdf_task.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
|
||||
small_pdf = io.BytesIO(b'%PDF-1.4 small')
|
||||
r = client.post(
|
||||
'/api/compress/pdf',
|
||||
data={'file': (small_pdf, 'small.pdf')},
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert r.status_code == 202
|
||||
64
backend/tests/test_pdf_service.py
Normal file
64
backend/tests/test_pdf_service.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for PDF conversion service (pdf_to_word, word_to_pdf)."""
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from app.services.pdf_service import pdf_to_word, PDFConversionError
|
||||
|
||||
|
||||
class TestPdfService:
|
||||
def test_pdf_to_word_creates_output_dir(self, app):
|
||||
"""Should create output directory if it doesn't exist."""
|
||||
with app.app_context():
|
||||
input_path = '/tmp/test_pdf_svc_input.pdf'
|
||||
output_dir = '/tmp/test_pdf_svc_output'
|
||||
expected_output = os.path.join(output_dir, 'test_pdf_svc_input.docx')
|
||||
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test')
|
||||
|
||||
with patch('app.services.pdf_service.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout='', stderr=''
|
||||
)
|
||||
# Simulate LibreOffice creating the output file
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(expected_output, 'wb') as f:
|
||||
f.write(b'PK\x03\x04 fake docx')
|
||||
|
||||
result = pdf_to_word(input_path, output_dir)
|
||||
assert result == expected_output
|
||||
|
||||
os.unlink(input_path)
|
||||
import shutil
|
||||
shutil.rmtree(output_dir, ignore_errors=True)
|
||||
|
||||
def test_pdf_to_word_timeout_raises(self, app):
|
||||
"""Should raise error on LibreOffice timeout."""
|
||||
with app.app_context():
|
||||
import subprocess
|
||||
|
||||
input_path = '/tmp/test_pdf_timeout.pdf'
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test')
|
||||
|
||||
with patch('app.services.pdf_service.subprocess.run') as mock_run:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd='soffice', timeout=120)
|
||||
with pytest.raises(PDFConversionError, match="timed out"):
|
||||
pdf_to_word(input_path, '/tmp/timeout_output')
|
||||
|
||||
os.unlink(input_path)
|
||||
|
||||
def test_pdf_to_word_not_installed_raises(self, app):
|
||||
"""Should raise error when LibreOffice is not installed."""
|
||||
with app.app_context():
|
||||
input_path = '/tmp/test_pdf_noinstall.pdf'
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test')
|
||||
|
||||
with patch('app.services.pdf_service.subprocess.run') as mock_run:
|
||||
mock_run.side_effect = FileNotFoundError()
|
||||
with pytest.raises(PDFConversionError, match="not installed"):
|
||||
pdf_to_word(input_path, '/tmp/noinstall_output')
|
||||
|
||||
os.unlink(input_path)
|
||||
531
backend/tests/test_pdf_tools.py
Normal file
531
backend/tests/test_pdf_tools.py
Normal file
@@ -0,0 +1,531 @@
|
||||
"""Tests for ALL PDF tools routes — Merge, Split, Rotate, Page Numbers, PDF↔Images, Watermark, Protect, Unlock."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Helper: create mock for validate_file + celery task
|
||||
# =========================================================================
|
||||
def _mock_validate_and_task(monkeypatch, task_module_path, task_name):
|
||||
"""Shared helper: mock validate_file to pass, mock the celery task,
|
||||
and ensure file.save() works by using a real temp directory."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'mock-task-id'
|
||||
|
||||
# Create a real temp dir so file.save() works
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
save_path = os.path.join(tmp_dir, 'mock.pdf')
|
||||
|
||||
# Mock file validator to accept any file
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('mock-task-id', save_path),
|
||||
)
|
||||
|
||||
# Mock the celery task delay
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
monkeypatch.setattr(f'app.routes.pdf_tools.{task_name}.delay', mock_delay)
|
||||
|
||||
return mock_task, mock_delay
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Merge PDFs — POST /api/pdf-tools/merge
|
||||
# =========================================================================
|
||||
class TestMergePdfs:
|
||||
def test_merge_no_files(self, client):
|
||||
"""Should return 400 when no files provided."""
|
||||
response = client.post('/api/pdf-tools/merge')
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_merge_single_file(self, client):
|
||||
"""Should return 400 when only one file provided."""
|
||||
data = {'files': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf')}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/merge',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert 'at least 2' in response.get_json()['error'].lower()
|
||||
|
||||
def test_merge_success(self, client, monkeypatch):
|
||||
"""Should return 202 with task_id when valid PDFs provided."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'merge-task-id'
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.merge_pdfs_task.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
# Mock os.makedirs and FileStorage.save so nothing touches disk
|
||||
monkeypatch.setattr('app.routes.pdf_tools.os.makedirs', lambda *a, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
'werkzeug.datastructures.file_storage.FileStorage.save',
|
||||
lambda self, dst, buffer_size=16384: None,
|
||||
)
|
||||
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(b'%PDF-1.4 file1'), 'a.pdf'),
|
||||
(io.BytesIO(b'%PDF-1.4 file2'), 'b.pdf'),
|
||||
]
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/merge',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
body = response.get_json()
|
||||
assert body['task_id'] == 'merge-task-id'
|
||||
assert 'message' in body
|
||||
|
||||
def test_merge_too_many_files(self, client, monkeypatch):
|
||||
"""Should return 400 when more than 20 files provided."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(b'%PDF-1.4'), f'file{i}.pdf')
|
||||
for i in range(21)
|
||||
]
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/merge',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert '20' in response.get_json()['error']
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Split PDF — POST /api/pdf-tools/split
|
||||
# =========================================================================
|
||||
class TestSplitPdf:
|
||||
def test_split_no_file(self, client):
|
||||
"""Should return 400 when no file provided."""
|
||||
response = client.post('/api/pdf-tools/split')
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert data['error'] == 'No file provided.'
|
||||
|
||||
def test_split_success_all_mode(self, client, monkeypatch):
|
||||
"""Should accept file and return 202 with mode=all."""
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'split_pdf_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf'),
|
||||
'mode': 'all',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/split',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
body = response.get_json()
|
||||
assert body['task_id'] == 'mock-task-id'
|
||||
|
||||
def test_split_success_range_mode(self, client, monkeypatch):
|
||||
"""Should accept file with mode=range and pages."""
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'split_pdf_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf'),
|
||||
'mode': 'range',
|
||||
'pages': '1,3,5-8',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/split',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
mock_delay.assert_called_once()
|
||||
# Verify pages parameter was passed
|
||||
call_args = mock_delay.call_args
|
||||
assert call_args[0][4] == '1,3,5-8' # pages arg
|
||||
|
||||
def test_split_range_mode_requires_pages(self, client, monkeypatch):
|
||||
"""Should return 400 when range mode is selected without pages."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf'),
|
||||
'mode': 'range',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/split',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'specify which pages to extract' in response.get_json()['error'].lower()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Rotate PDF — POST /api/pdf-tools/rotate
|
||||
# =========================================================================
|
||||
class TestRotatePdf:
|
||||
def test_rotate_no_file(self, client):
|
||||
response = client.post('/api/pdf-tools/rotate')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_rotate_invalid_degrees(self, client, monkeypatch):
|
||||
"""Should reject invalid rotation angles."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'rotation': '45',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/rotate',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert '90, 180, or 270' in response.get_json()['error']
|
||||
|
||||
def test_rotate_success(self, client, monkeypatch):
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'rotate_pdf_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'rotation': '90',
|
||||
'pages': 'all',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/rotate',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.get_json()['task_id'] == 'mock-task-id'
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. Page Numbers — POST /api/pdf-tools/page-numbers
|
||||
# =========================================================================
|
||||
class TestAddPageNumbers:
|
||||
def test_page_numbers_no_file(self, client):
|
||||
response = client.post('/api/pdf-tools/page-numbers')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_page_numbers_success(self, client, monkeypatch):
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'add_page_numbers_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'position': 'bottom-center',
|
||||
'start_number': '1',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/page-numbers',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_page_numbers_invalid_position_defaults(self, client, monkeypatch):
|
||||
"""Invalid position should default to bottom-center."""
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'add_page_numbers_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'position': 'invalid-position',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/page-numbers',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
# Should have used default 'bottom-center'
|
||||
call_args = mock_delay.call_args[0]
|
||||
assert call_args[3] == 'bottom-center'
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 5. PDF to Images — POST /api/pdf-tools/pdf-to-images
|
||||
# =========================================================================
|
||||
class TestPdfToImages:
|
||||
def test_pdf_to_images_no_file(self, client):
|
||||
response = client.post('/api/pdf-tools/pdf-to-images')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_pdf_to_images_success(self, client, monkeypatch):
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'pdf_to_images_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'format': 'png',
|
||||
'dpi': '200',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/pdf-to-images',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_pdf_to_images_invalid_format_defaults(self, client, monkeypatch):
|
||||
"""Invalid format should default to png."""
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'pdf_to_images_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'format': 'bmp',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/pdf-to-images',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
call_args = mock_delay.call_args[0]
|
||||
assert call_args[3] == 'png' # default format
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. Images to PDF — POST /api/pdf-tools/images-to-pdf
|
||||
# =========================================================================
|
||||
class TestImagesToPdf:
|
||||
def test_images_to_pdf_no_files(self, client):
|
||||
response = client.post('/api/pdf-tools/images-to-pdf')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_images_to_pdf_success(self, client, monkeypatch):
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'images-task-id'
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.png', 'png'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.images_to_pdf_task.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
# Mock os.makedirs and FileStorage.save so nothing touches disk
|
||||
monkeypatch.setattr('app.routes.pdf_tools.os.makedirs', lambda *a, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
'werkzeug.datastructures.file_storage.FileStorage.save',
|
||||
lambda self, dst, buffer_size=16384: None,
|
||||
)
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(b'\x89PNG\r\n'), 'img1.png'),
|
||||
(io.BytesIO(b'\x89PNG\r\n'), 'img2.png'),
|
||||
]
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/images-to-pdf',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.get_json()['task_id'] == 'images-task-id'
|
||||
|
||||
def test_images_to_pdf_too_many(self, client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.png', 'png'),
|
||||
)
|
||||
data = {
|
||||
'files': [
|
||||
(io.BytesIO(b'\x89PNG\r\n'), f'img{i}.png')
|
||||
for i in range(51)
|
||||
]
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/images-to-pdf',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert '50' in response.get_json()['error']
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 7. Watermark PDF — POST /api/pdf-tools/watermark
|
||||
# =========================================================================
|
||||
class TestWatermarkPdf:
|
||||
def test_watermark_no_file(self, client):
|
||||
response = client.post('/api/pdf-tools/watermark')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_watermark_no_text(self, client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'text': '',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/watermark',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert 'required' in response.get_json()['error'].lower()
|
||||
|
||||
def test_watermark_text_too_long(self, client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'text': 'x' * 101,
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/watermark',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert '100' in response.get_json()['error']
|
||||
|
||||
def test_watermark_success(self, client, monkeypatch):
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'watermark_pdf_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'text': 'CONFIDENTIAL',
|
||||
'opacity': '0.5',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/watermark',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 8. Protect PDF — POST /api/pdf-tools/protect
|
||||
# =========================================================================
|
||||
class TestProtectPdf:
|
||||
def test_protect_no_file(self, client):
|
||||
response = client.post('/api/pdf-tools/protect')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_protect_no_password(self, client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'password': '',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/protect',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert 'required' in response.get_json()['error'].lower()
|
||||
|
||||
def test_protect_short_password(self, client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'password': 'abc',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/protect',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert '4 characters' in response.get_json()['error']
|
||||
|
||||
def test_protect_success(self, client, monkeypatch):
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'protect_pdf_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'password': 'secret1234',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/protect',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 9. Unlock PDF — POST /api/pdf-tools/unlock
|
||||
# =========================================================================
|
||||
class TestUnlockPdf:
|
||||
def test_unlock_no_file(self, client):
|
||||
response = client.post('/api/pdf-tools/unlock')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_unlock_no_password(self, client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'password': '',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/unlock',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_unlock_success(self, client, monkeypatch):
|
||||
mock_task, mock_delay = _mock_validate_and_task(
|
||||
monkeypatch, 'app.routes.pdf_tools', 'unlock_pdf_task'
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'password': 'mypassword',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/pdf-tools/unlock',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
111
backend/tests/test_pdf_tools_service.py
Normal file
111
backend/tests/test_pdf_tools_service.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Tests for PDF tools service — Merge, Split, Rotate, etc."""
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from app.services.pdf_tools_service import (
|
||||
merge_pdfs,
|
||||
split_pdf,
|
||||
PDFToolsError,
|
||||
)
|
||||
|
||||
|
||||
class TestMergePdfsService:
|
||||
def test_merge_file_not_found_raises(self, app):
|
||||
"""Should raise when input file doesn't exist."""
|
||||
with app.app_context():
|
||||
with pytest.raises(PDFToolsError, match="not found"):
|
||||
merge_pdfs(
|
||||
['/tmp/nonexistent1.pdf', '/tmp/nonexistent2.pdf'],
|
||||
'/tmp/merged_output.pdf',
|
||||
)
|
||||
|
||||
def test_merge_success(self, app, tmp_path):
|
||||
"""Should merge PDF files successfully."""
|
||||
with app.app_context():
|
||||
# Create test PDFs using PyPDF2
|
||||
try:
|
||||
from PyPDF2 import PdfWriter
|
||||
|
||||
pdf1 = str(tmp_path / 'a.pdf')
|
||||
pdf2 = str(tmp_path / 'b.pdf')
|
||||
|
||||
for path in [pdf1, pdf2]:
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
with open(path, 'wb') as f:
|
||||
writer.write(f)
|
||||
|
||||
output = str(tmp_path / 'merged.pdf')
|
||||
result = merge_pdfs([pdf1, pdf2], output)
|
||||
|
||||
assert result['total_pages'] == 2
|
||||
assert result['files_merged'] == 2
|
||||
assert result['output_size'] > 0
|
||||
assert os.path.exists(output)
|
||||
except ImportError:
|
||||
pytest.skip("PyPDF2 not installed")
|
||||
|
||||
|
||||
class TestSplitPdfService:
|
||||
def test_split_all_pages(self, app, tmp_path):
|
||||
"""Should split PDF into individual pages."""
|
||||
with app.app_context():
|
||||
try:
|
||||
from PyPDF2 import PdfWriter
|
||||
|
||||
# Create 3-page PDF
|
||||
input_path = str(tmp_path / 'multi.pdf')
|
||||
writer = PdfWriter()
|
||||
for _ in range(3):
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
with open(input_path, 'wb') as f:
|
||||
writer.write(f)
|
||||
|
||||
output_dir = str(tmp_path / 'split_output')
|
||||
result = split_pdf(input_path, output_dir, mode='all')
|
||||
|
||||
assert result['total_pages'] == 3
|
||||
assert result['extracted_pages'] == 3
|
||||
assert os.path.exists(result['zip_path'])
|
||||
except ImportError:
|
||||
pytest.skip("PyPDF2 not installed")
|
||||
|
||||
def test_split_range_out_of_bounds_includes_total_pages(self, app, tmp_path):
|
||||
"""Should raise a clear error when requested pages exceed document page count."""
|
||||
with app.app_context():
|
||||
try:
|
||||
from PyPDF2 import PdfWriter
|
||||
|
||||
input_path = str(tmp_path / 'single-page.pdf')
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
with open(input_path, 'wb') as f:
|
||||
writer.write(f)
|
||||
|
||||
output_dir = str(tmp_path / 'split_output')
|
||||
|
||||
with pytest.raises(PDFToolsError, match='has only 1 page'):
|
||||
split_pdf(input_path, output_dir, mode='range', pages='1-2')
|
||||
except ImportError:
|
||||
pytest.skip("PyPDF2 not installed")
|
||||
|
||||
def test_split_range_invalid_format_returns_clear_message(self, app, tmp_path):
|
||||
"""Should raise a clear error for malformed page ranges."""
|
||||
with app.app_context():
|
||||
try:
|
||||
from PyPDF2 import PdfWriter
|
||||
|
||||
input_path = str(tmp_path / 'two-pages.pdf')
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
with open(input_path, 'wb') as f:
|
||||
writer.write(f)
|
||||
|
||||
output_dir = str(tmp_path / 'split_output')
|
||||
|
||||
with pytest.raises(PDFToolsError, match='Invalid page format'):
|
||||
split_pdf(input_path, output_dir, mode='range', pages='1-2-3')
|
||||
except ImportError:
|
||||
pytest.skip("PyPDF2 not installed")
|
||||
176
backend/tests/test_pdf_tools_tasks.py
Normal file
176
backend/tests/test_pdf_tools_tasks.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Tests for PDF tools Celery task routes — ensures frontend→backend request formats work."""
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestPdfToolsTaskRoutes:
|
||||
"""
|
||||
These tests verify that the backend route accepts the exact request format
|
||||
the frontend sends, processes parameters correctly, and dispatches the
|
||||
appropriate Celery task.
|
||||
"""
|
||||
|
||||
def test_split_dispatches_task(self, client, monkeypatch):
|
||||
"""Split route should dispatch split_pdf_task with correct params."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'split-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr('app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('split-id', '/tmp/test.pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.split_pdf_task.delay', mock_delay)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'mode': 'range',
|
||||
'pages': '1-5',
|
||||
}
|
||||
response = client.post('/api/pdf-tools/split', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 202
|
||||
# Verify task was called with (input_path, task_id, filename, mode, pages)
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[3] == 'range'
|
||||
assert args[4] == '1-5'
|
||||
|
||||
def test_rotate_dispatches_task(self, client, monkeypatch):
|
||||
"""Rotate route should dispatch with rotation and pages params."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'rotate-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr('app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('rotate-id', '/tmp/test.pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.rotate_pdf_task.delay', mock_delay)
|
||||
|
||||
# Frontend sends: rotation=90, pages=all
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'rotation': '180',
|
||||
'pages': 'all',
|
||||
}
|
||||
response = client.post('/api/pdf-tools/rotate', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 202
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[3] == 180 # rotation as int
|
||||
assert args[4] == 'all'
|
||||
|
||||
def test_watermark_dispatches_task(self, client, monkeypatch):
|
||||
"""Watermark route should dispatch with text and opacity."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'wm-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr('app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('wm-id', '/tmp/test.pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.watermark_pdf_task.delay', mock_delay)
|
||||
|
||||
# Frontend sends: text and opacity (as decimal string)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'text': 'CONFIDENTIAL',
|
||||
'opacity': '0.3',
|
||||
}
|
||||
response = client.post('/api/pdf-tools/watermark', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 202
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[3] == 'CONFIDENTIAL'
|
||||
assert args[4] == 0.3
|
||||
|
||||
def test_protect_dispatches_task(self, client, monkeypatch):
|
||||
"""Protect route should dispatch with password."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'protect-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr('app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('protect-id', '/tmp/test.pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.protect_pdf_task.delay', mock_delay)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'password': 'mySecret123',
|
||||
}
|
||||
response = client.post('/api/pdf-tools/protect', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 202
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[3] == 'mySecret123'
|
||||
|
||||
def test_unlock_dispatches_task(self, client, monkeypatch):
|
||||
"""Unlock route should dispatch with password."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'unlock-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr('app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('unlock-id', '/tmp/test.pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.unlock_pdf_task.delay', mock_delay)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'password': 'oldPassword',
|
||||
}
|
||||
response = client.post('/api/pdf-tools/unlock', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_page_numbers_dispatches_task(self, client, monkeypatch):
|
||||
"""Page numbers route should dispatch with position and start_number."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'pn-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr('app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('pn-id', '/tmp/test.pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.add_page_numbers_task.delay', mock_delay)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'position': 'top-right',
|
||||
'start_number': '5',
|
||||
}
|
||||
response = client.post('/api/pdf-tools/page-numbers', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 202
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[3] == 'top-right'
|
||||
assert args[4] == 5
|
||||
|
||||
def test_pdf_to_images_dispatches_task(self, client, monkeypatch):
|
||||
"""PDF to images route should dispatch with format and dpi."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'p2i-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr('app.routes.pdf_tools.validate_file',
|
||||
lambda f, allowed_types: ('test.pdf', 'pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path',
|
||||
lambda ext, folder_type: ('p2i-id', '/tmp/test.pdf'))
|
||||
monkeypatch.setattr('app.routes.pdf_tools.pdf_to_images_task.delay', mock_delay)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'),
|
||||
'format': 'jpg',
|
||||
'dpi': '300',
|
||||
}
|
||||
response = client.post('/api/pdf-tools/pdf-to-images', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 202
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[3] == 'jpg'
|
||||
assert args[4] == 300
|
||||
101
backend/tests/test_rate_limiter.py
Normal file
101
backend/tests/test_rate_limiter.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Tests for rate limiting middleware."""
|
||||
import pytest
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rate_limited_app(tmp_path):
|
||||
"""App with rate limiting ENABLED.
|
||||
|
||||
TestingConfig sets RATELIMIT_ENABLED=False so the other 116 tests are
|
||||
never throttled. Here we force the extension's internal flag back to
|
||||
True *after* init_app so the decorator limits are enforced.
|
||||
"""
|
||||
app = create_app('testing')
|
||||
app.config.update({
|
||||
'TESTING': True,
|
||||
'RATELIMIT_STORAGE_URI': 'memory://',
|
||||
'UPLOAD_FOLDER': str(tmp_path / 'uploads'),
|
||||
'OUTPUT_FOLDER': str(tmp_path / 'outputs'),
|
||||
})
|
||||
import os
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
|
||||
|
||||
# flask-limiter 3.x returns from init_app immediately when
|
||||
# RATELIMIT_ENABLED=False (TestingConfig default), so `initialized`
|
||||
# stays False and no limits are enforced. We override the config key
|
||||
# and call init_app a SECOND time so the extension fully initialises.
|
||||
# It is safe to call twice — flask-limiter guards against duplicate
|
||||
# before_request hook registration via app.extensions["limiter"].
|
||||
from app.extensions import limiter as _limiter
|
||||
app.config['RATELIMIT_ENABLED'] = True
|
||||
_limiter.init_app(app) # second call — now RATELIMIT_ENABLED=True
|
||||
|
||||
yield app
|
||||
|
||||
# Restore so other tests are unaffected
|
||||
_limiter.enabled = False
|
||||
_limiter.initialized = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rate_limited_client(rate_limited_app):
|
||||
return rate_limited_app.test_client()
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
def test_health_endpoint_not_rate_limited(self, client):
|
||||
"""Health endpoint should handle many rapid requests."""
|
||||
for _ in range(20):
|
||||
response = client.get('/api/health')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_rate_limit_header_present(self, client):
|
||||
"""Response should include a valid HTTP status code."""
|
||||
response = client.get('/api/health')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestRateLimitEnforcement:
|
||||
"""Verify that per-route rate limits actually trigger (429) when exceeded."""
|
||||
|
||||
def test_compress_rate_limit_triggers(self, rate_limited_client):
|
||||
"""
|
||||
POST /api/compress/pdf has @limiter.limit("10/minute").
|
||||
After 10 requests (each returns 400 for missing file, but the limiter
|
||||
still counts them), the 11th must get 429 Too Many Requests.
|
||||
"""
|
||||
blocked = False
|
||||
for i in range(15):
|
||||
r = rate_limited_client.post('/api/compress/pdf')
|
||||
if r.status_code == 429:
|
||||
blocked = True
|
||||
break
|
||||
assert blocked, (
|
||||
"Expected a 429 Too Many Requests after exceeding 10/minute "
|
||||
"on /api/compress/pdf"
|
||||
)
|
||||
|
||||
def test_convert_pdf_to_word_rate_limit(self, rate_limited_client):
|
||||
"""POST /api/convert/pdf-to-word is also rate-limited."""
|
||||
blocked = False
|
||||
for _ in range(15):
|
||||
r = rate_limited_client.post('/api/convert/pdf-to-word')
|
||||
if r.status_code == 429:
|
||||
blocked = True
|
||||
break
|
||||
assert blocked, "Rate limit not enforced on /api/convert/pdf-to-word"
|
||||
|
||||
def test_different_endpoints_have_independent_limits(self, rate_limited_client):
|
||||
"""
|
||||
Exhausting the limit on /compress/pdf must not affect /api/health,
|
||||
which has no rate limit.
|
||||
"""
|
||||
# Exhaust compress limit
|
||||
for _ in range(15):
|
||||
rate_limited_client.post('/api/compress/pdf')
|
||||
|
||||
# Health should still respond normally
|
||||
r = rate_limited_client.get('/api/health')
|
||||
assert r.status_code == 200
|
||||
74
backend/tests/test_sanitizer.py
Normal file
74
backend/tests/test_sanitizer.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for sanitizer utilities — generate_safe_path, get_output_path, cleanup."""
|
||||
import os
|
||||
from app.utils.sanitizer import generate_safe_path, get_output_path, cleanup_task_files
|
||||
|
||||
|
||||
class TestGenerateSafePath:
|
||||
def test_returns_tuple(self, app):
|
||||
"""Should return (task_id, file_path) tuple."""
|
||||
with app.app_context():
|
||||
task_id, path = generate_safe_path('pdf', folder_type='upload')
|
||||
assert isinstance(task_id, str)
|
||||
assert isinstance(path, str)
|
||||
|
||||
def test_uuid_in_path(self, app):
|
||||
"""Path should contain the UUID task_id."""
|
||||
with app.app_context():
|
||||
task_id, path = generate_safe_path('pdf')
|
||||
assert task_id in path
|
||||
|
||||
def test_correct_extension(self, app):
|
||||
"""Path should end with the specified extension."""
|
||||
with app.app_context():
|
||||
_, path = generate_safe_path('docx')
|
||||
assert path.endswith('.docx')
|
||||
|
||||
def test_upload_folder(self, app):
|
||||
"""upload folder_type should use UPLOAD_FOLDER config."""
|
||||
with app.app_context():
|
||||
_, path = generate_safe_path('pdf', folder_type='upload')
|
||||
assert app.config['UPLOAD_FOLDER'] in path
|
||||
|
||||
def test_output_folder(self, app):
|
||||
"""output folder_type should use OUTPUT_FOLDER config."""
|
||||
with app.app_context():
|
||||
_, path = generate_safe_path('pdf', folder_type='output')
|
||||
assert app.config['OUTPUT_FOLDER'] in path
|
||||
|
||||
|
||||
class TestGetOutputPath:
|
||||
def test_returns_correct_path(self, app):
|
||||
"""Should return path in OUTPUT_FOLDER with task_id and extension."""
|
||||
with app.app_context():
|
||||
path = get_output_path('my-task-id', 'pdf')
|
||||
assert 'my-task-id' in path
|
||||
assert path.endswith('.pdf')
|
||||
assert app.config['OUTPUT_FOLDER'] in path
|
||||
|
||||
|
||||
class TestCleanupTaskFiles:
|
||||
def test_cleanup_removes_upload_dir(self, app):
|
||||
"""Should remove upload directory for the task."""
|
||||
with app.app_context():
|
||||
task_id = 'cleanup-test-id'
|
||||
upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], task_id)
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# Create a test file
|
||||
with open(os.path.join(upload_dir, 'test.pdf'), 'w') as f:
|
||||
f.write('test')
|
||||
|
||||
cleanup_task_files(task_id)
|
||||
assert not os.path.exists(upload_dir)
|
||||
|
||||
def test_cleanup_keeps_outputs_when_requested(self, app):
|
||||
"""Should keep output directory when keep_outputs=True."""
|
||||
with app.app_context():
|
||||
task_id = 'keep-output-id'
|
||||
output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(os.path.join(output_dir, 'out.pdf'), 'w') as f:
|
||||
f.write('test')
|
||||
|
||||
cleanup_task_files(task_id, keep_outputs=True)
|
||||
assert os.path.exists(output_dir)
|
||||
56
backend/tests/test_storage_service.py
Normal file
56
backend/tests/test_storage_service.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for storage service — local mode (S3 not configured in tests)."""
|
||||
import os
|
||||
|
||||
from app.services.storage_service import StorageService
|
||||
|
||||
|
||||
class TestStorageServiceLocal:
|
||||
def test_use_s3_false_in_test(self, app):
|
||||
"""S3 should not be configured in test environment."""
|
||||
with app.app_context():
|
||||
svc = StorageService()
|
||||
assert svc.use_s3 is False
|
||||
|
||||
def test_upload_file_local(self, app):
|
||||
"""Should copy file to outputs directory in local mode."""
|
||||
with app.app_context():
|
||||
svc = StorageService()
|
||||
task_id = 'local-upload-test'
|
||||
|
||||
# Create a source file
|
||||
input_path = '/tmp/test_storage_input.pdf'
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test')
|
||||
|
||||
key = svc.upload_file(input_path, task_id)
|
||||
assert task_id in key
|
||||
assert 'test_storage_input.pdf' in key
|
||||
|
||||
os.unlink(input_path)
|
||||
|
||||
def test_generate_presigned_url_local(self, app):
|
||||
"""In local mode should return /api/download/... URL."""
|
||||
with app.app_context():
|
||||
svc = StorageService()
|
||||
url = svc.generate_presigned_url(
|
||||
'outputs/task-123/output.pdf',
|
||||
original_filename='my-doc.pdf',
|
||||
)
|
||||
assert '/api/download/task-123/output.pdf' in url
|
||||
assert 'name=my-doc.pdf' in url
|
||||
|
||||
def test_file_exists_local(self, app):
|
||||
"""Should check file existence on local filesystem."""
|
||||
with app.app_context():
|
||||
svc = StorageService()
|
||||
# Non-existent file
|
||||
assert svc.file_exists('outputs/nonexistent/file.pdf') is False
|
||||
|
||||
# Create existing file
|
||||
task_id = 'exists-test'
|
||||
output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(os.path.join(output_dir, 'test.pdf'), 'w') as f:
|
||||
f.write('test')
|
||||
|
||||
assert svc.file_exists(f'outputs/{task_id}/test.pdf') is True
|
||||
66
backend/tests/test_tasks_route.py
Normal file
66
backend/tests/test_tasks_route.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Tests for task status polling route."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestTaskStatus:
|
||||
def test_pending_task(self, client, monkeypatch):
|
||||
"""Should return PENDING state for a queued task."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.state = 'PENDING'
|
||||
mock_result.info = None
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/test-task-id/status')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['task_id'] == 'test-task-id'
|
||||
assert data['state'] == 'PENDING'
|
||||
assert 'progress' in data
|
||||
|
||||
def test_processing_task(self, client, monkeypatch):
|
||||
"""Should return PROCESSING state with step info."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.state = 'PROCESSING'
|
||||
mock_result.info = {'step': 'Converting page 3 of 10...'}
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/processing-id/status')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['state'] == 'PROCESSING'
|
||||
assert data['progress'] == 'Converting page 3 of 10...'
|
||||
|
||||
def test_success_task(self, client, monkeypatch):
|
||||
"""Should return SUCCESS state with result data."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.state = 'SUCCESS'
|
||||
mock_result.result = {
|
||||
'status': 'completed',
|
||||
'download_url': '/api/download/task-id/output.pdf',
|
||||
'filename': 'output.pdf',
|
||||
}
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/success-id/status')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['state'] == 'SUCCESS'
|
||||
assert data['result']['status'] == 'completed'
|
||||
assert 'download_url' in data['result']
|
||||
|
||||
def test_failure_task(self, client, monkeypatch):
|
||||
"""Should return FAILURE state with error message."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.state = 'FAILURE'
|
||||
mock_result.info = Exception('Conversion failed due to corrupt PDF.')
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/failed-id/status')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['state'] == 'FAILURE'
|
||||
assert 'error' in data
|
||||
@@ -1,19 +1,21 @@
|
||||
"""Tests for text utility functions."""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add backend to path so we can import utils directly
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from app.utils.file_validator import validate_file
|
||||
"""Tests for general utility functions."""
|
||||
from app.utils.sanitizer import generate_safe_path
|
||||
|
||||
|
||||
def test_generate_safe_path():
|
||||
def test_generate_safe_path(app):
|
||||
"""generate_safe_path should produce UUID-based path."""
|
||||
path = generate_safe_path('uploads', 'test.pdf')
|
||||
assert path.startswith('uploads')
|
||||
assert path.endswith('.pdf')
|
||||
# Should contain a UUID directory
|
||||
parts = path.replace('\\', '/').split('/')
|
||||
assert len(parts) >= 3 # uploads / uuid / filename.pdf
|
||||
with app.app_context():
|
||||
task_id, path = generate_safe_path('pdf', folder_type='upload')
|
||||
assert task_id in path
|
||||
assert path.endswith('.pdf')
|
||||
# Should contain a UUID directory
|
||||
parts = path.replace('\\', '/').split('/')
|
||||
assert len(parts) >= 3 # /tmp/test_uploads / uuid / filename.pdf
|
||||
|
||||
|
||||
def test_generate_safe_path_unique(app):
|
||||
"""Each call should produce a unique task_id."""
|
||||
with app.app_context():
|
||||
id1, _ = generate_safe_path('pdf')
|
||||
id2, _ = generate_safe_path('pdf')
|
||||
assert id1 != id2
|
||||
|
||||
151
backend/tests/test_video.py
Normal file
151
backend/tests/test_video.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for video processing routes — Video to GIF."""
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestVideoToGif:
|
||||
def test_to_gif_no_file(self, client):
|
||||
"""POST /api/video/to-gif without file should return 400."""
|
||||
response = client.post('/api/video/to-gif')
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert data['error'] == 'No file provided.'
|
||||
|
||||
def test_to_gif_invalid_params(self, client, monkeypatch):
|
||||
"""Should return 400 for non-numeric parameters."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('test.mp4', 'mp4'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'),
|
||||
'start_time': 'abc',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert 'numeric' in response.get_json()['error'].lower()
|
||||
|
||||
def test_to_gif_negative_start(self, client, monkeypatch):
|
||||
"""Should reject negative start time."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('test.mp4', 'mp4'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'),
|
||||
'start_time': '-5',
|
||||
'duration': '5',
|
||||
'fps': '10',
|
||||
'width': '480',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_to_gif_duration_too_long(self, client, monkeypatch):
|
||||
"""Should reject duration > 15 seconds."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('test.mp4', 'mp4'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'),
|
||||
'start_time': '0',
|
||||
'duration': '20',
|
||||
'fps': '10',
|
||||
'width': '480',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert '15' in response.get_json()['error']
|
||||
|
||||
def test_to_gif_fps_out_of_range(self, client, monkeypatch):
|
||||
"""Should reject FPS > 20."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('test.mp4', 'mp4'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'),
|
||||
'start_time': '0',
|
||||
'duration': '5',
|
||||
'fps': '30',
|
||||
'width': '480',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_to_gif_width_out_of_range(self, client, monkeypatch):
|
||||
"""Should reject width > 640."""
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('test.mp4', 'mp4'),
|
||||
)
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'),
|
||||
'start_time': '0',
|
||||
'duration': '5',
|
||||
'fps': '10',
|
||||
'width': '1000',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_to_gif_success(self, client, monkeypatch):
|
||||
"""Should return 202 with valid parameters."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'gif-task-id'
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('test.mp4', 'mp4'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.generate_safe_path',
|
||||
lambda ext, folder_type: ('gif-task-id', '/tmp/test_uploads/gif-task-id/test.mp4'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.create_gif_task.delay',
|
||||
MagicMock(return_value=mock_task),
|
||||
)
|
||||
# Mock FileStorage.save so nothing touches disk
|
||||
monkeypatch.setattr(
|
||||
'werkzeug.datastructures.file_storage.FileStorage.save',
|
||||
lambda self, dst, buffer_size=16384: None,
|
||||
)
|
||||
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'),
|
||||
'start_time': '0',
|
||||
'duration': '5',
|
||||
'fps': '10',
|
||||
'width': '480',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
body = response.get_json()
|
||||
assert body['task_id'] == 'gif-task-id'
|
||||
assert 'message' in body
|
||||
37
backend/tests/test_video_service.py
Normal file
37
backend/tests/test_video_service.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Tests for video processing service."""
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from app.services.video_service import video_to_gif, VideoProcessingError
|
||||
|
||||
|
||||
class TestVideoService:
|
||||
def test_sanitizes_parameters(self, app):
|
||||
"""Should clamp parameters to safe ranges."""
|
||||
with app.app_context():
|
||||
with patch('app.services.video_service.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr='test error')
|
||||
# Even with crazy params, it should clamp them
|
||||
with pytest.raises(VideoProcessingError):
|
||||
video_to_gif(
|
||||
'/tmp/test.mp4', '/tmp/out.gif',
|
||||
start_time=-10, duration=100,
|
||||
fps=50, width=2000,
|
||||
)
|
||||
|
||||
def test_ffmpeg_palette_failure_raises(self, app):
|
||||
"""Should raise when ffmpeg palette generation fails."""
|
||||
with app.app_context():
|
||||
input_path = '/tmp/test_vid_fail.mp4'
|
||||
with open(input_path, 'wb') as f:
|
||||
f.write(b'\x00\x00\x00\x1cftyp')
|
||||
|
||||
with patch('app.services.video_service.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stderr='Invalid video'
|
||||
)
|
||||
with pytest.raises(VideoProcessingError):
|
||||
video_to_gif(input_path, '/tmp/fail_out.gif')
|
||||
|
||||
os.unlink(input_path)
|
||||
83
backend/tests/test_video_tasks.py
Normal file
83
backend/tests/test_video_tasks.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for video task routes — Video to GIF."""
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestVideoTaskRoutes:
|
||||
def test_video_to_gif_dispatches_task(self, client, monkeypatch):
|
||||
"""Should dispatch create_gif_task with correct parameters."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'gif-task-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('video.mp4', 'mp4'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.generate_safe_path',
|
||||
lambda ext, folder_type: ('gif-task-id', '/tmp/test.mp4'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.create_gif_task.delay',
|
||||
mock_delay,
|
||||
)
|
||||
|
||||
# Simulate exact frontend request format
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'video.mp4'),
|
||||
'start_time': '2.5',
|
||||
'duration': '5',
|
||||
'fps': '10',
|
||||
'width': '480',
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
body = response.get_json()
|
||||
assert body['task_id'] == 'gif-task-id'
|
||||
|
||||
# Verify task arguments match what the route sends
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[0] == '/tmp/test.mp4' # input_path
|
||||
assert args[1] == 'gif-task-id' # task_id
|
||||
assert args[2] == 'video.mp4' # original_filename
|
||||
|
||||
def test_video_to_gif_default_params(self, client, monkeypatch):
|
||||
"""Should use default params when not provided."""
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'gif-default-id'
|
||||
mock_delay = MagicMock(return_value=mock_task)
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.validate_file',
|
||||
lambda f, allowed_types: ('video.mp4', 'mp4'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.generate_safe_path',
|
||||
lambda ext, folder_type: ('gif-default-id', '/tmp/test.mp4'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.routes.video.create_gif_task.delay',
|
||||
mock_delay,
|
||||
)
|
||||
|
||||
# Only send file, no extra params
|
||||
data = {
|
||||
'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'video.mp4'),
|
||||
}
|
||||
response = client.post(
|
||||
'/api/video/to-gif',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
)
|
||||
assert response.status_code == 202
|
||||
# Defaults: start_time=0, duration=5, fps=10, width=480
|
||||
args = mock_delay.call_args[0]
|
||||
assert args[3] == 0 # start_time
|
||||
assert args[4] == 5 # duration
|
||||
assert args[5] == 10 # fps
|
||||
assert args[6] == 480 # width
|
||||
Reference in New Issue
Block a user