Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Then inspect the code paths relevant to the task:
- [`backend/migrations/`](backend/migrations/): migration files managed by `mpt-tool`
- [`backend/tests/`](backend/tests/): backend test suite
- [`frontend/src/modules/`](frontend/src/modules/): React plug entry points (one bundle per module). Frontend authoring rules live in the shared [frontend standard](https://github.com/softwareone-platform/mpt-extension-skills/blob/main/standards/extensions-ui-best-practices.md)
- [`frontend/src/shared/`](frontend/src/shared/): shared frontend components, hooks, and model helpers
- [`frontend/src/shared/`](frontend/src/shared/): shared frontend components, hooks, constants, model helpers, and test fixtures
- [`frontend/esbuild.config.js`](frontend/esbuild.config.js): frontend build that emits plug bundles into `static/`
- [`peripherals/devmock/`](peripherals/devmock): WireMock image and mappings backing `make run-local`
- [`make/`](make): canonical commands used by the repository
Expand Down
81 changes: 74 additions & 7 deletions backend/mpt_extension_python_template/routers/api/agreement.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
import asyncio
from http import HTTPStatus

from mpt_api_client.exceptions import MPTHttpError
from mpt_extension_sdk.api import APIResponse, PaginatedResult
from mpt_extension_sdk import TaskTimeouts
from mpt_extension_sdk.api import APIResponse, PaginatedResult, TaskResult
from mpt_extension_sdk.api.context import APIContext
from mpt_extension_sdk.api.errors import NotFoundError
from mpt_extension_sdk.models import Agreement
from mpt_extension_sdk.routing import APIRouter
from mpt_extension_sdk.schemas import BaseSchema
from mpt_extension_sdk.tasks import TaskHandle
from pydantic import Field

agreements_router = APIRouter(prefix="/api/v2/agreements")

# The deferred sync reports progress one step per percent while it waits, and 100 % once
# it has synced. The wait is long on purpose: it outlasts the 300-second Delay the SDK
# answers the first delivery with, so the Extension Service delivers the task again while
# the handler is still processing.
PROGRESS_INTERVAL_SECONDS = 4
FULL_PROGRESS = 100
# The whole handler must fit in this budget, declared on the route. The platform finalizes
# a task that runs longer, and the SDK keeps 60 of these seconds as its own safety margin
# before that deadline, so the sync itself gets the rest.
SYNC_HEADROOM_SECONDS = 120
DEFERRED_SYNC_MAX_PROCESSING_SECONDS = (
PROGRESS_INTERVAL_SECONDS * (FULL_PROGRESS - 1) + SYNC_HEADROOM_SECONDS
)


class SyncAgreementPayload(BaseSchema):
"""Body of the sync call: a deferrable route cannot take the id in the path.

The id is a path segment of every Marketplace call the handler makes, and the
API client does not escape it, so the pattern keeps separators out of it.
"""

agreement_id: str = Field(alias="agreementId", min_length=1, pattern=r"^[A-Za-z0-9-]+$")


@agreements_router.get("/", name="agreements-list")
async def get_agreements(ctx: APIContext) -> APIResponse:
Expand All @@ -32,16 +62,53 @@ async def get_agreement(agreement_id: str, ctx: APIContext) -> APIResponse:
return APIResponse.ok(payload=agreement.to_dict())


@agreements_router.post(path="/{agreement_id}/sync", name="agreements-sync")
async def sync_agreement(agreement_id: str, ctx: APIContext) -> APIResponse:
@agreements_router.post(
path="/sync",
name="agreements-sync",
body_validator=SyncAgreementPayload,
deferrable=True,
timeouts=TaskTimeouts(max_task_processing=DEFERRED_SYNC_MAX_PROCESSING_SECONDS),
)
async def sync_agreement(
payload: SyncAgreementPayload, ctx: APIContext
) -> APIResponse | TaskResult:
"""Synchronize an agreement view with the current Marketplace data.

Writes `externalIds.vendor` as the vendor, the only agreement key a client
or operations account cannot touch. The value is the one already there, so
the example changes no data.
Writes `externalIds.vendor` as the vendor, the only agreement key a client or
operations account cannot touch. It writes back the value already there, so the
example changes no data.

The route is deferrable. A plain call syncs and answers with the agreement.
A call with `MPT-Async: true` runs as a platform Task: it sleeps, reporting
progress, syncs at the end and returns a result pointing at the agreement.
The sleep is deliberate — it lasts long enough for the Extension Service to
deliver the same task again while this execution is still running, which is
the redelivery an extension has to tolerate.
"""
agreement_id = payload.agreement_id
if ctx.task is None:
agreement = await _sync_vendor_external_id(ctx, agreement_id)
return APIResponse.ok(payload=agreement.to_dict())

await ctx.task.log(
"Agreement sync for {{AgreementId}} waits for the redelivery before it runs",
parameters={"AgreementId": agreement_id},
)
await _wait_reporting_progress(ctx.task)
await _sync_vendor_external_id(ctx, agreement_id)
await ctx.task.progress(FULL_PROGRESS)
return TaskResult.reference(f"/public/v1/commerce/agreements/{agreement_id}")


async def _sync_vendor_external_id(ctx: APIContext, agreement_id: str) -> Agreement:
agreement = await ctx.mpt_api_service.agreements.get_by_id(agreement_id)
await ctx.vendor_mpt_api_service.agreements.update(
agreement_id, {"externalIds": {"vendor": agreement.external_ids.vendor}}
)
return APIResponse.ok(payload=agreement.to_dict())
return agreement


async def _wait_reporting_progress(task: TaskHandle) -> None:
for progress in range(1, FULL_PROGRESS):
await asyncio.sleep(PROGRESS_INTERVAL_SECONDS) # noqa: WPS476
await task.progress(progress) # noqa: WPS476
26 changes: 26 additions & 0 deletions backend/tests/routers/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import pytest
from mpt_api_client.models.meta import Pagination
from mpt_extension_sdk.api.context import APIContext, AuthenticatedRequestContext
from mpt_extension_sdk.services.mpt_api_service import MPTAPIService
from mpt_extension_sdk.services.mpt_api_service.agreement import AgreementService

Expand All @@ -21,3 +23,27 @@ def mpt_api_service(mpt_api_service_factory):
@pytest.fixture
def vendor_mpt_api_service(mpt_api_service_factory):
return mpt_api_service_factory()


@pytest.fixture
def api_context_factory(mocker, mpt_api_service, vendor_mpt_api_service):
def factory(*, task=None, offset=0, limit=10):
ctx = mocker.create_autospec(APIContext, instance=True)
ctx.mpt_api_service = mpt_api_service
ctx.vendor_mpt_api_service = vendor_mpt_api_service
ctx.task = task
ctx.request = mocker.create_autospec(AuthenticatedRequestContext, instance=True)
ctx.request.pagination = Pagination(offset=offset, limit=limit)
return ctx

return factory


@pytest.fixture
def agreement_id(agreement_payload):
return agreement_payload["id"]


@pytest.fixture
def vendor_external_id():
return "ABC-2023-C07-dbeee0b302c0"
132 changes: 94 additions & 38 deletions backend/tests/routers/test_agreements.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,54 @@

import pytest
from mpt_api_client.exceptions import MPTAPIError
from mpt_api_client.models.meta import Pagination
from mpt_extension_sdk.api.context import APIContext, AuthenticatedRequestContext
from mpt_extension_sdk.api import TaskResult
from mpt_extension_sdk.api.errors import NotFoundError
from mpt_extension_sdk.models import Agreement
from mpt_extension_sdk.models.external_id import ExternalIds
from mpt_extension_sdk.services.mpt_api_service.base import PaginatedCollection
from mpt_extension_sdk.tasks import TaskHandle
from pydantic import ValidationError

from mpt_extension_python_template.routers.api.agreement import (
FULL_PROGRESS,
SyncAgreementPayload,
get_agreement,
get_agreements,
sync_agreement,
)


async def test_get_reads_marketplace(mocker, agreement_payload, mpt_api_service):
agreement = mocker.Mock(spec=Agreement)
@pytest.fixture
def agreement_in_mpt(mocker, agreement_payload, vendor_external_id, mpt_api_service):
agreement = mocker.create_autospec(Agreement, instance=True)
agreement.to_dict.return_value = agreement_payload
ctx = mocker.create_autospec(APIContext, instance=True)
ctx.mpt_api_service = mpt_api_service
agreement.external_ids = ExternalIds(vendor=vendor_external_id)
mpt_api_service.agreements.get_by_id.return_value = agreement
return agreement

result = await get_agreement("AGR-1234-5678", ctx)

mpt_api_service.agreements.get_by_id.assert_awaited_once_with("AGR-1234-5678")
@pytest.fixture
def deferred_context(mocker, api_context_factory):
return api_context_factory(task=mocker.create_autospec(TaskHandle, instance=True))


@pytest.fixture
def mock_sleep(mocker):
return mocker.patch(
"mpt_extension_python_template.routers.api.agreement.asyncio.sleep", autospec=True
)


async def test_get_reads_marketplace(
api_context_factory, agreement_id, agreement_in_mpt, agreement_payload
):
result = await get_agreement(agreement_id, api_context_factory())

assert result.payload == agreement_payload


async def test_get_maps_marketplace_not_found(mocker, mpt_api_service):
ctx = mocker.create_autospec(APIContext, instance=True)
ctx.mpt_api_service = mpt_api_service
async def test_get_maps_marketplace_not_found(api_context_factory, mpt_api_service):
ctx = api_context_factory()
mpt_api_service.agreements.get_by_id.side_effect = MPTAPIError(
HTTPStatus.NOT_FOUND, "Not Found", {"detail": "Entity for given id not-found not found"}
)
Expand All @@ -40,39 +58,77 @@ async def test_get_maps_marketplace_not_found(mocker, mpt_api_service):
await get_agreement("not-found", ctx)


async def test_get_agreements_paginated(api_context_factory, agreement_payload):
page = PaginatedCollection(limit=10, offset=0, resources=[agreement_payload], total=2)
ctx = api_context_factory(offset=0, limit=10)
ctx.mpt_api_service.agreements.get_all.return_value = page

result = await get_agreements(ctx)

ctx.mpt_api_service.agreements.get_all.assert_awaited_once_with(offset=0, limit=10)
assert result.payload == [agreement_payload]
assert result.paginated_result.total == 2
assert result.paginated_result.offset == 0
assert result.paginated_result.limit == 10


async def test_sync_answers_with_the_agreement(
api_context_factory, agreement_id, agreement_in_mpt, agreement_payload
):
result = await sync_agreement(
SyncAgreementPayload(agreement_id=agreement_id), api_context_factory()
)

assert result.payload == agreement_payload


async def test_sync_writes_back_as_the_vendor(
mocker, agreement_payload, mpt_api_service, vendor_mpt_api_service
api_context_factory, agreement_id, agreement_in_mpt, vendor_external_id
):
agreement = mocker.Mock(spec=Agreement)
agreement.to_dict.return_value = agreement_payload
agreement.external_ids = ExternalIds(vendor="ABC-2023-C07-dbeee0b302c0")
ctx = mocker.create_autospec(APIContext, instance=True)
ctx.mpt_api_service = mpt_api_service
ctx.vendor_mpt_api_service = vendor_mpt_api_service
mpt_api_service.agreements.get_by_id.return_value = agreement
ctx = api_context_factory()

result = await sync_agreement("AGR-1234-5678", ctx)
await sync_agreement(SyncAgreementPayload(agreement_id=agreement_id), ctx) # act

mpt_api_service.agreements.get_by_id.assert_awaited_once_with("AGR-1234-5678")
vendor_mpt_api_service.agreements.update.assert_awaited_once_with(
"AGR-1234-5678", {"externalIds": {"vendor": "ABC-2023-C07-dbeee0b302c0"}}
ctx.vendor_mpt_api_service.agreements.update.assert_awaited_once_with(
agreement_id, {"externalIds": {"vendor": vendor_external_id}}
)
mpt_api_service.agreements.update.assert_not_awaited()
assert result.payload == agreement_payload
ctx.mpt_api_service.agreements.update.assert_not_awaited()


async def test_get_agreements_paginated(mocker, agreement_payload, mpt_api_service):
page = mocker.Mock(spec=PaginatedCollection, resources=[agreement_payload], total=1)
ctx = mocker.create_autospec(APIContext, instance=True)
ctx.request = mocker.create_autospec(AuthenticatedRequestContext, instance=True)
ctx.request.pagination = mocker.Mock(spec=Pagination, offset=0, limit=10)
ctx.mpt_api_service = mpt_api_service
mpt_api_service.agreements.get_all.return_value = page
async def test_sync_deferred_returns_task_result(
deferred_context, agreement_id, agreement_in_mpt, mock_sleep
):
result = await sync_agreement(SyncAgreementPayload(agreement_id=agreement_id), deferred_context)

result = await get_agreements(ctx)
assert result == TaskResult.reference(f"/public/v1/commerce/agreements/{agreement_id}")

mpt_api_service.agreements.get_all.assert_awaited_once_with(offset=0, limit=10)
assert result.payload == [agreement_payload]
assert result.paginated_result.total == 1
assert result.paginated_result.offset == 0
assert result.paginated_result.limit == 10

async def test_sync_deferred_logs_the_wait(
deferred_context, agreement_id, agreement_in_mpt, mock_sleep
):
await sync_agreement( # act
SyncAgreementPayload(agreement_id=agreement_id), deferred_context
)

deferred_context.task.log.assert_awaited_once_with(
"Agreement sync for {{AgreementId}} waits for the redelivery before it runs",
parameters={"AgreementId": agreement_id},
)


async def test_sync_deferred_reports_progress(
deferred_context, agreement_id, agreement_in_mpt, mock_sleep
):
await sync_agreement( # act
SyncAgreementPayload(agreement_id=agreement_id), deferred_context
)

reported = [call.args[0] for call in deferred_context.task.progress.await_args_list]
assert reported == list(range(1, FULL_PROGRESS + 1))
assert mock_sleep.await_count == FULL_PROGRESS - 1


@pytest.mark.parametrize("unsafe_id", ["", "AGR-1/../other", "AGR 1", "AGR-1/sync"])
def test_sync_payload_rejects_an_unsafe_id(unsafe_id):
with pytest.raises(ValidationError):
SyncAgreementPayload(agreement_id=unsafe_id)
4 changes: 3 additions & 1 deletion backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ def test_app_registers_agreement_event_route():
def test_app_registers_sync_route():
result = {route.path: route for route in ext_app.routes}

assert isinstance(result["/api/v2/agreements/{agreement_id}/sync"], APIRouteDefinition)
sync_route = result["/api/v2/agreements/sync"]
assert isinstance(sync_route, APIRouteDefinition)
assert sync_route.deferrable is True


def test_app_registers_get_route():
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ repository; for the SDK APIs themselves, see the SDK references in

The extension currently registers:

- agreement API routes under `/api/v2/agreements`
- agreement API routes under `/api/v2/agreements`, with `POST /api/v2/agreements/sync` published in the `deferrables` metadata block
- order event route `/events/v2/orders/purchase`
- agreement event route `/events/v2/agreements/complete`
- agreement schedule route `/schedules/v1/agreements/sync`
Expand Down Expand Up @@ -61,7 +61,9 @@ This section only covers what is specific to *this* repository:
`components/` (presentational UI, including `AddPlugShowcase` reused by the
`add-*` modules), `hooks/` (for example `useAgreement`, which fetches
`/api/v2/agreements/{id}` through the SDK `http` client and exposes
load/error/ready states), and `model.ts` (response types and formatters).
load/error/ready states), `model.ts` (response types and formatters), and
`constants.ts` (paths, intervals and other configuration literals, kept out of
the modules that use them).
- **Build output**: the build emits bundles plus sourcemaps, and TypeScript
declarations under `static/types/`.

Expand Down
Loading
Loading