- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة. - إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي. - تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts. - إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء. - تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
531 lines
19 KiB
Python
531 lines
19 KiB
Python
"""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 |