Files
SaaS-PDF/backend/tests/test_pdf_tools.py
Your Name 0ad2ba0f02 ميزة: تحديث صفحات الخصوصية والشروط مع تاريخ آخر تحديث ثابت وفترة احتفاظ ديناميكية بالملفات
ميزة: إضافة خدمة تحليلات لتكامل Google Analytics

اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية

إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم

ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين

إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات
2026-03-07 11:14:05 +02:00

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