""" Tests for MED-009: Dynamic Watermark for Downloads This module contains unit tests for WatermarkService and integration tests for the download endpoint with watermark functionality. """ import pytest import uuid import os import io import tempfile import shutil from datetime import datetime from io import BytesIO from PIL import Image from app.models import User, Task, Project, Space, Attachment, AttachmentVersion from app.services.watermark_service import WatermarkService, watermark_service # ============================================================================= # Test Fixtures # ============================================================================= @pytest.fixture def test_user(db): """Create a test user for watermark tests.""" user = User( id=str(uuid.uuid4()), email="watermark.test@example.com", employee_id="EMP-WM001", name="Watermark Tester", role_id="00000000-0000-0000-0000-000000000003", is_active=True, is_system_admin=False, ) db.add(user) db.commit() return user @pytest.fixture def test_user_token(client, mock_redis, test_user): """Get a token for test user.""" from app.core.security import create_access_token, create_token_payload token_data = create_token_payload( user_id=test_user.id, email=test_user.email, role="engineer", department_id=None, is_system_admin=False, ) token = create_access_token(token_data) mock_redis.setex(f"session:{test_user.id}", 900, token) return token @pytest.fixture def test_space(db, test_user): """Create a test space.""" space = Space( id=str(uuid.uuid4()), name="Watermark Test Space", description="Test space for watermark tests", owner_id=test_user.id, ) db.add(space) db.commit() return space @pytest.fixture def test_project(db, test_space, test_user): """Create a test project.""" project = Project( id=str(uuid.uuid4()), space_id=test_space.id, title="Watermark Test Project", description="Test project for watermark tests", owner_id=test_user.id, ) db.add(project) db.commit() return project @pytest.fixture def test_task(db, test_project, test_user): """Create a test task.""" task = Task( id=str(uuid.uuid4()), project_id=test_project.id, title="Watermark Test Task", description="Test task for watermark tests", created_by=test_user.id, ) db.add(task) db.commit() return task @pytest.fixture def temp_upload_dir(): """Create a temporary upload directory.""" temp_dir = tempfile.mkdtemp() yield temp_dir shutil.rmtree(temp_dir) @pytest.fixture def sample_png_bytes(): """Create a sample PNG image as bytes.""" img = Image.new("RGB", (200, 200), color=(255, 255, 255)) output = io.BytesIO() img.save(output, format="PNG") output.seek(0) return output.getvalue() @pytest.fixture def sample_jpeg_bytes(): """Create a sample JPEG image as bytes.""" img = Image.new("RGB", (200, 200), color=(255, 255, 255)) output = io.BytesIO() img.save(output, format="JPEG") output.seek(0) return output.getvalue() @pytest.fixture def sample_pdf_bytes(): """Create a sample PDF as bytes.""" from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas buffer = io.BytesIO() c = canvas.Canvas(buffer, pagesize=letter) c.drawString(100, 750, "Test PDF Document") c.drawString(100, 700, "This is a test page for watermarking.") c.showPage() c.drawString(100, 750, "Page 2") c.drawString(100, 700, "Second page content.") c.showPage() c.save() buffer.seek(0) return buffer.getvalue() # ============================================================================= # Unit Tests for WatermarkService # ============================================================================= class TestWatermarkServiceUnit: """Unit tests for WatermarkService class.""" def test_format_watermark_text(self): """Test watermark text formatting with employee_id.""" test_time = datetime(2024, 1, 15, 10, 30, 45) text = WatermarkService._format_watermark_text( user_name="John Doe", employee_id="EMP001", download_time=test_time ) assert "John Doe" in text assert "EMP001" in text assert "2024-01-15 10:30:45" in text assert text == "John Doe (EMP001) - 2024-01-15 10:30:45" def test_format_watermark_text_without_employee_id(self): """Test that watermark text uses N/A when employee_id is not provided.""" test_time = datetime(2024, 1, 15, 10, 30, 45) text = WatermarkService._format_watermark_text( user_name="Jane Doe", employee_id=None, download_time=test_time ) assert "Jane Doe" in text assert "(N/A)" in text assert text == "Jane Doe (N/A) - 2024-01-15 10:30:45" def test_format_watermark_text_defaults_to_now(self): """Test that watermark text defaults to current time.""" text = WatermarkService._format_watermark_text( user_name="Jane Doe", employee_id="EMP002" ) assert "Jane Doe" in text assert "EMP002" in text # Should contain a date-like string assert "-" in text # Date separator def test_is_supported_image_png(self): """Test PNG is recognized as supported image.""" service = WatermarkService() assert service.is_supported_image("image/png") is True assert service.is_supported_image("IMAGE/PNG") is True def test_is_supported_image_jpeg(self): """Test JPEG is recognized as supported image.""" service = WatermarkService() assert service.is_supported_image("image/jpeg") is True assert service.is_supported_image("image/jpg") is True def test_is_supported_image_unsupported(self): """Test unsupported image formats are rejected.""" service = WatermarkService() assert service.is_supported_image("image/gif") is False assert service.is_supported_image("image/bmp") is False assert service.is_supported_image("image/webp") is False def test_is_supported_pdf(self): """Test PDF is recognized.""" service = WatermarkService() assert service.is_supported_pdf("application/pdf") is True assert service.is_supported_pdf("APPLICATION/PDF") is True def test_is_supported_pdf_negative(self): """Test non-PDF types are not recognized as PDF.""" service = WatermarkService() assert service.is_supported_pdf("application/json") is False assert service.is_supported_pdf("text/plain") is False def test_supports_watermark_images(self): """Test supports_watermark for images.""" service = WatermarkService() assert service.supports_watermark("image/png") is True assert service.supports_watermark("image/jpeg") is True def test_supports_watermark_pdf(self): """Test supports_watermark for PDF.""" service = WatermarkService() assert service.supports_watermark("application/pdf") is True def test_supports_watermark_unsupported(self): """Test supports_watermark for unsupported types.""" service = WatermarkService() assert service.supports_watermark("text/plain") is False assert service.supports_watermark("application/zip") is False assert service.supports_watermark("application/octet-stream") is False class TestImageWatermarking: """Unit tests for image watermarking functionality.""" def test_add_image_watermark_png(self, sample_png_bytes): """Test adding watermark to PNG image.""" test_time = datetime(2024, 1, 15, 10, 30, 45) result_bytes, output_format = watermark_service.add_image_watermark( image_bytes=sample_png_bytes, user_name="Test User", employee_id="EMP001", download_time=test_time ) # Verify output is valid image bytes assert len(result_bytes) > 0 assert output_format.lower() == "png" # Verify output is valid PNG image result_image = Image.open(io.BytesIO(result_bytes)) assert result_image.format == "PNG" assert result_image.size == (200, 200) def test_add_image_watermark_jpeg(self, sample_jpeg_bytes): """Test adding watermark to JPEG image.""" test_time = datetime(2024, 1, 15, 10, 30, 45) result_bytes, output_format = watermark_service.add_image_watermark( image_bytes=sample_jpeg_bytes, user_name="Test User", employee_id="EMP001", download_time=test_time ) # Verify output is valid image bytes assert len(result_bytes) > 0 assert output_format.lower() == "jpeg" # Verify output is valid JPEG image result_image = Image.open(io.BytesIO(result_bytes)) assert result_image.format == "JPEG" assert result_image.size == (200, 200) def test_add_image_watermark_preserves_dimensions(self, sample_png_bytes): """Test that watermarking preserves image dimensions.""" original = Image.open(io.BytesIO(sample_png_bytes)) original_size = original.size result_bytes, _ = watermark_service.add_image_watermark( image_bytes=sample_png_bytes, user_name="Test User", employee_id="EMP001" ) result = Image.open(io.BytesIO(result_bytes)) assert result.size == original_size def test_add_image_watermark_modifies_image(self, sample_png_bytes): """Test that watermark actually modifies the image.""" result_bytes, _ = watermark_service.add_image_watermark( image_bytes=sample_png_bytes, user_name="Test User", employee_id="EMP001" ) # The watermarked image should be different from original # (Note: size might differ slightly due to compression) # We verify the image data is actually different original = Image.open(io.BytesIO(sample_png_bytes)) result = Image.open(io.BytesIO(result_bytes)) # Convert to same mode for comparison original_rgb = original.convert("RGB") result_rgb = result.convert("RGB") # Compare pixel data - they should be different original_data = list(original_rgb.getdata()) result_data = list(result_rgb.getdata()) # At least some pixels should be different (watermark added) different_pixels = sum(1 for o, r in zip(original_data, result_data) if o != r) assert different_pixels > 0, "Watermark should modify image pixels" def test_add_image_watermark_large_image(self): """Test watermarking a larger image.""" # Create a larger image large_img = Image.new("RGB", (1920, 1080), color=(100, 150, 200)) output = io.BytesIO() large_img.save(output, format="PNG") large_bytes = output.getvalue() result_bytes, output_format = watermark_service.add_image_watermark( image_bytes=large_bytes, user_name="Large Image User", employee_id="EMP-LARGE" ) assert len(result_bytes) > 0 result_image = Image.open(io.BytesIO(result_bytes)) assert result_image.size == (1920, 1080) class TestPdfWatermarking: """Unit tests for PDF watermarking functionality.""" def test_add_pdf_watermark_basic(self, sample_pdf_bytes): """Test adding watermark to PDF.""" import fitz # PyMuPDF test_time = datetime(2024, 1, 15, 10, 30, 45) result_bytes = watermark_service.add_pdf_watermark( pdf_bytes=sample_pdf_bytes, user_name="PDF Test User", employee_id="EMP-PDF001", download_time=test_time ) # Verify output is valid PDF bytes assert len(result_bytes) > 0 # Verify output is valid PDF using PyMuPDF result_pdf = fitz.open(stream=result_bytes, filetype="pdf") assert len(result_pdf) == 2 result_pdf.close() def test_add_pdf_watermark_preserves_page_count(self, sample_pdf_bytes): """Test that watermarking preserves page count.""" import fitz # PyMuPDF original_pdf = fitz.open(stream=sample_pdf_bytes, filetype="pdf") original_page_count = len(original_pdf) original_pdf.close() result_bytes = watermark_service.add_pdf_watermark( pdf_bytes=sample_pdf_bytes, user_name="Test User", employee_id="EMP001" ) result_pdf = fitz.open(stream=result_bytes, filetype="pdf") assert len(result_pdf) == original_page_count result_pdf.close() def test_add_pdf_watermark_modifies_content(self, sample_pdf_bytes): """Test that watermark actually modifies the PDF content.""" result_bytes = watermark_service.add_pdf_watermark( pdf_bytes=sample_pdf_bytes, user_name="Modified User", employee_id="EMP-MOD" ) # The watermarked PDF should be different from original assert result_bytes != sample_pdf_bytes def test_add_pdf_watermark_single_page(self): """Test watermarking a single-page PDF.""" import fitz # PyMuPDF # Create single page PDF with PyMuPDF doc = fitz.open() page = doc.new_page(width=612, height=792) # Letter size page.insert_text(point=(100, 750), text="Single Page Document", fontsize=12) buffer = io.BytesIO() doc.save(buffer) doc.close() single_page_bytes = buffer.getvalue() result_bytes = watermark_service.add_pdf_watermark( pdf_bytes=single_page_bytes, user_name="Single Page User", employee_id="EMP-SINGLE" ) result_pdf = fitz.open(stream=result_bytes, filetype="pdf") assert len(result_pdf) == 1 result_pdf.close() def test_add_pdf_watermark_many_pages(self): """Test watermarking a multi-page PDF.""" import fitz # PyMuPDF # Create multi-page PDF with PyMuPDF doc = fitz.open() for i in range(5): page = doc.new_page(width=612, height=792) page.insert_text(point=(100, 750), text=f"Page {i + 1}", fontsize=12) buffer = io.BytesIO() doc.save(buffer) doc.close() multi_page_bytes = buffer.getvalue() result_bytes = watermark_service.add_pdf_watermark( pdf_bytes=multi_page_bytes, user_name="Multi Page User", employee_id="EMP-MULTI" ) result_pdf = fitz.open(stream=result_bytes, filetype="pdf") assert len(result_pdf) == 5 result_pdf.close() class TestWatermarkServiceConfiguration: """Tests for WatermarkService configuration constants.""" def test_default_opacity(self): """Test default watermark opacity.""" assert WatermarkService.WATERMARK_OPACITY == 0.3 def test_default_angle(self): """Test default watermark angle.""" assert WatermarkService.WATERMARK_ANGLE == -45 def test_default_font_size(self): """Test default watermark font size.""" assert WatermarkService.WATERMARK_FONT_SIZE == 24 def test_default_color(self): """Test default watermark color (gray).""" assert WatermarkService.WATERMARK_COLOR == (128, 128, 128) # ============================================================================= # Integration Tests for Download with Watermark # ============================================================================= class TestDownloadWithWatermark: """Integration tests for download endpoint with watermark.""" def test_download_png_with_watermark( self, client, test_user_token, test_task, db, monkeypatch, temp_upload_dir, sample_png_bytes ): """Test downloading PNG file applies watermark.""" from pathlib import Path from app.services.file_storage_service import file_storage_service monkeypatch.setattr("app.core.config.settings.UPLOAD_DIR", temp_upload_dir) monkeypatch.setattr(file_storage_service, "base_dir", Path(temp_upload_dir)) # Create attachment and version attachment_id = str(uuid.uuid4()) version_id = str(uuid.uuid4()) # Save the file to disk file_dir = os.path.join(temp_upload_dir, test_task.project_id, test_task.id, attachment_id, "v1") os.makedirs(file_dir, exist_ok=True) file_path = os.path.join(file_dir, "test.png") with open(file_path, "wb") as f: f.write(sample_png_bytes) relative_path = os.path.join(test_task.project_id, test_task.id, attachment_id, "v1", "test.png") attachment = Attachment( id=attachment_id, task_id=test_task.id, filename="test.png", original_filename="test.png", mime_type="image/png", file_size=len(sample_png_bytes), current_version=1, uploaded_by=test_task.created_by, ) db.add(attachment) version = AttachmentVersion( id=version_id, attachment_id=attachment_id, version=1, file_path=relative_path, file_size=len(sample_png_bytes), checksum="0" * 64, uploaded_by=test_task.created_by, ) db.add(version) db.commit() # Download the file response = client.get( f"/api/attachments/{attachment_id}/download", headers={"Authorization": f"Bearer {test_user_token}"}, ) assert response.status_code == 200 assert response.headers["content-type"] == "image/png" # Verify watermark was applied (image should be different) downloaded_image = Image.open(io.BytesIO(response.content)) original_image = Image.open(io.BytesIO(sample_png_bytes)) # Convert to comparable format downloaded_rgb = downloaded_image.convert("RGB") original_rgb = original_image.convert("RGB") downloaded_data = list(downloaded_rgb.getdata()) original_data = list(original_rgb.getdata()) # At least some pixels should be different (watermark present) different_pixels = sum(1 for o, d in zip(original_data, downloaded_data) if o != d) assert different_pixels > 0, "Downloaded image should have watermark" def test_download_pdf_with_watermark( self, client, test_user_token, test_task, db, monkeypatch, temp_upload_dir, sample_pdf_bytes ): """Test downloading PDF file applies watermark.""" from pathlib import Path from app.services.file_storage_service import file_storage_service monkeypatch.setattr("app.core.config.settings.UPLOAD_DIR", temp_upload_dir) monkeypatch.setattr(file_storage_service, "base_dir", Path(temp_upload_dir)) # Create attachment and version attachment_id = str(uuid.uuid4()) version_id = str(uuid.uuid4()) # Save the file to disk file_dir = os.path.join(temp_upload_dir, test_task.project_id, test_task.id, attachment_id, "v1") os.makedirs(file_dir, exist_ok=True) file_path = os.path.join(file_dir, "test.pdf") with open(file_path, "wb") as f: f.write(sample_pdf_bytes) relative_path = os.path.join(test_task.project_id, test_task.id, attachment_id, "v1", "test.pdf") attachment = Attachment( id=attachment_id, task_id=test_task.id, filename="test.pdf", original_filename="test.pdf", mime_type="application/pdf", file_size=len(sample_pdf_bytes), current_version=1, uploaded_by=test_task.created_by, ) db.add(attachment) version = AttachmentVersion( id=version_id, attachment_id=attachment_id, version=1, file_path=relative_path, file_size=len(sample_pdf_bytes), checksum="0" * 64, uploaded_by=test_task.created_by, ) db.add(version) db.commit() # Download the file response = client.get( f"/api/attachments/{attachment_id}/download", headers={"Authorization": f"Bearer {test_user_token}"}, ) assert response.status_code == 200 assert response.headers["content-type"] == "application/pdf" # Verify watermark was applied (PDF content should be different) assert response.content != sample_pdf_bytes, "Downloaded PDF should have watermark" def test_download_unsupported_file_no_watermark( self, client, test_user_token, test_task, db, monkeypatch, temp_upload_dir ): """Test downloading unsupported file type returns original without watermark.""" from pathlib import Path from app.services.file_storage_service import file_storage_service monkeypatch.setattr("app.core.config.settings.UPLOAD_DIR", temp_upload_dir) monkeypatch.setattr(file_storage_service, "base_dir", Path(temp_upload_dir)) # Create a text file text_content = b"This is a plain text file." attachment_id = str(uuid.uuid4()) version_id = str(uuid.uuid4()) # Save the file to disk file_dir = os.path.join(temp_upload_dir, test_task.project_id, test_task.id, attachment_id, "v1") os.makedirs(file_dir, exist_ok=True) file_path = os.path.join(file_dir, "test.txt") with open(file_path, "wb") as f: f.write(text_content) relative_path = os.path.join(test_task.project_id, test_task.id, attachment_id, "v1", "test.txt") attachment = Attachment( id=attachment_id, task_id=test_task.id, filename="test.txt", original_filename="test.txt", mime_type="text/plain", file_size=len(text_content), current_version=1, uploaded_by=test_task.created_by, ) db.add(attachment) version = AttachmentVersion( id=version_id, attachment_id=attachment_id, version=1, file_path=relative_path, file_size=len(text_content), checksum="0" * 64, uploaded_by=test_task.created_by, ) db.add(version) db.commit() # Download the file response = client.get( f"/api/attachments/{attachment_id}/download", headers={"Authorization": f"Bearer {test_user_token}"}, ) assert response.status_code == 200 # Content should be unchanged for unsupported types assert response.content == text_content def test_download_jpeg_with_watermark( self, client, test_user_token, test_task, db, monkeypatch, temp_upload_dir, sample_jpeg_bytes ): """Test downloading JPEG file applies watermark.""" from pathlib import Path from app.services.file_storage_service import file_storage_service monkeypatch.setattr("app.core.config.settings.UPLOAD_DIR", temp_upload_dir) monkeypatch.setattr(file_storage_service, "base_dir", Path(temp_upload_dir)) attachment_id = str(uuid.uuid4()) version_id = str(uuid.uuid4()) # Save the file to disk file_dir = os.path.join(temp_upload_dir, test_task.project_id, test_task.id, attachment_id, "v1") os.makedirs(file_dir, exist_ok=True) file_path = os.path.join(file_dir, "test.jpg") with open(file_path, "wb") as f: f.write(sample_jpeg_bytes) relative_path = os.path.join(test_task.project_id, test_task.id, attachment_id, "v1", "test.jpg") attachment = Attachment( id=attachment_id, task_id=test_task.id, filename="test.jpg", original_filename="test.jpg", mime_type="image/jpeg", file_size=len(sample_jpeg_bytes), current_version=1, uploaded_by=test_task.created_by, ) db.add(attachment) version = AttachmentVersion( id=version_id, attachment_id=attachment_id, version=1, file_path=relative_path, file_size=len(sample_jpeg_bytes), checksum="0" * 64, uploaded_by=test_task.created_by, ) db.add(version) db.commit() # Download the file response = client.get( f"/api/attachments/{attachment_id}/download", headers={"Authorization": f"Bearer {test_user_token}"}, ) assert response.status_code == 200 assert response.headers["content-type"] == "image/jpeg" # Verify the response is a valid JPEG downloaded_image = Image.open(io.BytesIO(response.content)) assert downloaded_image.format == "JPEG" class TestWatermarkErrorHandling: """Tests for watermark error handling and graceful degradation.""" def test_watermark_service_singleton_exists(self): """Test that watermark_service singleton is available.""" assert watermark_service is not None assert isinstance(watermark_service, WatermarkService) def test_invalid_image_bytes_graceful_handling(self): """Test handling of invalid image bytes.""" invalid_bytes = b"not an image" with pytest.raises(Exception): # Should raise an exception for invalid image data watermark_service.add_image_watermark( image_bytes=invalid_bytes, user_name="Test", employee_id="EMP001" ) def test_invalid_pdf_bytes_graceful_handling(self): """Test handling of invalid PDF bytes.""" invalid_bytes = b"not a pdf" with pytest.raises(Exception): # Should raise an exception for invalid PDF data watermark_service.add_pdf_watermark( pdf_bytes=invalid_bytes, user_name="Test", employee_id="EMP001" )