diff --git a/e2e_config.test.json b/e2e_config.test.json index 03507e6d..deb30fae 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -11,6 +11,7 @@ "accounts.seller.id": "SEL-7310-3075", "accounts.user.id": "USR-9673-3314", "accounts.user_group.id": "UGR-6822-0561", + "billing.journal.id": "BJO-6562-0928", "catalog.authorization.id": "AUT-9288-6146", "catalog.listing.id": "LST-5489-0806", "catalog.price_list.id": "PRC-7255-3950-0245", @@ -24,9 +25,9 @@ "catalog.product.terms.id": "TCS-7255-3950-0001", "catalog.product.terms.variant.id": "TCV-7255-3950-0001-0001", "catalog.unit.id": "UNT-1229", - "commerce.agreement.attachment.id": "ATT-9850-2169-6098-0001", - "commerce.agreement.id": "AGR-9850-2169-6098", - "commerce.agreement.subscription.line.id": "ALI-9850-2169-6098-0001", + "commerce.agreement.attachment.id": "ATT-0078-7880-7436-0001", + "commerce.agreement.id": "AGR-0078-7880-7436", + "commerce.agreement.subscription.line.id": "ALI-0078-7880-7436-0001", "commerce.assets.agreement.id": "AGR-2473-3299-1721", "commerce.assets.agreement.line.id": "ALI-9320-4904-7940-0001", "commerce.assets.id": "AST-0625-6526-6154", @@ -37,7 +38,7 @@ "commerce.assets.product.template.id": "", "commerce.authorization.id": "AUT-0031-2873", "commerce.client.id": "ACC-1086-6867", - "commerce.order.id": "ORD-6969-3541-5426", + "commerce.order.id": "ORD-0557-5037-6263", "commerce.product.id": "PRD-1767-7355", "commerce.product.item.id": "ITM-1767-7355-0001", "commerce.product.listing.id": "LST-5489-0806", diff --git a/mpt_api_client/resources/billing/journal_upload.py b/mpt_api_client/resources/billing/journal_upload.py deleted file mode 100644 index dd9e9774..00000000 --- a/mpt_api_client/resources/billing/journal_upload.py +++ /dev/null @@ -1,38 +0,0 @@ -from mpt_api_client.http import AsyncService, Service -from mpt_api_client.http.mixins import ( - AsyncCollectionMixin, - AsyncFilesOperationsMixin, - CollectionMixin, - FilesOperationsMixin, -) -from mpt_api_client.models import Model - - -class JournalUpload(Model): - """Journal Upload resource.""" - - -class JournalUploadServiceConfig: - """Journal Upload service configuration.""" - - _endpoint = "/public/v1/billing/journals/{journal_id}/upload" - _model_class = JournalUpload - _collection_key = "data" - - -class JournalUploadService( - FilesOperationsMixin[JournalUpload], - CollectionMixin[JournalUpload], - Service[JournalUpload], - JournalUploadServiceConfig, -): - """Journal Upload service.""" - - -class AsyncJournalUploadService( - AsyncFilesOperationsMixin[JournalUpload], - AsyncCollectionMixin[JournalUpload], - AsyncService[JournalUpload], - JournalUploadServiceConfig, -): - """Journal Upload service.""" diff --git a/mpt_api_client/resources/billing/journals.py b/mpt_api_client/resources/billing/journals.py index 208b5a49..e4377277 100644 --- a/mpt_api_client/resources/billing/journals.py +++ b/mpt_api_client/resources/billing/journals.py @@ -1,3 +1,5 @@ +from urllib.parse import urljoin + from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, @@ -5,6 +7,7 @@ CollectionMixin, ManagedResourceMixin, ) +from mpt_api_client.http.types import FileTypes from mpt_api_client.models import Model from mpt_api_client.resources.billing.journal_attachments import ( AsyncJournalAttachmentsService, @@ -18,10 +21,6 @@ AsyncJournalSellersService, JournalSellersService, ) -from mpt_api_client.resources.billing.journal_upload import ( - AsyncJournalUploadService, - JournalUploadService, -) from mpt_api_client.resources.billing.mixins import AsyncRegeneratableMixin, RegeneratableMixin @@ -35,6 +34,8 @@ class JournalsServiceConfig: _endpoint = "/public/v1/billing/journals" _model_class = Journal _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "id" class JournalsService( @@ -46,6 +47,35 @@ class JournalsService( ): """Journals service.""" + def upload(self, journal_id: str, file: FileTypes | None = None) -> Journal: # noqa: WPS110 + """Upload journal file. + + Args: + journal_id: Journal ID. + file: journal file. + + Returns: + Journal: Created resource. + """ + files = {} + + if file: + files[self._upload_file_key] = file # UNUSED type: ignore[attr-defined] + files[self._upload_data_key] = journal_id # UNUSED type: ignore + + path = urljoin(f"{self.path}/", f"{journal_id}/upload") + + response = self.http_client.request( # UNUSED type: ignore[attr-defined] + "post", + path, # UNUSED type: ignore[attr-defined] + files=files, + force_multipart=True, + ) + + return self._model_class.from_response( + response + ) # UNUSED type: ignore[attr-defined, no-any-return] + def attachments(self, journal_id: str) -> JournalAttachmentsService: """Return journal attachments service.""" return JournalAttachmentsService( @@ -65,12 +95,6 @@ def charges(self, journal_id: str) -> JournalChargesService: http_client=self.http_client, endpoint_params={"journal_id": journal_id} ) - def upload(self, journal_id: str) -> JournalUploadService: - """Return journal upload service.""" - return JournalUploadService( - http_client=self.http_client, endpoint_params={"journal_id": journal_id} - ) - class AsyncJournalsService( AsyncRegeneratableMixin[Journal], @@ -81,6 +105,35 @@ class AsyncJournalsService( ): """Async Journals service.""" + async def upload(self, journal_id: str, file: FileTypes | None = None) -> Journal: # noqa: WPS110 + """Upload journal file. + + Args: + journal_id: Journal ID. + file: journal file. + + Returns: + Journal: Created resource. + """ + files = {} + + if file: + files[self._upload_file_key] = file # UNUSED type: ignore[attr-defined] + files[self._upload_data_key] = journal_id # UNUSED type: ignore + + path = urljoin(f"{self.path}/", f"{journal_id}/upload") + + response = await self.http_client.request( # UNUSED type: ignore[attr-defined] + "post", + path, # UNUSED type: ignore[attr-defined] + files=files, + force_multipart=True, + ) + + return self._model_class.from_response( + response + ) # UNUSED type: ignore[attr-defined, no-any-return] + def attachments(self, journal_id: str) -> AsyncJournalAttachmentsService: """Return journal attachments service.""" return AsyncJournalAttachmentsService( @@ -99,9 +152,3 @@ def charges(self, journal_id: str) -> AsyncJournalChargesService: return AsyncJournalChargesService( http_client=self.http_client, endpoint_params={"journal_id": journal_id} ) - - def upload(self, journal_id: str) -> AsyncJournalUploadService: - """Return journal upload service.""" - return AsyncJournalUploadService( - http_client=self.http_client, endpoint_params={"journal_id": journal_id} - ) diff --git a/tests/data/test_billing_journal.jsonl b/tests/data/test_billing_journal.jsonl new file mode 100644 index 00000000..a4906804 --- /dev/null +++ b/tests/data/test_billing_journal.jsonl @@ -0,0 +1 @@ +{"externalIds": {"vendor": "ext-seeded-billing-sub-vendor-id", "invoice": "INV12345", "reference": "ORD-7924-7691-0805"}, "search": {"source": {"type": "Subscription", "criteria": "id", "value": "SUB-5839-4140-9574"},"order":{"criteria":"order.id","value":"ORD-7924-7691-0805"}, "item": {"criteria": "item.id", "value": "ITM-1767-7355-0001"}}, "period": {"start": "2025-12-22", "end": "2026-12-21"}, "price": {"unitPP": 10, "PPx1": 8.33}, "quantity": 10, "segment": "COM", "description": {"value1": "desc-1", "value2": "desc-2"}} diff --git a/tests/data/test_billing_journal.xlsx b/tests/data/test_billing_journal.xlsx new file mode 100644 index 00000000..81203ebd Binary files /dev/null and b/tests/data/test_billing_journal.xlsx differ diff --git a/tests/e2e/billing/conftest.py b/tests/e2e/billing/conftest.py new file mode 100644 index 00000000..47b08ed1 --- /dev/null +++ b/tests/e2e/billing/conftest.py @@ -0,0 +1,18 @@ +import pathlib + +import pytest + + +@pytest.fixture +def billing_journal_fd(): + file_path = pathlib.Path("tests/data/test_billing_journal.jsonl").resolve() + fd = file_path.open("rb") + try: + yield fd + finally: + fd.close() + + +@pytest.fixture +def billing_journal_id(e2e_config): + return e2e_config["billing.journal.id"] diff --git a/tests/e2e/billing/journal/conftest.py b/tests/e2e/billing/journal/conftest.py new file mode 100644 index 00000000..9ac8708b --- /dev/null +++ b/tests/e2e/billing/journal/conftest.py @@ -0,0 +1,21 @@ +import pytest + + +@pytest.fixture +def invalid_billing_journal_id(): + return "BJO-0000-0000" + + +@pytest.fixture +def billing_journal_factory(authorization_id): + def factory( + name: str = "E2E Created Billing Journal", + ): + return { + "authorization": {"id": authorization_id}, + "dueDate": "2026-01-02T19:00:00.000Z", + "externalIds": {}, + "name": name, + } + + return factory diff --git a/tests/e2e/billing/journal/test_async_journal.py b/tests/e2e/billing/journal/test_async_journal.py new file mode 100644 index 00000000..e15ad605 --- /dev/null +++ b/tests/e2e/billing/journal/test_async_journal.py @@ -0,0 +1,111 @@ +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_billing_journal(async_mpt_vendor, billing_journal_factory): + new_billing_journal_request_data = billing_journal_factory( + name="E2E Created Billing Journal", + ) + + created_billing_journal = await async_mpt_vendor.billing.journals.create( + new_billing_journal_request_data + ) + + yield created_billing_journal + + try: + await async_mpt_vendor.billing.journals.delete(created_billing_journal.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete billing journal: {error.title}") # noqa: WPS421 + + +@pytest.fixture +async def submitted_billing_journal(async_mpt_vendor, created_billing_journal, billing_journal_fd): + await async_mpt_vendor.billing.journals.submit(created_billing_journal.id) + await async_mpt_vendor.billing.journals.upload( + journal_id=created_billing_journal.id, + file=billing_journal_fd, + ) + + return created_billing_journal + + +@pytest.fixture +async def completed_billing_journal(async_mpt_vendor, submitted_billing_journal): + await async_mpt_vendor.billing.journals.accept(submitted_billing_journal.id) + await async_mpt_vendor.billing.journals.complete(submitted_billing_journal.id) + return submitted_billing_journal + + +async def test_get_billing_journal_by_id(async_mpt_vendor, billing_journal_id): + result = await async_mpt_vendor.billing.journals.get(billing_journal_id) + + assert result is not None + + +async def test_list_billing_journals(async_mpt_vendor): + limit = 10 + + result = await async_mpt_vendor.billing.journals.fetch_page(limit=limit) + + assert len(result) > 0 + + +async def test_get_billing_journal_by_id_not_found(async_mpt_vendor, invalid_billing_journal_id): + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + await async_mpt_vendor.billing.journals.get(invalid_billing_journal_id) + + +async def test_filter_billing_journals(async_mpt_vendor, billing_journal_id): + select_fields = ["-value"] + filtered_billing_journals = ( + async_mpt_vendor.billing.journals.filter(RQLQuery(id=billing_journal_id)) + .filter(RQLQuery(name="E2E Seeded Billing Journal")) + .select(*select_fields) + ) + + result = [billing_journal async for billing_journal in filtered_billing_journals.iterate()] + + assert len(result) == 1 + + +def test_create_billing_journal(created_billing_journal): + result = created_billing_journal + + assert result is not None + + +async def test_update_billing_journal( + async_mpt_vendor, created_billing_journal, billing_journal_factory +): + updated_name = "E2E Updated Billing Journal Name" + updated_billing_journal_data = billing_journal_factory(name=updated_name) + + result = await async_mpt_vendor.billing.journals.update( + created_billing_journal.id, + updated_billing_journal_data, + ) + + assert result.name == updated_name + + +async def test_delete_billing_journal(async_mpt_vendor, created_billing_journal): + result = created_billing_journal + + await async_mpt_vendor.billing.journals.delete(result.id) + + +async def test_upload_billing_journal( + async_mpt_vendor, created_billing_journal, billing_journal_fd +): + result = await async_mpt_vendor.billing.journals.upload( + journal_id=created_billing_journal.id, + file=billing_journal_fd, + ) + + assert result is not None diff --git a/tests/e2e/billing/journal/test_sync_journal.py b/tests/e2e/billing/journal/test_sync_journal.py new file mode 100644 index 00000000..7b99d0b3 --- /dev/null +++ b/tests/e2e/billing/journal/test_sync_journal.py @@ -0,0 +1,105 @@ +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_billing_journal(mpt_vendor, billing_journal_factory): + new_billing_journal_request_data = billing_journal_factory( + name="E2E Created Billing Journal", + ) + + created_billing_journal = mpt_vendor.billing.journals.create(new_billing_journal_request_data) + + yield created_billing_journal + + try: + mpt_vendor.billing.journals.delete(created_billing_journal.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete billing journal: {error.title}") # noqa: WPS421 + + +@pytest.fixture +def submitted_billing_journal(mpt_vendor, created_billing_journal, billing_journal_fd): + mpt_vendor.billing.journals.submit(created_billing_journal.id) + mpt_vendor.billing.journals.upload( + journal_id=created_billing_journal.id, + file=billing_journal_fd, + ) + + return created_billing_journal + + +@pytest.fixture +def completed_billing_journal(mpt_vendor, submitted_billing_journal): + mpt_vendor.billing.journals.accept(submitted_billing_journal.id) + mpt_vendor.billing.journals.complete(submitted_billing_journal.id) + return submitted_billing_journal + + +def test_get_billing_journal_by_id(mpt_vendor, billing_journal_id): + result = mpt_vendor.billing.journals.get(billing_journal_id) + + assert result is not None + + +def test_list_billing_journals(mpt_vendor): + limit = 10 + + result = mpt_vendor.billing.journals.fetch_page(limit=limit) + + assert len(result) > 0 + + +def test_get_billing_journal_by_id_not_found(mpt_vendor, invalid_billing_journal_id): + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + mpt_vendor.billing.journals.get(invalid_billing_journal_id) + + +def test_filter_billing_journals(mpt_vendor, billing_journal_id): + select_fields = ["-value"] + filtered_billing_journals = ( + mpt_vendor.billing.journals.filter(RQLQuery(id=billing_journal_id)) + .filter(RQLQuery(name="E2E Seeded Billing Journal")) + .select(*select_fields) + ) + + result = list(filtered_billing_journals.iterate()) + + assert len(result) == 1 + + +def test_create_billing_journal(created_billing_journal): + result = created_billing_journal + + assert result is not None + + +def test_update_billing_journal(mpt_vendor, created_billing_journal, billing_journal_factory): + updated_name = "E2E Updated Billing Journal Name" + updated_billing_journal_data = billing_journal_factory(name=updated_name) + + result = mpt_vendor.billing.journals.update( + created_billing_journal.id, + updated_billing_journal_data, + ) + + assert result.name == updated_name + + +def test_delete_billing_journal(mpt_vendor, created_billing_journal): + result = created_billing_journal + + mpt_vendor.billing.journals.delete(result.id) + + +def test_upload_billing_journal(mpt_vendor, created_billing_journal, billing_journal_fd): + result = mpt_vendor.billing.journals.upload( + journal_id=created_billing_journal.id, + file=billing_journal_fd, + ) + + assert result is not None diff --git a/tests/unit/resources/billing/test_journal_upload.py b/tests/unit/resources/billing/test_journal_upload.py deleted file mode 100644 index 350d7354..00000000 --- a/tests/unit/resources/billing/test_journal_upload.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest - -from mpt_api_client.resources.billing.journal_upload import ( - AsyncJournalUploadService, - JournalUploadService, -) - - -@pytest.fixture -def journal_upload_service(http_client): - return JournalUploadService( - http_client=http_client, endpoint_params={"journal_id": "JRN-0000-0001"} - ) - - -@pytest.fixture -def async_journal_upload_service(async_http_client): - return AsyncJournalUploadService( - http_client=async_http_client, endpoint_params={"journal_id": "JRN-0000-0001"} - ) - - -def test_endpoint(journal_upload_service) -> None: - result = journal_upload_service.path == "/public/v1/billing/journals/JRN-0000-0001/upload" - - assert result is True - - -def test_async_endpoint(async_journal_upload_service) -> None: - result = async_journal_upload_service.path == "/public/v1/billing/journals/JRN-0000-0001/upload" - - assert result is True - - -@pytest.mark.parametrize("method", ["create"]) -def test_methods_present(journal_upload_service, method: str) -> None: - result = hasattr(journal_upload_service, method) - - assert result is True - - -@pytest.mark.parametrize("method", ["create"]) -def test_async_methods_present(async_journal_upload_service, method: str) -> None: - result = hasattr(async_journal_upload_service, method) - - assert result is True diff --git a/tests/unit/resources/billing/test_journals.py b/tests/unit/resources/billing/test_journals.py index 62b1ed1f..af05a0c3 100644 --- a/tests/unit/resources/billing/test_journals.py +++ b/tests/unit/resources/billing/test_journals.py @@ -1,4 +1,6 @@ +import httpx import pytest +import respx from mpt_api_client.resources.billing.journal_attachments import ( AsyncJournalAttachmentsService, @@ -12,10 +14,6 @@ AsyncJournalSellersService, JournalSellersService, ) -from mpt_api_client.resources.billing.journal_upload import ( - AsyncJournalUploadService, - JournalUploadService, -) from mpt_api_client.resources.billing.journals import AsyncJournalsService, JournalsService @@ -31,7 +29,7 @@ def async_journals_service(async_http_client): @pytest.mark.parametrize( "method", - ["get", "create", "update", "delete", "regenerate", "submit", "enquiry", "accept"], + ["get", "create", "update", "delete", "regenerate", "submit", "enquiry", "accept", "upload"], ) def test_mixins_present(journals_service, method): result = hasattr(journals_service, method) @@ -41,7 +39,7 @@ def test_mixins_present(journals_service, method): @pytest.mark.parametrize( "method", - ["get", "create", "update", "delete", "regenerate", "submit", "enquiry", "accept"], + ["get", "create", "update", "delete", "regenerate", "submit", "enquiry", "accept", "upload"], ) def test_async_mixins_present(async_journals_service, method): result = hasattr(async_journals_service, method) @@ -55,7 +53,6 @@ def test_async_mixins_present(async_journals_service, method): ("attachments", JournalAttachmentsService), ("sellers", JournalSellersService), ("charges", JournalChargesService), - ("upload", JournalUploadService), ], ) def test_property_services(journals_service, service_method, expected_service_class): @@ -71,7 +68,6 @@ def test_property_services(journals_service, service_method, expected_service_cl ("attachments", AsyncJournalAttachmentsService), ("sellers", AsyncJournalSellersService), ("charges", AsyncJournalChargesService), - ("upload", AsyncJournalUploadService), ], ) def test_async_property_services(async_journals_service, service_method, expected_service_class): @@ -79,3 +75,67 @@ def test_async_property_services(async_journals_service, service_method, expecte assert isinstance(result, expected_service_class) assert result.endpoint_params == {"journal_id": "JRN-0000-0001"} + + +def test_upload(journals_service, tmp_path) -> None: + file_path = tmp_path / "journal.jsonl" + file_path.write_text("test data") + with file_path.open("rb") as file_obj, respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/billing/journals/JRN-0000-0001/upload" + ).mock(return_value=httpx.Response(200, json={"result": "ok"})) + + result = journals_service.upload( + journal_id="JRN-0000-0001", + file=file_obj, + ) + + assert mock_route.called + assert result is not None + + +async def test_async_upload(async_journals_service, tmp_path) -> None: + file_path = tmp_path / "journal.jsonl" + file_path.write_text("test data") + with file_path.open("rb") as file_obj, respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/billing/journals/JRN-0000-0001/upload" + ).mock(return_value=httpx.Response(200, json={"result": "ok"})) + + result = await async_journals_service.upload( + journal_id="JRN-0000-0001", + file=file_obj, + ) + + assert mock_route.called + assert result is not None + + +def test_upload_without_file(journals_service) -> None: + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/billing/journals/JRN-0000-0001/upload" + ).mock(return_value=httpx.Response(200, json={"result": "ok"})) + + result = journals_service.upload( + journal_id="JRN-0000-0001", + file=None, + ) + + assert mock_route.called + assert result is not None + + +async def test_async_upload_without_file(async_journals_service) -> None: + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/billing/journals/JRN-0000-0001/upload" + ).mock(return_value=httpx.Response(200, json={"result": "ok"})) + + result = await async_journals_service.upload( + journal_id="JRN-0000-0001", + file=None, + ) + + assert mock_route.called + assert result is not None