تم الانتهاء من آخر دفعة تحسينات على المشروع، وتشمل:

تحويل لوحة الإدارة الداخلية من secret header إلى session auth حقيقي مع صلاحيات admin.
إضافة دعم إدارة الأدوار من داخل لوحة الإدارة نفسها، مع حماية الحسابات المعتمدة عبر INTERNAL_ADMIN_EMAILS.
تحسين بيانات المستخدم في الواجهة والباكند لتشمل role وis_allowlisted_admin.
إضافة اختبار frontend مخصص لصفحة /internal/admin بدل الاعتماد فقط على build واختبار routes.
تحسين إضافي في الأداء عبر إزالة الاعتماد على pdfjs-dist/pdf.worker في عدّ صفحات PDF واستبداله بمسار أخف باستخدام pdf-lib.
تحسين تقسيم الـ chunks في build لتقليل أثر الحزم الكبيرة وفصل أجزاء مثل network, icons, pdf-core, وeditor.
التحقق الذي تم:

نجاح build للواجهة.
نجاح اختبار صفحة الإدارة الداخلية في frontend.
نجاح اختبارات auth/admin في backend.
نجاح full backend suite مسبقًا مع EXIT:0.
ولو تريد نسخة أقصر جدًا، استخدم هذه:

آخر التحديثات:
تم تحسين نظام الإدارة الداخلية ليعتمد على صلاحيات وجلسات حقيقية بدل secret header، مع إضافة إدارة أدوار من لوحة admin نفسها، وإضافة اختبارات frontend مخصصة للوحة، وتحسين أداء الواجهة عبر إزالة pdf.worker وتحسين تقسيم الـ chunks في build. جميع الاختبارات والتحققات الأساسية المطلوبة نجح
This commit is contained in:
Your Name
2026-03-16 13:50:45 +02:00
parent b5d97324a9
commit 957d37838c
85 changed files with 9915 additions and 119 deletions

View File

@@ -9,6 +9,8 @@ from app.services.account_service import init_account_db
from app.services.rating_service import init_ratings_db
from app.services.ai_cost_service import init_ai_cost_db
from app.services.site_assistant_service import init_site_assistant_db
from app.services.contact_service import init_contact_db
from app.services.stripe_service import init_stripe_db
@pytest.fixture
@@ -35,6 +37,8 @@ def app():
init_ratings_db()
init_ai_cost_db()
init_site_assistant_db()
init_contact_db()
init_stripe_db()
# Create temp directories
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

175
backend/tests/test_admin.py Normal file
View File

