diff --git a/e2e_config.test.json b/e2e_config.test.json index c5a99ec2..3cd51b91 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -65,10 +65,11 @@ "notifications.batch.attachment.id": "ATT-7183-1965-7758", "notifications.batch.id": "MST-3638-2460-4825", "notifications.category.id": "NTC-6157-0397", + "notifications.contact.id": "CTT-0001-9158", "notifications.message.id": "MSG-0000-6215-1019-0139", "notifications.subscriber.id": "NTS-0829-7123-7123", "integration.extension.id": "EXT-6587-4477", "integration.term.id": "ETC-6587-4477-0062", "program.program.id": "PRG-9643-3741", - "notifications.contact.id": "CTT-0001-9158" + "program.document.file.id": "PDM-9643-3741-0001" } diff --git a/mpt_api_client/resources/program/mixins/__init__.py b/mpt_api_client/resources/program/mixins/__init__.py index 551e3e00..d68cdbc9 100644 --- a/mpt_api_client/resources/program/mixins/__init__.py +++ b/mpt_api_client/resources/program/mixins/__init__.py @@ -1,9 +1,15 @@ +from mpt_api_client.resources.program.mixins.document_mixin import ( + AsyncDocumentMixin, + DocumentMixin, +) from mpt_api_client.resources.program.mixins.publishable_mixin import ( AsyncPublishableMixin, PublishableMixin, ) __all__ = [ # noqa: WPS410 + "AsyncDocumentMixin", "AsyncPublishableMixin", + "DocumentMixin", "PublishableMixin", ] diff --git a/mpt_api_client/resources/program/mixins/document_mixin.py b/mpt_api_client/resources/program/mixins/document_mixin.py new file mode 100644 index 00000000..d548da51 --- /dev/null +++ b/mpt_api_client/resources/program/mixins/document_mixin.py @@ -0,0 +1,26 @@ +from mpt_api_client.http.mixins import ( + AsyncCreateFileMixin, + AsyncDownloadFileMixin, + CreateFileMixin, + DownloadFileMixin, +) +from mpt_api_client.resources.program.mixins.publishable_mixin import ( + AsyncPublishableMixin, + PublishableMixin, +) + + +class DocumentMixin[Model]( + CreateFileMixin[Model], + DownloadFileMixin[Model], + PublishableMixin[Model], +): + """Document mixin.""" + + +class AsyncDocumentMixin[Model]( + AsyncCreateFileMixin[Model], + AsyncDownloadFileMixin[Model], + AsyncPublishableMixin[Model], +): + """Async document mixin.""" diff --git a/mpt_api_client/resources/program/programs.py b/mpt_api_client/resources/program/programs.py index 792fee4d..72bd7132 100644 --- a/mpt_api_client/resources/program/programs.py +++ b/mpt_api_client/resources/program/programs.py @@ -10,6 +10,10 @@ AsyncPublishableMixin, PublishableMixin, ) +from mpt_api_client.resources.program.programs_documents import ( + AsyncDocumentService, + DocumentService, +) class Program(Model): @@ -71,6 +75,12 @@ def update_settings(self, program_id: str, settings: ResourceData) -> Program: """ return self._resource(program_id).put("settings", json=settings) + def documents(self, program_id: str) -> DocumentService: + """Return program documents service.""" + return DocumentService( + http_client=self.http_client, endpoint_params={"program_id": program_id} + ) + class AsyncProgramsService( AsyncGetMixin[Program], @@ -92,3 +102,9 @@ async def update_settings(self, program_id: str, settings: ResourceData) -> Prog settings: Settings data to be updated """ return await self._resource(program_id).put("settings", json=settings) + + def documents(self, program_id: str) -> AsyncDocumentService: + """Return async program documents service.""" + return AsyncDocumentService( + http_client=self.http_client, endpoint_params={"program_id": program_id} + ) diff --git a/mpt_api_client/resources/program/programs_documents.py b/mpt_api_client/resources/program/programs_documents.py new file mode 100644 index 00000000..10653f56 --- /dev/null +++ b/mpt_api_client/resources/program/programs_documents.py @@ -0,0 +1,68 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncModifiableResourceMixin, + CollectionMixin, + ModifiableResourceMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.program.mixins import AsyncDocumentMixin, DocumentMixin + + +class Document(Model): + """Document resource. + + Attributes: + name: Document name. + type: Document type. + description: Document description. + status: Document status. + filename: Original file name. + size: File size in bytes. + content_type: MIME content type of the document. + url: URL to access the document. + program: Reference to the program. + audit: Audit information (created, updated events). + """ + + name: str | None + type: str | None + description: str | None + status: str | None + filename: str | None + size: int | None + content_type: str | None + url: str | None + program: BaseModel | None + audit: BaseModel | None + + +class DocumentServiceConfig: + """Document service configuration.""" + + _endpoint = "/public/v1/program/programs/{program_id}/documents" + _model_class = Document + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "document" + + +class DocumentService( + DocumentMixin[Document], + ModifiableResourceMixin[Document], + CollectionMixin[Document], + Service[Document], + DocumentServiceConfig, +): + """Program documents service.""" + + +class AsyncDocumentService( + AsyncDocumentMixin[Document], + AsyncModifiableResourceMixin[Document], + AsyncCollectionMixin[Document], + AsyncService[Document], + DocumentServiceConfig, +): + """Async program documents service.""" diff --git a/tests/e2e/program/program/document/conftest.py b/tests/e2e/program/program/document/conftest.py new file mode 100644 index 00000000..8c5be974 --- /dev/null +++ b/tests/e2e/program/program/document/conftest.py @@ -0,0 +1,38 @@ +import pytest + + +@pytest.fixture +def document_id(e2e_config): + return e2e_config["program.document.file.id"] + + +@pytest.fixture +def invalid_document_id(e2e_config): + return "PDM-0000-0000-0000" + + +@pytest.fixture +def document_data_factory(): + def factory( + document_type: str = "File", + ): + return { + "name": "E2E Created Program Document", + "description": "E2E Created Program Document", + "type": document_type, + "language": "en-us", + "url": "", + "documentType": document_type, + } + + return factory + + +@pytest.fixture +def vendor_document_service(mpt_vendor, program_id): + return mpt_vendor.program.programs.documents(program_id) + + +@pytest.fixture +def async_vendor_document_service(async_mpt_vendor, program_id): + return async_mpt_vendor.program.programs.documents(program_id) diff --git a/tests/e2e/program/program/document/test_async_document.py b/tests/e2e/program/program/document/test_async_document.py new file mode 100644 index 00000000..a7b56821 --- /dev/null +++ b/tests/e2e/program/program/document/test_async_document.py @@ -0,0 +1,102 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky] + + +@pytest.fixture +async def created_document_from_file(async_vendor_document_service, document_data_factory, pdf_fd): + document_data = document_data_factory(document_type="File") + document = await async_vendor_document_service.create(document_data, pdf_fd) + yield document, document_data + try: + await async_vendor_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +async def created_document_from_url(async_vendor_document_service, document_data_factory, pdf_url): + document_data = document_data_factory(document_type="Online") + document_data["url"] = pdf_url + document = await async_vendor_document_service.create(document_data) + yield document, document_data + try: + await async_vendor_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") # noqa: WPS421 + + +def test_create_document(created_document_from_file): # noqa: AAA01 + result, document_data = created_document_from_file + assert result.name == document_data["name"] + assert result.description == document_data["description"] + + +def test_create_document_from_url(created_document_from_url): # noqa: AAA01 + result, document_data = created_document_from_url + assert result.name == document_data["name"] + assert result.description == document_data["description"] + + +async def test_update_document(async_vendor_document_service, created_document_from_file): + update_data = {"name": "Updated e2e test document"} + document, _ = created_document_from_file + + result = await async_vendor_document_service.update(document.id, update_data) + + assert result.name == update_data["name"] + + +async def test_get_document(async_vendor_document_service, document_id): + result = await async_vendor_document_service.get(document_id) + + assert result.id == document_id + + +async def test_download_document(async_vendor_document_service, document_id): + result = await async_vendor_document_service.download(document_id) + + assert result.file_contents is not None + assert result.filename == "empty.pdf" + + +async def test_iterate_documents(async_vendor_document_service, document_id): + documents = [doc async for doc in async_vendor_document_service.iterate()] + + result = any(doc.id == document_id for doc in documents) + + assert result is True + + +async def test_filter_documents(async_vendor_document_service, created_document_from_file): + document, _ = created_document_from_file + filtered_service = async_vendor_document_service.filter(RQLQuery(id=document.id)) + + result = [doc async for doc in filtered_service.iterate()] + + assert len(result) == 1 + assert result[0].id == document.id + + +async def test_not_found_document(async_vendor_document_service, invalid_document_id): + with pytest.raises(MPTAPIError): + await async_vendor_document_service.get(invalid_document_id) + + +async def test_publish_document(async_vendor_document_service, created_document_from_file): + document, _ = created_document_from_file + result = await async_vendor_document_service.publish(document.id) + + assert result.status == "Published" + + +async def test_unpublish_document(async_vendor_document_service, created_document_from_file): + document, _ = created_document_from_file + await async_vendor_document_service.publish(document.id) + + result = await async_vendor_document_service.unpublish(document.id) + + assert result.status == "Unpublished" diff --git a/tests/e2e/program/program/document/test_sync_document.py b/tests/e2e/program/program/document/test_sync_document.py new file mode 100644 index 00000000..34669cde --- /dev/null +++ b/tests/e2e/program/program/document/test_sync_document.py @@ -0,0 +1,102 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky] + + +@pytest.fixture +def created_document_from_file(vendor_document_service, document_data_factory, pdf_fd): + document_data = document_data_factory(document_type="File") + document = vendor_document_service.create(document_data, pdf_fd) + yield document, document_data + try: + vendor_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +def created_document_from_url(vendor_document_service, document_data_factory, pdf_url): + document_data = document_data_factory(document_type="Online") + document_data["url"] = pdf_url + document = vendor_document_service.create(document_data) + yield document, document_data + try: + vendor_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") # noqa: WPS421 + + +def test_create_document(created_document_from_file): # noqa: AAA01 + result, document_data = created_document_from_file + assert result.name == document_data["name"] + assert result.description == document_data["description"] + + +def test_create_document_from_url(created_document_from_url): # noqa: AAA01 + result, document_data = created_document_from_url + assert result.name == document_data["name"] + assert result.description == document_data["description"] + + +def test_update_document(vendor_document_service, created_document_from_file): + update_data = {"name": "Updated e2e test document"} + document, _ = created_document_from_file + + result = vendor_document_service.update(document.id, update_data) + + assert result.name == update_data["name"] + + +def test_get_document(vendor_document_service, document_id): + result = vendor_document_service.get(document_id) + + assert result.id == document_id + + +def test_download_document(vendor_document_service, document_id): + result = vendor_document_service.download(document_id) + + assert result.file_contents is not None + assert result.filename == "empty.pdf" + + +def test_iterate_documents(vendor_document_service, document_id): + documents = list(vendor_document_service.iterate()) + + result = any(doc.id == document_id for doc in documents) + + assert result is True + + +def test_filter_documents(vendor_document_service, created_document_from_file): + document, _ = created_document_from_file + + result = list(vendor_document_service.filter(RQLQuery(id=document.id)).iterate()) + + assert len(result) == 1 + assert result[0].id == document.id + + +def test_not_found_document(vendor_document_service, invalid_document_id): + with pytest.raises(MPTAPIError): + vendor_document_service.get(invalid_document_id) + + +def test_publish_document(vendor_document_service, created_document_from_file): + document, _ = created_document_from_file + + result = vendor_document_service.publish(document.id) + + assert result.status == "Published" + + +def test_unpublish_document(vendor_document_service, created_document_from_file): + document, _ = created_document_from_file + vendor_document_service.publish(document.id) + + result = vendor_document_service.unpublish(document.id) + + assert result.status == "Unpublished" diff --git a/tests/unit/resources/program/mixin/test_document_mixin.py b/tests/unit/resources/program/mixin/test_document_mixin.py new file mode 100644 index 00000000..0854d016 --- /dev/null +++ b/tests/unit/resources/program/mixin/test_document_mixin.py @@ -0,0 +1,157 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.http.async_service import AsyncService +from mpt_api_client.http.service import Service +from mpt_api_client.resources.program.mixins import ( + AsyncDocumentMixin, + DocumentMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyDocumentService( + DocumentMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/documents" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "document" + + +class DummyAsyncDocumentService( + AsyncDocumentMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/documents" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "document" + + +@pytest.fixture +def document_service(http_client): + return DummyDocumentService(http_client=http_client) + + +@pytest.fixture +def async_document_service(async_http_client): + return DummyAsyncDocumentService(http_client=async_http_client) + + +def test_document_create_with_url(document_service): + resource_data = { + "name": "Dummy Doc", + "description": "Dummy Doc", + "url": "https://example.com/dummyfile.pdf", + } + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=resource_data, + ) + ) + new_document = document_service.create(resource_data=resource_data) + + result = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Dummy Doc","description":"Dummy Doc","url":"https://example.com/dummyfile.pdf"}\r\n' + in result.content + ) + assert b'Content-Disposition: form-data; name="file"' not in result.content + assert "multipart/form-data" in result.headers["Content-Type"] + assert new_document.to_dict() == resource_data + assert isinstance(new_document, DummyModel) + + +def test_document_create_with_file(document_service): + resource_data = {"id": "DOC-123", "name": "Dummy Data And File"} + response_data = resource_data + file_tuple = ("dummyfile.pdf", io.BytesIO(b"dummy file content"), "application/pdf") + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response(status_code=httpx.codes.OK, json=response_data) + ) + + result = document_service.create(resource_data=resource_data, file=file_tuple) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"id":"DOC-123","name":"Dummy Data And File"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="file"; filename="dummyfile.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"dummy file content\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_data + assert isinstance(result, DummyModel) + + +async def test_async_document_create_with_url(async_document_service): + resource_data = { + "name": "Async Dummy Doc", + "description": "Async Dummy Doc", + "url": "https://example.com/async_dummyfile.pdf", + } + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=resource_data, + ) + ) + + result = await async_document_service.create(resource_data=resource_data) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Async Dummy Doc","description":"Async Dummy Doc","url":"https://example.com/async_dummyfile.pdf"}\r\n' + in request.content + ) + assert b'Content-Disposition: form-data; name="file"' not in request.content + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == resource_data + assert isinstance(result, DummyModel) + + +async def test_async_document_create_with_file(async_document_service): # noqa: WPS210 + resource_data = {"id": "DOC-124", "name": "Async Dummy Data And File"} + response_data = resource_data + file_tuple = ("manual.pdf", io.BytesIO(b"PDF DATA"), "application/pdf") + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response(status_code=httpx.codes.OK, json=response_data) + ) + + result = await async_document_service.create(resource_data=resource_data, file=file_tuple) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"id":"DOC-124","name":"Async Dummy Data And File"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="file"; filename="manual.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF DATA\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_data + assert isinstance(result, DummyModel) diff --git a/tests/unit/resources/program/test_programs.py b/tests/unit/resources/program/test_programs.py index 2428e1b7..1025de4e 100644 --- a/tests/unit/resources/program/test_programs.py +++ b/tests/unit/resources/program/test_programs.py @@ -4,6 +4,10 @@ from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.program.programs import AsyncProgramsService, Program, ProgramsService +from mpt_api_client.resources.program.programs_documents import ( + AsyncDocumentService, + DocumentService, +) @pytest.fixture @@ -76,6 +80,32 @@ def test_async_mixins_present(async_programs_service, method): assert result is True +@pytest.mark.parametrize( + ("service_method", "expected_service_class"), + [ + ("documents", DocumentService), + ], +) +def test_property_services(programs_service, service_method, expected_service_class): + result = getattr(programs_service, service_method)("PRG-123") + + assert isinstance(result, expected_service_class) + assert result.endpoint_params == {"program_id": "PRG-123"} + + +@pytest.mark.parametrize( + ("service_method", "expected_service_class"), + [ + ("documents", AsyncDocumentService), + ], +) +def test_async_property_services(async_programs_service, service_method, expected_service_class): + result = getattr(async_programs_service, service_method)("PRG-123") + + assert isinstance(result, expected_service_class) + assert result.endpoint_params == {"program_id": "PRG-123"} + + def test_update_settings(programs_service, program_settings_data): program_id = "PRG-123" expected_response = {"id": program_id, "settings": program_settings_data} diff --git a/tests/unit/resources/program/test_programs_documents.py b/tests/unit/resources/program/test_programs_documents.py new file mode 100644 index 00000000..e49dd98a --- /dev/null +++ b/tests/unit/resources/program/test_programs_documents.py @@ -0,0 +1,94 @@ +import pytest + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.program.programs_documents import ( + AsyncDocumentService, + Document, + DocumentService, +) + + +@pytest.fixture +def document_service(http_client) -> DocumentService: + return DocumentService(http_client=http_client, endpoint_params={"program_id": "PRG-123"}) + + +@pytest.fixture +def async_document_service(async_http_client) -> AsyncDocumentService: + return AsyncDocumentService( + http_client=async_http_client, endpoint_params={"program_id": "PRG-123"} + ) + + +@pytest.fixture +def document_data(): + return { + "id": "PDM-001", + "name": "Program Overview", + "type": "Overview", + "description": "Overview of the program", + "status": "Active", + "filename": "overview.pdf", + "size": 2048, + "contentType": "application/pdf", + "url": "https://example.com/overview.pdf", + "language": "en", + "program": {"id": "PRG-123", "name": "My Program"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_endpoint(document_service): + result = document_service.path == "/public/v1/program/programs/PRG-123/documents" + + assert result is True + + +def test_async_endpoint(async_document_service): + result = async_document_service.path == "/public/v1/program/programs/PRG-123/documents" + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "delete", "update", "download", "publish", "unpublish", "iterate"], +) +def test_methods_present(document_service, method): + result = hasattr(document_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "delete", "update", "download", "publish", "unpublish", "iterate"], +) +def test_async_methods_present(async_document_service, method): + result = hasattr(async_document_service, method) + + assert result is True + + +def test_document_primitive_fields(document_data): + result = Document(document_data) + + assert result.to_dict() == document_data + + +def test_document_nested_fields_are_base_models(document_data): + result = Document(document_data) + + assert isinstance(result.program, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_document_optional_fields_absent(): + result = Document({"id": "PDM-001"}) + + assert result.id == "PDM-001" + assert not hasattr(result, "name") + assert not hasattr(result, "type") + assert not hasattr(result, "description") + assert not hasattr(result, "status") + assert not hasattr(result, "audit")