ميزة: إضافة خدمة تحليلات لتكامل Google Analytics اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات
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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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_actor_file',
|
|
lambda f, allowed_types, actor: ('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 |