@@ -0,0 +1,175 @@
"""Tests for internal admin dashboard endpoints."""
from app.services.account_service import create_user, record_file_history, set_user_role, update_user_plan
from app.services.contact_service import save_message
from app.services.rating_service import submit_rating
class TestInternalAdminRoutes:
def test_overview_requires_authenticated_admin(self, client):
response = client.get("/api/internal/admin/overview")
assert response.status_code == 401
def test_overview_rejects_non_admin_user(self, app, client):
with app.app_context():
create_user("member@example.com", "testpass123")
login_response = client.post(
"/api/auth/login",
json={"email": "member@example.com", "password": "testpass123"},
)
assert login_response.status_code == 200
response = client.get("/api/internal/admin/overview")
assert response.status_code == 403
def test_overview_returns_operational_summary(self, app, client):
with app.app_context():
first_user = create_user("admin-a@example.com", "testpass123")
second_user = create_user("admin-b@example.com", "testpass123")
set_user_role(first_user["id"], "admin")
update_user_plan(second_user["id"], "pro")
record_file_history(
user_id=first_user["id"],
tool="compress-pdf",
original_filename="one.pdf",
output_filename="one-small.pdf",
status="completed",
download_url="https://example.com/one-small.pdf",
)
record_file_history(
user_id=second_user["id"],
tool="repair-pdf",
original_filename="broken.pdf",
output_filename=None,
status="failed",
download_url=None,
metadata={"error": "Repair failed."},
)
submit_rating("compress-pdf", 5, fingerprint="admin-rating")
message = save_message("Admin User", "ops@example.com", "bug", "Need help", "Broken upload")
login_response = client.post(
"/api/auth/login",
json={"email": "admin-a@example.com", "password": "testpass123"},
)
assert login_response.status_code == 200
response = client.get("/api/internal/admin/overview")
assert response.status_code == 200
data = response.get_json()
assert data["users"]["total"] == 2
assert data["users"]["pro"] == 1
assert data["processing"]["total_files_processed"] == 2
assert data["processing"]["failed_files"] == 1
assert data["ratings"]["rating_count"] == 1
assert data["contacts"]["unread_messages"] == 1
assert data["contacts"]["recent"][0]["id"] == message["id"]
assert data["recent_failures"][0]["tool"] == "repair-pdf"
def test_contacts_can_be_marked_read(self, app, client):
with app.app_context():
admin_user = create_user("admin-reader@example.com", "testpass123")
set_user_role(admin_user["id"], "admin")
message = save_message("Reader", "reader@example.com", "general", "Hello", "Please review")
login_response = client.post(
"/api/auth/login",
json={"email": "admin-reader@example.com", "password": "testpass123"},
)
assert login_response.status_code == 200
mark_response = client.post(f"/api/internal/admin/contacts/{message['id']}/read")
assert mark_response.status_code == 200
contacts_response = client.get("/api/internal/admin/contacts")
assert contacts_response.status_code == 200
contacts_data = contacts_response.get_json()
assert contacts_data["unread"] == 0
assert contacts_data["items"][0]["is_read"] is True
def test_user_plan_can_be_updated(self, app, client):
with app.app_context():
admin_user = create_user("admin-plan@example.com", "testpass123")
user = create_user("plan-change@example.com", "testpass123")
set_user_role(admin_user["id"], "admin")
login_response = client.post(
"/api/auth/login",
json={"email": "admin-plan@example.com", "password": "testpass123"},
)
assert login_response.status_code == 200
response = client.post(
f"/api/internal/admin/users/{user['id']}/plan",
json={"plan": "pro"},
)
assert response.status_code == 200
data = response.get_json()
assert data["user"]["plan"] == "pro"
def test_user_role_can_be_updated(self, app, client):
with app.app_context():
admin_user = create_user("admin-role@example.com", "testpass123")
user = create_user("member-role@example.com", "testpass123")
set_user_role(admin_user["id"], "admin")
login_response = client.post(
"/api/auth/login",
json={"email": "admin-role@example.com", "password": "testpass123"},
)
assert login_response.status_code == 200
response = client.post(
f"/api/internal/admin/users/{user['id']}/role",
json={"role": "admin"},
)
assert response.status_code == 200
data = response.get_json()
assert data["user"]["role"] == "admin"
def test_allowlisted_admin_role_cannot_be_changed(self, app, client):
app.config["INTERNAL_ADMIN_EMAILS"] = ("bootstrap-admin@example.com",)
with app.app_context():
actor = create_user("actor-admin@example.com", "testpass123")
bootstrap = create_user("bootstrap-admin@example.com", "testpass123")
set_user_role(actor["id"], "admin")
login_response = client.post(
"/api/auth/login",
json={"email": "actor-admin@example.com", "password": "testpass123"},
)
assert login_response.status_code == 200
response = client.post(
f"/api/internal/admin/users/{bootstrap['id']}/role",
json={"role": "user"},
)
assert response.status_code == 400
assert "INTERNAL_ADMIN_EMAILS" in response.get_json()["error"]
def test_admin_cannot_remove_own_role(self, app, client):
with app.app_context():
admin_user = create_user("self-admin@example.com", "testpass123")
set_user_role(admin_user["id"], "admin")
login_response = client.post(
"/api/auth/login",
json={"email": "self-admin@example.com", "password": "testpass123"},
)
assert login_response.status_code == 200
response = client.post(
f"/api/internal/admin/users/{admin_user['id']}/role",
json={"role": "user"},
)
assert response.status_code == 400
assert "cannot remove your own admin role" in response.get_json()["error"].lower()

View File

