diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 932ba616..3c13cf4d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -11,11 +11,11 @@ from django.utils.functional import lazy from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ -from core.services import mime_types import magic from rest_framework import serializers from core import choices, enums, models, utils, validators +from core.services import mime_types from core.services.ai_services import AI_ACTIONS from core.services.converter_services import ( ConversionError, @@ -441,9 +441,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer): try: document_content = Converter().convert( - validated_data["content"], - mime_types.MARKDOWN, - mime_types.YJS + validated_data["content"], mime_types.MARKDOWN, mime_types.YJS ) except ConversionError as err: raise serializers.ValidationError( diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7a61bc0f..b0cc3c9b 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -43,15 +43,19 @@ from rest_framework.permissions import AllowAny from core import authentication, choices, enums, models from core.api.filters import remove_accents +from core.services import mime_types from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService from core.services.converter_services import ( ConversionError, - ServiceUnavailableError as YProviderServiceUnavailableError, - ValidationError as YProviderValidationError, Converter, ) -from core.services import mime_types +from core.services.converter_services import ( + ServiceUnavailableError as YProviderServiceUnavailableError, +) +from core.services.converter_services import ( + ValidationError as YProviderValidationError, +) from core.services.search_indexers import ( get_document_indexer, get_visited_document_ids_of, @@ -538,7 +542,7 @@ class DocumentViewSet( converted_content = converter.convert( file_content, content_type=uploaded_file.content_type, - accept=mime_types.YJS + accept=mime_types.YJS, ) serializer.validated_data["content"] = converted_content serializer.validated_data["title"] = uploaded_file.name diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py index 8790bf9a..91dd6e5d 100644 --- a/src/backend/core/services/converter_services.py +++ b/src/backend/core/services/converter_services.py @@ -1,14 +1,15 @@ """Y-Provider API services.""" +import typing from base64 import b64encode from django.conf import settings import requests -import typing from core.services import mime_types + class ConversionError(Exception): """Base exception for conversion-related errors.""" @@ -22,10 +23,15 @@ class ServiceUnavailableError(ConversionError): class ConverterProtocol(typing.Protocol): - def convert(self, text, content_type, accept): ... + """Protocol for converter classes.""" + + def convert(self, text, content_type, accept): + """Convert content from one format to another.""" class Converter: + """Orchestrates conversion between different formats using specialized converters.""" + docspec: ConverterProtocol ydoc: ConverterProtocol @@ -33,17 +39,17 @@ class Converter: self.docspec = DocSpecConverter() self.ydoc = YdocConverter() - def convert(self, input, content_type, accept): + def convert(self, data, content_type, accept): """Convert input into other formats using external microservices.""" - + if content_type == mime_types.DOCX and accept == mime_types.YJS: return self.convert( - self.docspec.convert(input, mime_types.DOCX, mime_types.BLOCKNOTE), + self.docspec.convert(data, mime_types.DOCX, mime_types.BLOCKNOTE), mime_types.BLOCKNOTE, - mime_types.YJS + mime_types.YJS, ) - - return self.ydoc.convert(input, content_type, accept) + + return self.ydoc.convert(data, content_type, accept) class DocSpecConverter: @@ -61,15 +67,17 @@ class DocSpecConverter: ) response.raise_for_status() return response - + def convert(self, data, content_type, accept): """Convert a Document to BlockNote.""" if not data: raise ValidationError("Input data cannot be empty") - + if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE: - raise ValidationError(f"Conversion from {content_type} to {accept} is not supported.") - + raise ValidationError( + f"Conversion from {content_type} to {accept} is not supported." + ) + try: return self._request(settings.DOCSPEC_API_URL, data, content_type).content except requests.RequestException as err: @@ -103,9 +111,7 @@ class YdocConverter: response.raise_for_status() return response - def convert( - self, text, content_type=mime_types.MARKDOWN, accept=mime_types.YJS - ): + def convert(self, text, content_type=mime_types.MARKDOWN, accept=mime_types.YJS): """Convert a Markdown text into our internal format using an external microservice.""" if not text: diff --git a/src/backend/core/services/mime_types.py b/src/backend/core/services/mime_types.py index 84714e7f..ab0535a9 100644 --- a/src/backend/core/services/mime_types.py +++ b/src/backend/core/services/mime_types.py @@ -1,3 +1,5 @@ +"""MIME type constants for document conversion.""" + BLOCKNOTE = "application/vnd.blocknote+json" YJS = "application/vnd.yjs.doc" MARKDOWN = "text/markdown" diff --git a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py index 346fe407..f2944038 100644 --- a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py +++ b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py @@ -16,6 +16,7 @@ from rest_framework.test import APIClient from core import factories from core.api.serializers import ServerCreateDocumentSerializer from core.models import Document, Invitation, User +from core.services import mime_types from core.services.converter_services import ConversionError, YdocConverter pytestmark = pytest.mark.django_db @@ -191,7 +192,9 @@ def test_api_documents_create_for_owner_existing(mock_convert_md): assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -236,7 +239,9 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md): assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -297,7 +302,9 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -393,7 +400,9 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic HTTP_AUTHORIZATION="Bearer DummyToken", ) assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -474,7 +483,9 @@ def test_api_documents_create_for_owner_with_default_language( ) assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert mock_send.call_args[0][3] == "de-de" @@ -501,7 +512,9 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md): assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -537,7 +550,9 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message( assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -571,7 +586,9 @@ def test_api_documents_create_for_owner_with_converter_exception( format="json", HTTP_AUTHORIZATION="Bearer DummyToken", ) - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert response.status_code == 400 assert response.json() == {"content": ["Could not convert content"]} diff --git a/src/backend/core/tests/documents/test_api_documents_create_with_file.py b/src/backend/core/tests/documents/test_api_documents_create_with_file.py new file mode 100644 index 00000000..9389a816 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_create_with_file.py @@ -0,0 +1,358 @@ +""" +Tests for Documents API endpoint in impress's core app: create with file upload +""" + +from base64 import b64decode, binascii +from io import BytesIO +from unittest.mock import patch + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Document +from core.services import mime_types +from core.services.converter_services import ( + ConversionError, + ServiceUnavailableError, +) + +pytestmark = pytest.mark.django_db + + +def test_api_documents_create_with_file_anonymous(): + """Anonymous users should not be allowed to create documents with file upload.""" + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "test_document.docx" + + response = APIClient().post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_docx_file_success(mock_convert): + """ + Authenticated users should be able to create documents by uploading a DOCX file. + The file should be converted to YJS format and the title should be set from filename. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion + converted_yjs = "base64encodedyjscontent" + mock_convert.return_value = converted_yjs + + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "My Important Document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "My Important Document.docx" + assert document.content == converted_yjs + assert document.accesses.filter(role="owner", user=user).exists() + + # Verify the converter was called correctly + mock_convert.assert_called_once_with( + file_content, + content_type=mime_types.DOCX, + accept=mime_types.YJS, + ) + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_markdown_file_success(mock_convert): + """ + Authenticated users should be able to create documents by uploading a Markdown file. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion + converted_yjs = "base64encodedyjscontent" + mock_convert.return_value = converted_yjs + + # Create a fake Markdown file + file_content = b"# Test Document\n\nThis is a test." + file = BytesIO(file_content) + file.name = "readme.md" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "readme.md" + assert document.content == converted_yjs + assert document.accesses.filter(role="owner", user=user).exists() + + # Verify the converter was called correctly + mock_convert.assert_called_once_with( + file_content, + content_type=mime_types.MARKDOWN, + accept=mime_types.YJS, + ) + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_and_explicit_title(mock_convert): + """ + When both file and title are provided, the filename should override the title. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion + converted_yjs = "base64encodedyjscontent" + mock_convert.return_value = converted_yjs + + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "Uploaded Document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + "title": "This should be overridden", + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + # The filename should take precedence + assert document.title == "Uploaded Document.docx" + + +def test_api_documents_create_with_empty_file(): + """ + Creating a document with an empty file should fail with a validation error. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create an empty file + file = BytesIO(b"") + file.name = "empty.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["The submitted file is empty."]} + assert not Document.objects.exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_conversion_error(mock_convert): + """ + When conversion fails, the API should return a 400 error with appropriate message. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion to raise an error + mock_convert.side_effect = ConversionError("Failed to convert document") + + # Create a fake DOCX file + file_content = b"fake invalid docx content" + file = BytesIO(file_content) + file.name = "corrupted.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["Could not convert file content"]} + assert not Document.objects.exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_service_unavailable(mock_convert): + """ + When the conversion service is unavailable, appropriate error should be returned. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion to raise ServiceUnavailableError + mock_convert.side_effect = ServiceUnavailableError( + "Failed to connect to conversion service" + ) + + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["Could not convert file content"]} + assert not Document.objects.exists() + + +def test_api_documents_create_without_file_still_works(): + """ + Creating a document without a file should still work as before (backward compatibility). + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/documents/", + { + "title": "Regular document without file", + }, + format="json", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "Regular document without file" + assert document.content is None + assert document.accesses.filter(role="owner", user=user).exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_null_value(mock_convert): + """ + Passing file=null should be treated as no file upload. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/documents/", + { + "title": "Document with null file", + "file": None, + }, + format="json", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "Document with null file" + # Converter should not have been called + mock_convert.assert_not_called() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_preserves_content_format(mock_convert): + """ + Verify that the converted content is stored correctly in the document. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion with realistic base64-encoded YJS data + converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA=" + mock_convert.return_value = converted_yjs + + # Create a fake DOCX file + file_content = b"fake docx with complex formatting" + file = BytesIO(file_content) + file.name = "complex_document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + + # Verify the content is stored as returned by the converter + assert document.content == converted_yjs + + # Verify it's valid base64 (can be decoded) + try: + b64decode(converted_yjs) + except binascii.Error: + pytest.fail("Content should be valid base64-encoded data") + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_unicode_filename(mock_convert): + """ + Test that Unicode characters in filenames are handled correctly. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion + converted_yjs = "base64encodedyjscontent" + mock_convert.return_value = converted_yjs + + # Create a file with Unicode characters in the name + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "文档-télécharger-документ.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "文档-télécharger-документ.docx" diff --git a/src/backend/core/tests/test_services_converter_orchestration.py b/src/backend/core/tests/test_services_converter_orchestration.py new file mode 100644 index 00000000..90ac66d3 --- /dev/null +++ b/src/backend/core/tests/test_services_converter_orchestration.py @@ -0,0 +1,93 @@ +"""Test Converter orchestration services.""" + +from unittest.mock import MagicMock, patch + +from core.services import mime_types +from core.services.converter_services import Converter + + +@patch("core.services.converter_services.DocSpecConverter") +@patch("core.services.converter_services.YdocConverter") +def test_converter_docx_to_yjs_orchestration(mock_ydoc_class, mock_docspec_class): + """Test that DOCX to YJS conversion uses both DocSpec and Ydoc converters.""" + # Setup mocks + mock_docspec = MagicMock() + mock_ydoc = MagicMock() + mock_docspec_class.return_value = mock_docspec + mock_ydoc_class.return_value = mock_ydoc + + # Mock the conversion chain: DOCX -> BlockNote -> YJS + blocknote_data = b'[{"type": "paragraph", "content": "test"}]' + yjs_data = "base64encodedyjs" + + mock_docspec.convert.return_value = blocknote_data + mock_ydoc.convert.return_value = yjs_data + + # Execute conversion + converter = Converter() + docx_data = b"fake docx data" + result = converter.convert(docx_data, mime_types.DOCX, mime_types.YJS) + + # Verify the orchestration + mock_docspec.convert.assert_called_once_with( + docx_data, mime_types.DOCX, mime_types.BLOCKNOTE + ) + mock_ydoc.convert.assert_called_once_with( + blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS + ) + assert result == yjs_data + + +@patch("core.services.converter_services.YdocConverter") +def test_converter_markdown_to_yjs_delegation(mock_ydoc_class): + """Test that Markdown to YJS conversion is delegated to YdocConverter.""" + mock_ydoc = MagicMock() + mock_ydoc_class.return_value = mock_ydoc + + yjs_data = "base64encodedyjs" + mock_ydoc.convert.return_value = yjs_data + + converter = Converter() + markdown_data = "# Test Document" + result = converter.convert(markdown_data, mime_types.MARKDOWN, mime_types.YJS) + + mock_ydoc.convert.assert_called_once_with( + markdown_data, mime_types.MARKDOWN, mime_types.YJS + ) + assert result == yjs_data + + +@patch("core.services.converter_services.YdocConverter") +def test_converter_yjs_to_html_delegation(mock_ydoc_class): + """Test that YJS to HTML conversion is delegated to YdocConverter.""" + mock_ydoc = MagicMock() + mock_ydoc_class.return_value = mock_ydoc + + html_data = "

Test Document

" + mock_ydoc.convert.return_value = html_data + + converter = Converter() + yjs_data = b"yjs binary data" + result = converter.convert(yjs_data, mime_types.YJS, mime_types.HTML) + + mock_ydoc.convert.assert_called_once_with(yjs_data, mime_types.YJS, mime_types.HTML) + assert result == html_data + + +@patch("core.services.converter_services.YdocConverter") +def test_converter_blocknote_to_yjs_delegation(mock_ydoc_class): + """Test that BlockNote to YJS conversion is delegated to YdocConverter.""" + mock_ydoc = MagicMock() + mock_ydoc_class.return_value = mock_ydoc + + yjs_data = "base64encodedyjs" + mock_ydoc.convert.return_value = yjs_data + + converter = Converter() + blocknote_data = b'[{"type": "paragraph"}]' + result = converter.convert(blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS) + + mock_ydoc.convert.assert_called_once_with( + blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS + ) + assert result == yjs_data diff --git a/src/backend/core/tests/test_services_converter_services.py b/src/backend/core/tests/test_services_converter_services.py index 086d132b..5cb9a4b1 100644 --- a/src/backend/core/tests/test_services_converter_services.py +++ b/src/backend/core/tests/test_services_converter_services.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest import requests +from core.services import mime_types from core.services.converter_services import ( ServiceUnavailableError, ValidationError, @@ -36,7 +37,7 @@ def test_convert_service_unavailable(mock_post): with pytest.raises( ServiceUnavailableError, - match="Failed to connect to conversion service", + match="Failed to connect to YDoc conversion service", ): converter.convert("test text") @@ -52,7 +53,7 @@ def test_convert_http_error(mock_post): with pytest.raises( ServiceUnavailableError, - match="Failed to connect to conversion service", + match="Failed to connect to YDoc conversion service", ): converter.convert("test text") @@ -83,8 +84,8 @@ def test_convert_full_integration(mock_post, settings): data="test markdown", headers={ "Authorization": "Bearer test-key", - "Content-Type": "text/markdown", - "Accept": "application/vnd.yjs.doc", + "Content-Type": mime_types.MARKDOWN, + "Accept": mime_types.YJS, }, timeout=5, verify=False, @@ -108,9 +109,7 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings): mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response - result = converter.convert( - b"test_content", "application/vnd.yjs.doc", "text/markdown" - ) + result = converter.convert(b"test_content", mime_types.YJS, mime_types.MARKDOWN) assert result == expected_response mock_post.assert_called_once_with( @@ -118,8 +117,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings): data=b"test_content", headers={ "Authorization": "Bearer test-key", - "Content-Type": "application/vnd.yjs.doc", - "Accept": "text/markdown", + "Content-Type": mime_types.YJS, + "Accept": mime_types.MARKDOWN, }, timeout=5, verify=False, @@ -135,7 +134,7 @@ def test_convert_timeout(mock_post): with pytest.raises( ServiceUnavailableError, - match="Failed to connect to conversion service", + match="Failed to connect to YDoc conversion service", ): converter.convert("test text") diff --git a/src/backend/core/tests/test_services_docspec_converter.py b/src/backend/core/tests/test_services_docspec_converter.py new file mode 100644 index 00000000..16f4a5f5 --- /dev/null +++ b/src/backend/core/tests/test_services_docspec_converter.py @@ -0,0 +1,117 @@ +"""Test DocSpec converter services.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from core.services import mime_types +from core.services.converter_services import ( + DocSpecConverter, + ServiceUnavailableError, + ValidationError, +) + + +def test_docspec_convert_empty_data(): + """Should raise ValidationError when data is empty.""" + converter = DocSpecConverter() + with pytest.raises(ValidationError, match="Input data cannot be empty"): + converter.convert("", mime_types.DOCX, mime_types.BLOCKNOTE) + + +def test_docspec_convert_none_input(): + """Should raise ValidationError when input is None.""" + converter = DocSpecConverter() + with pytest.raises(ValidationError, match="Input data cannot be empty"): + converter.convert(None, mime_types.DOCX, mime_types.BLOCKNOTE) + + +def test_docspec_convert_unsupported_content_type(): + """Should raise ValidationError when content type is not DOCX.""" + converter = DocSpecConverter() + with pytest.raises( + ValidationError, match="Conversion from text/plain to .* is not supported" + ): + converter.convert(b"test data", "text/plain", mime_types.BLOCKNOTE) + + +def test_docspec_convert_unsupported_accept(): + """Should raise ValidationError when accept type is not BLOCKNOTE.""" + converter = DocSpecConverter() + with pytest.raises( + ValidationError, + match=f"Conversion from {mime_types.DOCX} to {mime_types.YJS} is not supported", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.YJS) + + +@patch("requests.post") +def test_docspec_convert_service_unavailable(mock_post): + """Should raise ServiceUnavailableError when service is unavailable.""" + converter = DocSpecConverter() + mock_post.side_effect = requests.RequestException("Connection error") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to DocSpec conversion service", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE) + + +@patch("requests.post") +def test_docspec_convert_http_error(mock_post): + """Should raise ServiceUnavailableError when HTTP error occurs.""" + converter = DocSpecConverter() + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error") + mock_post.return_value = mock_response + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to DocSpec conversion service", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE) + + +@patch("requests.post") +def test_docspec_convert_timeout(mock_post): + """Should raise ServiceUnavailableError when request times out.""" + converter = DocSpecConverter() + mock_post.side_effect = requests.Timeout("Request timed out") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to DocSpec conversion service", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE) + + +@patch("requests.post") +def test_docspec_convert_success(mock_post, settings): + """Test successful DOCX to BlockNote conversion.""" + settings.DOCSPEC_API_URL = "http://docspec.test/convert" + settings.CONVERSION_API_TIMEOUT = 5 + settings.CONVERSION_API_SECURE = False + + converter = DocSpecConverter() + + expected_content = b'[{"type": "paragraph", "content": "test"}]' + mock_response = MagicMock() + mock_response.content = expected_content + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + docx_data = b"fake docx binary data" + result = converter.convert(docx_data, mime_types.DOCX, mime_types.BLOCKNOTE) + + assert result == expected_content + + # Verify the request was made correctly + mock_post.assert_called_once_with( + "http://docspec.test/convert", + headers={"Accept": mime_types.BLOCKNOTE}, + files={"file": ("document.docx", docx_data, mime_types.DOCX)}, + timeout=5, + verify=False, + ) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 0f7c9468..07a5182a 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -710,10 +710,7 @@ class Base(Configuration): ) # DocSpec API microservice - DOCSPEC_API_URL = values.Value( - environ_name="DOCSPEC_API_URL", - environ_prefix=None - ) + DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None) # Conversion endpoint CONVERSION_API_ENDPOINT = values.Value(