From 2f156fc7e8cdea19593a0456eb06e2d47ae5bfac Mon Sep 17 00:00:00 2001 From: Robert Segal Date: Wed, 22 Apr 2026 12:08:23 -0600 Subject: [PATCH] Added endpoints and e2e tests for program certificates --- e2e_config.test.json | 1 + .../resources/program/certificates.py | 93 ++++++++++++ mpt_api_client/resources/program/program.py | 14 ++ tests/e2e/program/certificate/conftest.py | 36 +++++ .../certificate/test_async_certificate.py | 83 +++++++++++ .../certificate/test_sync_certificate.py | 79 ++++++++++ .../resources/program/test_certificates.py | 135 ++++++++++++++++++ tests/unit/resources/program/test_program.py | 6 + 8 files changed, 447 insertions(+) create mode 100644 mpt_api_client/resources/program/certificates.py create mode 100644 tests/e2e/program/certificate/conftest.py create mode 100644 tests/e2e/program/certificate/test_async_certificate.py create mode 100644 tests/e2e/program/certificate/test_sync_certificate.py create mode 100644 tests/unit/resources/program/test_certificates.py diff --git a/e2e_config.test.json b/e2e_config.test.json index ec8528a7..0d875fd5 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -70,6 +70,7 @@ "notifications.subscriber.id": "NTS-0829-7123-7123", "integration.extension.id": "EXT-6587-4477", "integration.term.id": "ETC-6587-4477-0062", + "program.certificate.id": "CER-9646-2171-8417", "program.document.file.id": "PDM-9643-3741-0001", "program.enrollment.assignee.id": "USR-6337-1324", "program.enrollment.id": "ENR-3965-5056-7966", diff --git a/mpt_api_client/resources/program/certificates.py b/mpt_api_client/resources/program/certificates.py new file mode 100644 index 00000000..7c64117c --- /dev/null +++ b/mpt_api_client/resources/program/certificates.py @@ -0,0 +1,93 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncManagedResourceMixin, + CollectionMixin, + ManagedResourceMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel, ResourceData + + +class Certificate(Model): + """Program certificate resource. + + Attributes: + name: Certificate name. + program: Reference to the program. + vendor: Reference to the vendor. + external_ids: External identifiers. + client: Reference to the client. + applicable_to: Applicable to which entities. + licensee: Reference to the licensee. + eligibility: Eligibility criteria. + status: Certificate status. + status_notes: Additional notes on the certificate status. + parameters: Certificate parameters. + audit: Audit information. + """ + + name: str | None + program: BaseModel | None + vendor: BaseModel | None + external_ids: BaseModel | None + client: BaseModel | None + applicable_to: str | None + licensee: BaseModel | None + eligibility: BaseModel | None + status: str | None + status_notes: str | None + parameters: BaseModel | None # noqa: WPS110 + audit: BaseModel | None + + +class CertificateServiceConfig: + """Program certificate service config.""" + + _endpoint = "/public/v1/program/certificates" + _model_class = Certificate + _collection_key = "data" + + +class CertificateService( + ManagedResourceMixin[Certificate], + CollectionMixin[Certificate], + Service[Certificate], + CertificateServiceConfig, +): + """Program certificate service.""" + + def terminate(self, resource_id: str, resource_data: ResourceData | None = None) -> Certificate: + """Terminate a certificate. + + Args: + resource_id: Certificate ID. + resource_data: Additional data for termination. + + Returns: + Terminated certificate. + """ + return self._resource(resource_id).post("/terminate", json=resource_data) + + +class AsyncCertificateService( + AsyncManagedResourceMixin[Certificate], + AsyncCollectionMixin[Certificate], + AsyncService[Certificate], + CertificateServiceConfig, +): + """Asynchronous program certificate service.""" + + async def terminate( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> Certificate: + """Asynchronously terminate a certificate. + + Args: + resource_id: Certificate ID. + resource_data: Additional data for termination. + + Returns: + Terminated certificate. + """ + return await self._resource(resource_id).post("/terminate", json=resource_data) diff --git a/mpt_api_client/resources/program/program.py b/mpt_api_client/resources/program/program.py index aae30ad5..0055207a 100644 --- a/mpt_api_client/resources/program/program.py +++ b/mpt_api_client/resources/program/program.py @@ -1,4 +1,8 @@ from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources.program.certificates import ( + AsyncCertificateService, + CertificateService, +) from mpt_api_client.resources.program.enrollments import AsyncEnrollmentService, EnrollmentService from mpt_api_client.resources.program.programs import AsyncProgramsService, ProgramsService @@ -19,6 +23,11 @@ def enrollments(self) -> EnrollmentService: """Enrollments service.""" return EnrollmentService(http_client=self.http_client) + @property + def certificates(self) -> CertificateService: + """Certificates service.""" + return CertificateService(http_client=self.http_client) + class AsyncProgram: """Program MPT API Module.""" @@ -35,3 +44,8 @@ def programs(self) -> AsyncProgramsService: def enrollments(self) -> AsyncEnrollmentService: """Enrollments service.""" return AsyncEnrollmentService(http_client=self.http_client) + + @property + def certificates(self) -> AsyncCertificateService: + """Certificates service.""" + return AsyncCertificateService(http_client=self.http_client) diff --git a/tests/e2e/program/certificate/conftest.py b/tests/e2e/program/certificate/conftest.py new file mode 100644 index 00000000..b2ea6e75 --- /dev/null +++ b/tests/e2e/program/certificate/conftest.py @@ -0,0 +1,36 @@ +import pytest + + +@pytest.fixture +def certificate_id(e2e_config): + return e2e_config["program.certificate.id"] + + +@pytest.fixture +def invalid_certificate_id(): + return "CER-0000-0000-0000" + + +@pytest.fixture +def certificate_data(program_id, licensee_id, buyer_account_id): + return { + "name": "E2E Created Program Certificate", + "program": {"id": program_id}, + "licensee": {"id": licensee_id}, + "client": {"id": buyer_account_id}, + "parameters": {"ordering": [], "fulfillment": []}, + } + + +@pytest.fixture +def terminated_certificate_data_factory(): + def factory(certificate_id: str): + return { + "id": certificate_id, + "status": "terminated", + "statusNotes": { + "message": "Terminating certificate for E2E test", + }, + } + + return factory diff --git a/tests/e2e/program/certificate/test_async_certificate.py b/tests/e2e/program/certificate/test_async_certificate.py new file mode 100644 index 00000000..d17ca130 --- /dev/null +++ b/tests/e2e/program/certificate/test_async_certificate.py @@ -0,0 +1,83 @@ +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_certificate( + async_mpt_ops, async_mpt_vendor, certificate_data, terminated_certificate_data_factory +): + certificate = await async_mpt_ops.program.certificates.create(certificate_data) + terminated_certificate_data = terminated_certificate_data_factory(certificate.id) + + yield certificate + + try: + await async_mpt_vendor.program.certificates.terminate( + certificate.id, terminated_certificate_data + ) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to terminate certificate {certificate.id}: {error.title}") # noqa: WPS421 + + +async def test_get_certificate_by_id(async_mpt_vendor, certificate_id): + result = await async_mpt_vendor.program.certificates.get(certificate_id) + + assert result is not None + + +async def test_get_certificate_by_invalid_id(async_mpt_vendor, invalid_certificate_id): + with pytest.raises(MPTAPIError): + await async_mpt_vendor.program.certificates.get(invalid_certificate_id) + + +async def test_list_certificates(async_mpt_vendor): + limit = 10 + + result = await async_mpt_vendor.program.certificates.fetch_page(limit=limit) + + assert len(result) > 0 + + +async def test_filter_certificates(async_mpt_vendor, certificate_id): + select_fields = ["-audit", "-parameters"] + filtered_certificates = ( + async_mpt_vendor.program.certificates + .filter(RQLQuery(id=certificate_id)) + .filter(RQLQuery(status="Active")) + .select(*select_fields) + ) + + result = [certificate async for certificate in filtered_certificates.iterate()] + + assert len(result) == 1 + + +def test_create_certificate(created_certificate): + result = created_certificate + + assert result is not None + + +async def test_update_certificate(async_mpt_client, created_certificate): + updated_name = "E2E Updated Certificate Name" + update_data = {"id": created_certificate.id, "name": updated_name} + + result = await async_mpt_client.program.certificates.update(created_certificate.id, update_data) + + assert result is not None + + +async def test_terminate_certificate( + async_mpt_vendor, created_certificate, terminated_certificate_data_factory +): + terminated_certificate_data = terminated_certificate_data_factory(created_certificate.id) + + result = await async_mpt_vendor.program.certificates.terminate( + created_certificate.id, terminated_certificate_data + ) + + assert result is not None diff --git a/tests/e2e/program/certificate/test_sync_certificate.py b/tests/e2e/program/certificate/test_sync_certificate.py new file mode 100644 index 00000000..0c5c49fa --- /dev/null +++ b/tests/e2e/program/certificate/test_sync_certificate.py @@ -0,0 +1,79 @@ +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_certificate(mpt_ops, mpt_vendor, certificate_data, terminated_certificate_data_factory): + certificate = mpt_ops.program.certificates.create(certificate_data) + terminated_certificate_data = terminated_certificate_data_factory(certificate.id) + + yield certificate + + try: + mpt_vendor.program.certificates.terminate(certificate.id, terminated_certificate_data) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to terminate certificate {certificate.id}: {error.title}") # noqa: WPS421 + + +def test_get_certificate_by_id(mpt_vendor, certificate_id): + result = mpt_vendor.program.certificates.get(certificate_id) + + assert result is not None + + +def test_get_certificate_by_invalid_id(mpt_vendor, invalid_certificate_id): + with pytest.raises(MPTAPIError): + mpt_vendor.program.certificates.get(invalid_certificate_id) + + +def test_list_certificates(mpt_vendor): + limit = 10 + + result = mpt_vendor.program.certificates.fetch_page(limit=limit) + + assert len(result) > 0 + + +def test_filter_certificates(mpt_vendor, certificate_id): + select_fields = ["-audit", "-parameters"] + filtered_certificates = ( + mpt_vendor.program.certificates + .filter(RQLQuery(id=certificate_id)) + .filter(RQLQuery(status="Active")) + .select(*select_fields) + ) + + result = list(filtered_certificates.iterate()) + + assert len(result) == 1 + + +def test_create_certificate(created_certificate): + result = created_certificate + + assert result is not None + + +def test_update_certificate(mpt_client, created_certificate): + updated_name = "E2E Updated Certificate Name" + update_data = {"id": created_certificate.id, "name": updated_name} + + result = mpt_client.program.certificates.update(created_certificate.id, update_data) + + assert result is not None + + +def test_terminate_certificate( + mpt_vendor, created_certificate, terminated_certificate_data_factory +): + terminated_certificate_data = terminated_certificate_data_factory(created_certificate.id) + + result = mpt_vendor.program.certificates.terminate( + created_certificate.id, terminated_certificate_data + ) + + assert result is not None diff --git a/tests/unit/resources/program/test_certificates.py b/tests/unit/resources/program/test_certificates.py new file mode 100644 index 00000000..c7568d2c --- /dev/null +++ b/tests/unit/resources/program/test_certificates.py @@ -0,0 +1,135 @@ +from http import HTTPStatus + +import httpx +import pytest +import respx + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.program.certificates import ( + AsyncCertificateService, + Certificate, + CertificateService, +) + + +@pytest.fixture +def certificate_service(http_client): + return CertificateService(http_client=http_client) + + +@pytest.fixture +def async_certificate_service(async_http_client): + return AsyncCertificateService(http_client=async_http_client) + + +@pytest.fixture +def certificate_data(): + return { + "id": "CER-123", + "name": "Certificate 123", + "program": {"id": "PRG-123"}, + "vendor": {"id": "ACC-123"}, + "externalIds": {"extId1": "value1"}, + "client": {"id": "ACC-123"}, + "applicableTo": "all", + "licensee": {"id": "LCE-123"}, + "eligibility": {"criteria": "must be eligible"}, + "status": "active", + "statusNotes": "Certificate is active", + "parameters": {"param1": "value1"}, + "audit": {"created": "2024-01-01T00:00:00Z", "updated": "2024-01-02T00:00:00Z"}, + } + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("terminate", {"id": "CER-123", "status": "updated"}), + ], +) +def test_custom_resource_actions(certificate_service, action, input_status): + request_expected_content = b'{"id":"CER-123","status":"updated"}' + response_expected_data = {"id": "CER-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/program/certificates/CER-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + headers={"Content-Type": "application/json"}, + json=response_expected_data, + ) + ) + + result = certificate_service.terminate("CER-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, Certificate) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("terminate", {"id": "CER-123", "status": "updated"}), + ], +) +async def test_async_custom_resource_actions(async_certificate_service, action, input_status): + request_expected_content = b'{"id":"CER-123","status":"updated"}' + response_expected_data = {"id": "CER-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/program/certificates/CER-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + headers={"Content-Type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await async_certificate_service.terminate("CER-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, Certificate) + + +def test_certificate_primitive_fields(certificate_data): + result = Certificate(certificate_data) + + assert result.to_dict() == certificate_data + + +def test_certificate_nested_fields(certificate_data): + result = Certificate(certificate_data) + + assert isinstance(result.program, BaseModel) + assert isinstance(result.vendor, BaseModel) + assert isinstance(result.client, BaseModel) + assert isinstance(result.licensee, BaseModel) + assert isinstance(result.eligibility, BaseModel) + assert isinstance(result.parameters, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_certificate_optional_fields(): + result = Certificate({"id": "CER-123"}) + + assert result.id == "CER-123" + assert not hasattr(result, "name") + assert not hasattr(result, "program") + assert not hasattr(result, "vendor") + assert not hasattr(result, "external_ids") + assert not hasattr(result, "client") + assert not hasattr(result, "applicable_to") + assert not hasattr(result, "licensee") + assert not hasattr(result, "eligibility") + assert not hasattr(result, "status") + assert not hasattr(result, "status_notes") + assert not hasattr(result, "parameters") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/program/test_program.py b/tests/unit/resources/program/test_program.py index 487d1a40..076379c5 100644 --- a/tests/unit/resources/program/test_program.py +++ b/tests/unit/resources/program/test_program.py @@ -1,5 +1,9 @@ import pytest +from mpt_api_client.resources.program.certificates import ( + AsyncCertificateService, + CertificateService, +) from mpt_api_client.resources.program.enrollments import AsyncEnrollmentService, EnrollmentService from mpt_api_client.resources.program.program import AsyncProgram, Program from mpt_api_client.resources.program.programs import AsyncProgramsService, ProgramsService @@ -20,6 +24,7 @@ def async_program(async_http_client): [ ("programs", ProgramsService), ("enrollments", EnrollmentService), + ("certificates", CertificateService), ], ) def test_program_properties(program, property_name, expected_service_class): @@ -34,6 +39,7 @@ def test_program_properties(program, property_name, expected_service_class): [ ("programs", AsyncProgramsService), ("enrollments", AsyncEnrollmentService), + ("certificates", AsyncCertificateService), ], ) def test_async_program_properties(async_program, property_name, expected_service_class):