تم الانتهاء من آخر دفعة تحسينات على المشروع، وتشمل:
تحويل لوحة الإدارة الداخلية من 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:
@@ -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
175
backend/tests/test_admin.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
79
backend/tests/test_contact.py
Normal file
79
backend/tests/test_contact.py
Normal 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
|
||||
460
backend/tests/test_phase2_tools.py
Normal file
460
backend/tests/test_phase2_tools.py
Normal 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)
|
||||
52
backend/tests/test_stats.py
Normal file
52
backend/tests/test_stats.py
Normal 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}
|
||||
48
backend/tests/test_stripe.py
Normal file
48
backend/tests/test_stripe.py
Normal 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")
|
||||
Reference in New Issue
Block a user