@@ -12,6 +12,18 @@ class TestAuthRoutes:
data = response.get_json()
assert data['user']['email'] == 'user@example.com'
assert data['user']['plan'] == 'free'
assert data['user']['role'] == 'user'
def test_register_assigns_admin_role_for_allowlisted_email(self, app, client):
app.config['INTERNAL_ADMIN_EMAILS'] = ('admin@example.com',)
response = client.post(
'/api/auth/register',
json={'email': 'admin@example.com', 'password': 'secretpass123'},
)
assert response.status_code == 201
assert response.get_json()['user']['role'] == 'admin'
def test_register_duplicate_email(self, client):
client.post(

View File

@@ -0,0 +1,79 @@
"""Tests for the contact form endpoint."""
import pytest
class TestContactSubmission:
"""Tests for POST /api/contact/submit."""
def test_submit_success(self, client):
response = client.post("/api/contact/submit", json={
"name": "Test User",
"email": "test@example.com",
"category": "general",
"subject": "Test Subject",
"message": "This is a test message body.",
})
assert response.status_code == 201
data = response.get_json()
assert data["message"] == "Message sent successfully."
assert "id" in data
assert "created_at" in data
def test_submit_missing_name(self, client):
response = client.post("/api/contact/submit", json={
"email": "test@example.com",
"subject": "Test",
"message": "Body",
})
assert response.status_code == 400
assert "Name" in response.get_json()["error"]
def test_submit_invalid_email(self, client):
response = client.post("/api/contact/submit", json={
"name": "User",
"email": "not-an-email",
"subject": "Test",
"message": "Body",
})
assert response.status_code == 400
assert "email" in response.get_json()["error"].lower()
def test_submit_missing_subject(self, client):
response = client.post("/api/contact/submit", json={
"name": "User",
"email": "test@example.com",
"subject": "",
"message": "Body",
})
assert response.status_code == 400
assert "Subject" in response.get_json()["error"]
def test_submit_missing_message(self, client):
response = client.post("/api/contact/submit", json={
"name": "User",
"email": "test@example.com",
"subject": "Test",
"message": "",
})
assert response.status_code == 400
assert "Message" in response.get_json()["error"]
def test_submit_bug_category(self, client):
response = client.post("/api/contact/submit", json={
"name": "Bug Reporter",
"email": "bug@example.com",
"category": "bug",
"subject": "Found a bug",
"message": "The merge tool crashes on large files.",
})
assert response.status_code == 201
def test_submit_invalid_category_defaults_to_general(self, client):
response = client.post("/api/contact/submit", json={
"name": "User",
"email": "test@example.com",
"category": "hacking",
"subject": "Test",
"message": "Body text here.",
})
assert response.status_code == 201

View File

@@ -0,0 +1,460 @@
"""Tests for Phase 2 routes — PDF Conversion, PDF Extra, Image Extra, Barcode."""
import io
import json
import os
import tempfile
from unittest.mock import patch, MagicMock
import pytest
def _barcode_available():
"""Check if python-barcode is installed."""
try:
import barcode # noqa: F401
return True
except ImportError:
return False
# =========================================================================
# Helpers
# =========================================================================
def _make_pdf():
"""Minimal valid PDF bytes."""
return (
b"%PDF-1.4\n1 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\n0000000000 65535 f \n0000000009 00000 n \n"
b"0000000058 00000 n \n0000000115 00000 n \n"
b"trailer<</Root 1 0 R/Size 4>>\nstartxref\n190\n%%EOF"
)
def _make_png():
"""Minimal valid PNG bytes."""
return (
b"\x89PNG\r\n\x1a\n"
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 _mock_route(monkeypatch, route_module, task_name, validator_name='validate_actor_file'):
"""Mock validate + generate_safe_path + celery task for a route module."""
mock_task = MagicMock()
mock_task.id = 'mock-task-id'
tmp_dir = tempfile.mkdtemp()
save_path = os.path.join(tmp_dir, 'mock_file')
monkeypatch.setattr(
f'app.routes.{route_module}.validate_actor_file',
lambda f, allowed_types, actor: ('test_file', 'pdf'),
)
monkeypatch.setattr(
f'app.routes.{route_module}.generate_safe_path',
lambda ext, folder_type: ('mock-task-id', save_path),
)
mock_delay = MagicMock(return_value=mock_task)
monkeypatch.setattr(f'app.routes.{route_module}.{task_name}.delay', mock_delay)
return mock_task, mock_delay
# =========================================================================
# PDF Convert Routes — /api/convert
# =========================================================================
class TestPdfToPptx:
def test_no_file(self, client):
resp = client.post('/api/convert/pdf-to-pptx')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
_, mock_delay = _mock_route(monkeypatch, 'pdf_convert', 'pdf_to_pptx_task')
resp = client.post('/api/convert/pdf-to-pptx', data={
'file': (io.BytesIO(_make_pdf()), 'test.pdf'),
}, content_type='multipart/form-data')
assert resp.status_code == 202
data = resp.get_json()
assert data['task_id'] == 'mock-task-id'
mock_delay.assert_called_once()
class TestExcelToPdf:
def test_no_file(self, client):
resp = client.post('/api/convert/excel-to-pdf')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
mock_task = MagicMock()
mock_task.id = 'mock-task-id'
tmp_dir = tempfile.mkdtemp()
save_path = os.path.join(tmp_dir, 'mock.xlsx')
monkeypatch.setattr(
'app.routes.pdf_convert.validate_actor_file',
lambda f, allowed_types, actor: ('test.xlsx', 'xlsx'),
)
monkeypatch.setattr(
'app.routes.pdf_convert.generate_safe_path',
lambda ext, folder_type: ('mock-task-id', save_path),
)
mock_delay = MagicMock(return_value=mock_task)
monkeypatch.setattr('app.routes.pdf_convert.excel_to_pdf_task.delay', mock_delay)
# Create a file with xlsx content type
resp = client.post('/api/convert/excel-to-pdf', data={
'file': (io.BytesIO(b'PK\x03\x04' + b'\x00' * 100), 'test.xlsx'),
}, content_type='multipart/form-data')
assert resp.status_code == 202
assert resp.get_json()['task_id'] == 'mock-task-id'
class TestPptxToPdf:
def test_no_file(self, client):
resp = client.post('/api/convert/pptx-to-pdf')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
mock_task = MagicMock()
mock_task.id = 'mock-task-id'
tmp_dir = tempfile.mkdtemp()
save_path = os.path.join(tmp_dir, 'mock.pptx')
monkeypatch.setattr(
'app.routes.pdf_convert.validate_actor_file',
lambda f, allowed_types, actor: ('test.pptx', 'pptx'),
)
monkeypatch.setattr(
'app.routes.pdf_convert.generate_safe_path',
lambda ext, folder_type: ('mock-task-id', save_path),
)
mock_delay = MagicMock(return_value=mock_task)
monkeypatch.setattr('app.routes.pdf_convert.pptx_to_pdf_task.delay', mock_delay)
resp = client.post('/api/convert/pptx-to-pdf', data={
'file': (io.BytesIO(b'PK\x03\x04' + b'\x00' * 100), 'test.pptx'),
}, content_type='multipart/form-data')
assert resp.status_code == 202
assert resp.get_json()['task_id'] == 'mock-task-id'
class TestSignPdf:
def test_no_files(self, client):
resp = client.post('/api/convert/sign')
assert resp.status_code == 400
def test_missing_signature(self, client):
resp = client.post('/api/convert/sign', data={
'file': (io.BytesIO(_make_pdf()), 'test.pdf'),
}, content_type='multipart/form-data')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
mock_task = MagicMock()
mock_task.id = 'mock-task-id'
tmp_dir = tempfile.mkdtemp()
monkeypatch.setattr(
'app.routes.pdf_convert.validate_actor_file',
lambda f, allowed_types, actor: ('test.pdf', 'pdf'),
)
monkeypatch.setattr(
'app.routes.pdf_convert.generate_safe_path',
lambda ext, folder_type: ('mock-task-id', os.path.join(tmp_dir, f'mock.{ext}')),
)
mock_delay = MagicMock(return_value=mock_task)
monkeypatch.setattr('app.routes.pdf_convert.sign_pdf_task.delay', mock_delay)
resp = client.post('/api/convert/sign', data={
'file': (io.BytesIO(_make_pdf()), 'test.pdf'),
'signature': (io.BytesIO(_make_png()), 'sig.png'),
'page': '1',
}, content_type='multipart/form-data')
assert resp.status_code == 202
assert resp.get_json()['task_id'] == 'mock-task-id'
# =========================================================================
# PDF Extra Routes — /api/pdf-tools
# =========================================================================
class TestCropPdf:
def test_no_file(self, client):
resp = client.post('/api/pdf-tools/crop')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
_, mock_delay = _mock_route(monkeypatch, 'pdf_extra', 'crop_pdf_task')
resp = client.post('/api/pdf-tools/crop', data={
'file': (io.BytesIO(_make_pdf()), 'test.pdf'),
'left': '10', 'right': '10', 'top': '20', 'bottom': '20',
}, content_type='multipart/form-data')
assert resp.status_code == 202
assert resp.get_json()['task_id'] == 'mock-task-id'
mock_delay.assert_called_once()
class TestFlattenPdf:
def test_no_file(self, client):
resp = client.post('/api/pdf-tools/flatten')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
_, mock_delay = _mock_route(monkeypatch, 'pdf_extra', 'flatten_pdf_task')
resp = client.post('/api/pdf-tools/flatten', data={
'file': (io.BytesIO(_make_pdf()), 'test.pdf'),
}, content_type='multipart/form-data')
assert resp.status_code == 202
mock_delay.assert_called_once()
class TestRepairPdf:
def test_no_file(self, client):
resp = client.post('/api/pdf-tools/repair')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
_, mock_delay = _mock_route(monkeypatch, 'pdf_extra', 'repair_pdf_task')
resp = client.post('/api/pdf-tools/repair', data={
'file': (io.BytesIO(_make_pdf()), 'test.pdf'),
}, content_type='multipart/form-data')
assert resp.status_code == 202
mock_delay.assert_called_once()
class TestEditMetadata:
def test_no_file(self, client):
resp = client.post('/api/pdf-tools/metadata')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
_, mock_delay = _mock_route(monkeypatch, 'pdf_extra', 'edit_metadata_task')
resp = client.post('/api/pdf-tools/metadata', data={
'file': (io.BytesIO(_make_pdf()), 'test.pdf'),
'title': 'Test Title',
'author': 'Test Author',
}, content_type='multipart/form-data')
assert resp.status_code == 202
mock_delay.assert_called_once()
# =========================================================================
# Image Extra Routes — /api/image
# =========================================================================
class TestImageCrop:
def test_no_file(self, client):
resp = client.post('/api/image/crop')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
mock_task = MagicMock()
mock_task.id = 'mock-task-id'
tmp_dir = tempfile.mkdtemp()
save_path = os.path.join(tmp_dir, 'mock.png')
monkeypatch.setattr(
'app.routes.image_extra.validate_actor_file',
lambda f, allowed_types, actor: ('test.png', 'png'),
)
monkeypatch.setattr(
'app.routes.image_extra.generate_safe_path',
lambda ext, folder_type: ('mock-task-id', save_path),
)
mock_delay = MagicMock(return_value=mock_task)
monkeypatch.setattr('app.routes.image_extra.crop_image_task.delay', mock_delay)
resp = client.post('/api/image/crop', data={
'file': (io.BytesIO(_make_png()), 'test.png'),
'left': '0', 'top': '0', 'right': '100', 'bottom': '100',
}, content_type='multipart/form-data')
assert resp.status_code == 202
assert resp.get_json()['task_id'] == 'mock-task-id'
class TestImageRotateFlip:
def test_no_file(self, client):
resp = client.post('/api/image/rotate-flip')
assert resp.status_code == 400
def test_success(self, client, monkeypatch):
mock_task = MagicMock()
mock_task.id = 'mock-task-id'
tmp_dir = tempfile.mkdtemp()
save_path = os.path.join(tmp_dir, 'mock.png')
monkeypatch.setattr(
'app.routes.image_extra.validate_actor_file',
lambda f, allowed_types, actor: ('test.png', 'png'),
)
monkeypatch.setattr(
'app.routes.image_extra.generate_safe_path',
lambda ext, folder_type: ('mock-task-id', save_path),
)
mock_delay = MagicMock(return_value=mock_task)
monkeypatch.setattr('app.routes.image_extra.rotate_flip_image_task.delay', mock_delay)
resp = client.post('/api/image/rotate-flip', data={
'file': (io.BytesIO(_make_png()), 'test.png'),
'rotation': '90',
'flip_horizontal': 'true',
}, content_type='multipart/form-data')
assert resp.status_code == 202
assert resp.get_json()['task_id'] == 'mock-task-id'
# =========================================================================
# Barcode Routes — /api/barcode
# =========================================================================
class TestBarcodeGenerate:
def test_no_data(self, client):
resp = client.post('/api/barcode/generate',
data=json.dumps({}),
content_type='application/json')
assert resp.status_code == 400
def test_success_json(self, client, monkeypatch):
mock_task = MagicMock()
mock_task.id = 'mock-task-id'
tmp_dir = tempfile.mkdtemp()
monkeypatch.setattr(
'app.routes.barcode.generate_safe_path',
lambda ext, folder_type: ('mock-task-id', os.path.join(tmp_dir, 'mock.png')),
)
mock_delay = MagicMock(return_value=mock_task)
monkeypatch.setattr('app.routes.barcode.generate_barcode_task.delay', mock_delay)
resp = client.post('/api/barcode/generate',
data=json.dumps({'data': '12345', 'barcode_type': 'code128'}),
content_type='application/json')
assert resp.status_code == 202
assert resp.get_json()['task_id'] == 'mock-task-id'
def test_invalid_barcode_type(self, client):
resp = client.post('/api/barcode/generate',
data=json.dumps({'data': '12345', 'type': 'invalid_type'}),
content_type='application/json')
assert resp.status_code == 400
# =========================================================================
# Service unit tests
# =========================================================================
class TestBarcodeService:
@pytest.mark.skipif(
not _barcode_available(),
reason='python-barcode not installed'
)
def test_generate_barcode(self, app):
from app.services.barcode_service import generate_barcode
with app.app_context():
tmp_dir = tempfile.mkdtemp()
output_path = os.path.join(tmp_dir, 'test_barcode')
result = generate_barcode('12345678', 'code128', output_path, 'png')
assert 'output_path' in result
assert os.path.exists(result['output_path'])
def test_invalid_barcode_type(self, app):
from app.services.barcode_service import generate_barcode, BarcodeGenerationError
with app.app_context():
tmp_dir = tempfile.mkdtemp()
output_path = os.path.join(tmp_dir, 'test_barcode')
with pytest.raises(BarcodeGenerationError):
generate_barcode('12345', 'nonexistent_type', output_path, 'png')
class TestPdfExtraService:
def test_edit_metadata(self, app):
from app.services.pdf_extra_service import edit_pdf_metadata
with app.app_context():
tmp_dir = tempfile.mkdtemp()
input_path = os.path.join(tmp_dir, 'input.pdf')
output_path = os.path.join(tmp_dir, 'output.pdf')
with open(input_path, 'wb') as f:
f.write(_make_pdf())
edit_pdf_metadata(input_path, output_path, title='Test Title', author='Test Author')
assert os.path.exists(output_path)
assert os.path.getsize(output_path) > 0
def test_flatten_pdf(self, app):
from app.services.pdf_extra_service import flatten_pdf
with app.app_context():
tmp_dir = tempfile.mkdtemp()
input_path = os.path.join(tmp_dir, 'input.pdf')
output_path = os.path.join(tmp_dir, 'output.pdf')
with open(input_path, 'wb') as f:
f.write(_make_pdf())
flatten_pdf(input_path, output_path)
assert os.path.exists(output_path)
def test_repair_pdf(self, app):
from app.services.pdf_extra_service import repair_pdf
with app.app_context():
tmp_dir = tempfile.mkdtemp()
input_path = os.path.join(tmp_dir, 'input.pdf')
output_path = os.path.join(tmp_dir, 'output.pdf')
with open(input_path, 'wb') as f:
f.write(_make_pdf())
repair_pdf(input_path, output_path)
assert os.path.exists(output_path)
def test_crop_pdf(self, app):
from app.services.pdf_extra_service import crop_pdf
with app.app_context():
tmp_dir = tempfile.mkdtemp()
input_path = os.path.join(tmp_dir, 'input.pdf')
output_path = os.path.join(tmp_dir, 'output.pdf')
with open(input_path, 'wb') as f:
f.write(_make_pdf())
crop_pdf(input_path, output_path, margin_left=10, margin_right=10, margin_top=10, margin_bottom=10)
assert os.path.exists(output_path)
class TestImageExtraService:
def test_rotate_flip(self, app):
from app.services.image_extra_service import rotate_flip_image
from PIL import Image
with app.app_context():
tmp_dir = tempfile.mkdtemp()
input_path = os.path.join(tmp_dir, 'input.png')
output_path = os.path.join(tmp_dir, 'output.png')
img = Image.new('RGB', (100, 100), color='red')
img.save(input_path)
rotate_flip_image(input_path, output_path, rotation=90)
assert os.path.exists(output_path)
result = Image.open(output_path)
assert result.size == (100, 100)
def test_crop_image(self, app):
from app.services.image_extra_service import crop_image
from PIL import Image
with app.app_context():
tmp_dir = tempfile.mkdtemp()
input_path = os.path.join(tmp_dir, 'input.png')
output_path = os.path.join(tmp_dir, 'output.png')
img = Image.new('RGB', (200, 200), color='blue')
img.save(input_path)
crop_image(input_path, output_path, left=10, top=10, right=100, bottom=100)
assert os.path.exists(output_path)
result = Image.open(output_path)
assert result.size == (90, 90)
def test_crop_invalid_coords(self, app):
from app.services.image_extra_service import crop_image, ImageExtraError
from PIL import Image
with app.app_context():
tmp_dir = tempfile.mkdtemp()
input_path = os.path.join(tmp_dir, 'input.png')
output_path = os.path.join(tmp_dir, 'output.png')
img = Image.new('RGB', (100, 100), color='blue')
img.save(input_path)
with __import__('pytest').raises(ImageExtraError):
crop_image(input_path, output_path, left=100, top=0, right=50, bottom=100)

View File

@@ -0,0 +1,52 @@
"""Tests for public stats summary endpoint."""
from app.services.account_service import create_user, record_file_history
from app.services.rating_service import submit_rating
class TestStatsSummary:
def test_summary_returns_processing_and_rating_totals(self, app, client):
with app.app_context():
user = create_user("stats@example.com", "testpass123")
record_file_history(
user_id=user["id"],
tool="compress-pdf",
original_filename="input.pdf",
output_filename="output.pdf",
status="completed",
download_url="https://example.com/file.pdf",
)
record_file_history(
user_id=user["id"],
tool="compress-pdf",
original_filename="input-2.pdf",
output_filename="output-2.pdf",
status="completed",
download_url="https://example.com/file-2.pdf",
)
record_file_history(
user_id=user["id"],
tool="repair-pdf",
original_filename="broken.pdf",
output_filename=None,
status="failed",
download_url=None,
metadata={"error": "Repair failed."},
)
submit_rating("compress-pdf", 5, fingerprint="stats-a")
submit_rating("repair-pdf", 4, fingerprint="stats-b")
response = client.get("/api/stats/summary")
assert response.status_code == 200
data = response.get_json()
assert data["total_files_processed"] == 3
assert data["completed_files"] == 2
assert data["failed_files"] == 1
assert data["success_rate"] == 66.7
assert data["files_last_24h"] == 3
assert data["rating_count"] == 2
assert data["average_rating"] == 4.5
assert data["top_tools"][0] == {"tool": "compress-pdf", "count": 2}

View File

@@ -0,0 +1,48 @@
"""Tests for Stripe payment routes."""
import pytest
from unittest.mock import patch, MagicMock
class TestStripeRoutes:
"""Tests for /api/stripe/ endpoints."""
def _login(self, client, email="stripe@test.com", password="testpass123"):
"""Register and login a user."""
client.post("/api/auth/register", json={
"email": email, "password": password,
})
resp = client.post("/api/auth/login", json={
"email": email, "password": password,
})
return resp.get_json()
def test_checkout_requires_auth(self, client):
response = client.post("/api/stripe/create-checkout-session", json={
"billing": "monthly",
})
assert response.status_code == 401
def test_checkout_no_stripe_key(self, client, app):
"""When STRIPE_PRICE_ID_PRO_MONTHLY is not set, return 503."""
self._login(client)
app.config["STRIPE_PRICE_ID_PRO_MONTHLY"] = ""
app.config["STRIPE_PRICE_ID_PRO_YEARLY"] = ""
response = client.post("/api/stripe/create-checkout-session", json={
"billing": "monthly",
})
assert response.status_code == 503
def test_portal_requires_auth(self, client):
response = client.post("/api/stripe/create-portal-session")
assert response.status_code == 401
def test_webhook_missing_signature(self, client):
"""Webhook without config returns ignored status."""
response = client.post(
"/api/stripe/webhook",
data=b'{}',
headers={"Stripe-Signature": "test_sig"},
)
data = response.get_json()
# Without webhook secret, it should be ignored
assert data["status"] in ("ignored", "error")