diff --git a/CHANGELOG.md b/CHANGELOG.md index ec133fc..12c9a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] - 2026-09-18 + +### Added +- `WebhookEndpoint.event_naming`, the spelling of the photo-mockup events an + endpoint is pinned to: `"current"` (`photo_mockup.*`, + `photo_mockup_render.*`) or `"legacy"` (`2d_mockup.*`, `2d_render.*`). The + payload's `kind` follows the same pin. The field carries a default of `None`, + so a client kept working against a deployment that predates it, and it stays + a plain string so a naming a later API release adds still parses. +- `client.webhook_endpoints.create(..., event_naming=)` and + `client.webhook_endpoints.update(uuid, event_naming=)` choose or change that + pin, on both the sync and the asyncio client. Left out on create, the field + is not sent and the API pins a new endpoint to `"current"`; left out on + update, the pin is untouched. `WebhookEventNaming`, the `Literal` of the two + values, is exported from the package. +- `JobKind`, a `Literal` of every `jobs.kind` value the API admits: + `render`, `video`, `upload`, `2d_create`, `2d_render`, + `photo_mockup_create` and `photo_mockup_render`. It is exported from the + package and types the `kind` filter of `client.jobs.list(...)`. A photo + mockup has two spellings of its kind, the published one (`2d_*`) and the + family's own name (`photo_mockup_*`); both name the same work, and a filter + on either spelling selects both. + +### Changed +- `client.ai.wait_for_2d_mockup(...)` accepts a job of kind + `photo_mockup_create` as well as `2d_create`. A job of any other kind still + raises `SudoMockError`; the message now reads + `expected a photo-mockup create job ('2d_create' or 'photo_mockup_create')` + instead of `expected '2d_create'`. + +`Job.kind` and `JobAccepted.kind` stay plain strings on purpose, so a kind a +later API release adds still parses instead of raising `ValidationError`. + ## [0.9.1] - 2026-09-18 ### Added @@ -220,7 +253,8 @@ parses is still present and still required. - Typed Pydantic v2 response models, typed exceptions, and tenacity-backed retry with exponential backoff. -[Unreleased]: https://github.com/sudomock/sudomock-python/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/sudomock/sudomock-python/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/sudomock/sudomock-python/compare/v0.9.1...v0.10.0 [0.7.0]: https://github.com/sudomock/sudomock-python/compare/v0.6.1...v0.7.0 [0.2.0]: https://github.com/sudomock/sudomock-python/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/sudomock/sudomock-python/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 861eec0..1d88faf 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,13 @@ ep = client.webhook_endpoints.create( ) print(ep.secret) # store this -- it signs deliveries +# Photo-mockup events reach an endpoint in one spelling, its `event_naming` +# pin: "current" (photo_mockup.*, photo_mockup_render.*) or "legacy" +# (2d_mockup.*, 2d_render.*). A new endpoint is pinned to "current"; pass +# "legacy" for a handler that still reads the older names, and re-pin later. +print(ep.event_naming) # "current" +client.webhook_endpoints.update(ep.id, event_naming="legacy") + # List / update / rotate / test / replay client.webhook_endpoints.list() client.webhook_endpoints.update(ep.id, enabled=False) @@ -490,7 +497,7 @@ client = SudoMock( | Method | Description | |--------|-------------| -| `client.jobs.list(kind=, mockup_uuid=, limit=, cursor=)` | List your async jobs (keyset-paginated, newest first) | +| `client.jobs.list(kind=, mockup_uuid=, limit=, cursor=)` | List your async jobs (keyset-paginated, newest first). `kind` is a `JobKind`: `render`, `video`, `upload`, `2d_create`, `2d_render`, `photo_mockup_create`, `photo_mockup_render`; a photo-mockup kind selects both spellings of that job | | `client.jobs.get(job_id)` | Get async job status (`queued`/`running`/`succeeded`/`failed`) | | `client.jobs.wait(job_id, poll_interval=2.0, timeout=300.0)` | Poll until the job reaches a terminal state | @@ -505,7 +512,7 @@ client = SudoMock( | Method | Description | |--------|-------------| | `client.ai.create(source_url=, source_base64=, name=, print_areas=, is_async=False, idempotency_key=)` | Create a 2D mockup (25 credits; sync `TwoDMockup` by default, or `JobAccepted` when `is_async=True`) | -| `client.ai.wait_for_2d_mockup(job_id, poll_interval=2.0, timeout=180.0)` | Wait for an `is_async=True` creation and return the full 2D mockup | +| `client.ai.wait_for_2d_mockup(job_id, poll_interval=2.0, timeout=180.0)` | Wait for an `is_async=True` creation and return the full 2D mockup (accepts a job of kind `2d_create` or `photo_mockup_create`) | | `client.ai.update_2d_print_areas(mockup_id, print_areas)` | Replace a 2D mockup's print areas (free) | | `client.ai.render(mockup_uuid=, print_areas=, export_options=, is_async=False)` | Render artwork onto a 2D mockup (5 credits; sync `AIRender` with `render_uuid` by default, or `JobAccepted` when `is_async=True`) | | `client.ai.list(limit=, offset=, customizable_only=)` | List your 2D mockups; set `customizable_only=True` for shopper-ready items | @@ -536,9 +543,9 @@ client = SudoMock( | Method | Description | |--------|-------------| | `client.webhook_endpoints.list()` | List registered endpoints | -| `client.webhook_endpoints.create(url=, events=, description=None)` | Register an endpoint (empty `events` = all) | +| `client.webhook_endpoints.create(url=, events=, description=None, event_naming=None)` | Register an endpoint (empty `events` = all; `event_naming` `"current"` / `"legacy"`, API default `"current"`) | | `client.webhook_endpoints.get(uuid)` | Get an endpoint | -| `client.webhook_endpoints.update(uuid, url=, events=, description=, enabled=)` | Update an endpoint | +| `client.webhook_endpoints.update(uuid, url=, events=, description=, enabled=, event_naming=)` | Update an endpoint (`event_naming` re-pins it to `"current"` or `"legacy"`) | | `client.webhook_endpoints.delete(uuid)` | Delete an endpoint | | `client.webhook_endpoints.rotate_secret(uuid)` | Rotate the signing secret | | `client.webhook_endpoints.test(uuid)` | Send a synthetic test delivery | diff --git a/pyproject.toml b/pyproject.toml index 9e28afb..5dde2d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sudomock" -version = "0.9.1" +version = "0.10.0" description = "Official Python SDK for the SudoMock Mockup Generator API" readme = "README.md" license = "MIT" diff --git a/src/sudomock/__init__.py b/src/sudomock/__init__.py index 2eb80b0..c9987c9 100644 --- a/src/sudomock/__init__.py +++ b/src/sudomock/__init__.py @@ -50,6 +50,7 @@ FullSurface, Job, JobAccepted, + JobKind, JobList, Mockup, MockupList, @@ -80,6 +81,7 @@ WebhookDeliveryList, WebhookEndpoint, WebhookEndpointList, + WebhookEventNaming, WebhookSecret, ) from .webhooks import verify_webhook_signature @@ -98,6 +100,7 @@ "FullSurface", "Job", "JobAccepted", + "JobKind", "JobList", "Mockup", "MockupList", @@ -128,6 +131,7 @@ "WebhookDeliveryList", "WebhookEndpoint", "WebhookEndpointList", + "WebhookEventNaming", "WebhookSecret", # Webhooks "verify_webhook_signature", diff --git a/src/sudomock/async_client.py b/src/sudomock/async_client.py index 64fcedb..090d9fd 100644 --- a/src/sudomock/async_client.py +++ b/src/sudomock/async_client.py @@ -47,11 +47,13 @@ from ._public_contract import public_2d_render_targets from .exceptions import JobFailedError, JobTimeoutError, SudoMockError from .models import ( + PHOTO_MOCKUP_CREATE_KINDS, AccountInfo, AIRender, BackgroundRemoval, Job, JobAccepted, + JobKind, JobList, Mockup, MockupList, @@ -69,6 +71,7 @@ WebhookDeliveryList, WebhookEndpoint, WebhookEndpointList, + WebhookEventNaming, WebhookSecret, ) @@ -319,7 +322,7 @@ def __init__(self, transport: AsyncTransport) -> None: async def list( self, *, - kind: Optional[str] = None, + kind: Optional[JobKind] = None, mockup_uuid: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None, @@ -327,8 +330,10 @@ async def list( """List your async jobs, newest first (keyset-paginated). Args: - kind: Filter by job kind: ``video``, ``render``, ``upload``, or - ``2d_create``. + kind: Filter by job kind: ``video``, ``render``, ``upload``, + ``2d_create``, ``2d_render``, ``photo_mockup_create`` or + ``photo_mockup_render``. A photo-mockup kind selects both + spellings of that job. mockup_uuid: Filter by source mockup (raw-image videos excluded). limit: Page size, 1..50 (default server-side: 20). cursor: Opaque keyset cursor from a previous page's ``next_cursor``. @@ -464,6 +469,7 @@ async def create( url: str, events: _StrList, description: Optional[str] = None, + event_naming: Optional[WebhookEventNaming] = None, ) -> WebhookEndpoint: """Register a new webhook endpoint. @@ -471,12 +477,20 @@ async def create( url: HTTPS URL that will receive POSTed events. events: Event types to subscribe to; an empty list subscribes to ALL. description: Optional human-readable label (≤255 chars). + event_naming: Which spelling of the photo-mockup events this endpoint + receives: ``"current"`` (``photo_mockup.*``, + ``photo_mockup_render.*``) or ``"legacy"`` (``2d_mockup.*``, + ``2d_render.*``). Left out, the API pins a new endpoint to + ``"current"``; pass ``"legacy"`` for a handler that still reads + the older names. """ # API field is `event_types` (empty list = subscribe to all events). # NOTE: the create endpoint has no `enabled` field (it is update-only). body: dict[str, Any] = {"url": url, "event_types": events} if description is not None: body["description"] = description + if event_naming is not None: + body["event_naming"] = event_naming resp = await self._transport.request("POST", "/api/v1/webhook-endpoints", json=body) # BARE endpoint object (no {success, data} envelope). return WebhookEndpoint.model_validate(resp.json()) @@ -517,8 +531,16 @@ async def update( events: Optional[_StrList] = None, description: Optional[str] = None, enabled: Optional[bool] = None, + event_naming: Optional[WebhookEventNaming] = None, ) -> WebhookEndpoint: - """Update a webhook endpoint's URL, events, description, or enabled state.""" + """Update a webhook endpoint's URL, events, description, enabled state + or event naming. + + Args: + event_naming: Re-pin the endpoint to ``"current"`` or ``"legacy"`` + event names once its handler is ready for them. Only the fields + passed are sent. + """ body: dict[str, Any] = {} if url is not None: body["url"] = url @@ -528,6 +550,8 @@ async def update( body["description"] = description if enabled is not None: body["enabled"] = enabled + if event_naming is not None: + body["event_naming"] = event_naming resp = await self._transport.request( "PATCH", f"/api/v1/webhook-endpoints/{uuid}", json=body ) @@ -667,8 +691,12 @@ async def wait_for_2d_mockup( except TimeoutError as exc: raise JobTimeoutError(job_id, timeout=timeout) from exc - if job.kind not in (None, "2d_create"): - raise SudoMockError(f"Job {job_id} has kind {job.kind!r}; expected '2d_create'") + if job.kind is not None and job.kind not in PHOTO_MOCKUP_CREATE_KINDS: + expected = " or ".join(repr(kind) for kind in PHOTO_MOCKUP_CREATE_KINDS) + raise SudoMockError( + f"Job {job_id} has kind {job.kind!r}; " + f"expected a photo-mockup create job ({expected})" + ) if job.failed: error_code, reason = job.failure_details() raise JobFailedError( diff --git a/src/sudomock/client.py b/src/sudomock/client.py index f1d75b8..9077488 100644 --- a/src/sudomock/client.py +++ b/src/sudomock/client.py @@ -31,11 +31,13 @@ from ._public_contract import public_2d_render_targets from .exceptions import JobFailedError, JobTimeoutError, SudoMockError from .models import ( + PHOTO_MOCKUP_CREATE_KINDS, AccountInfo, AIRender, BackgroundRemoval, Job, JobAccepted, + JobKind, JobList, Mockup, MockupList, @@ -53,6 +55,7 @@ WebhookDeliveryList, WebhookEndpoint, WebhookEndpointList, + WebhookEventNaming, WebhookSecret, ) @@ -328,7 +331,7 @@ def __init__(self, transport: SyncTransport) -> None: def list( self, *, - kind: Optional[str] = None, + kind: Optional[JobKind] = None, mockup_uuid: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None, @@ -336,8 +339,10 @@ def list( """List your async jobs, newest first (keyset-paginated). Args: - kind: Filter by job kind: ``video``, ``render``, ``upload``, or - ``2d_create``. + kind: Filter by job kind: ``video``, ``render``, ``upload``, + ``2d_create``, ``2d_render``, ``photo_mockup_create`` or + ``photo_mockup_render``. A photo-mockup kind selects both + spellings of that job. mockup_uuid: Filter by source mockup (raw-image videos excluded). limit: Page size, 1..50 (default server-side: 20). cursor: Opaque keyset cursor from a previous page's ``next_cursor``. @@ -489,6 +494,7 @@ def create( url: str, events: _StrList, description: Optional[str] = None, + event_naming: Optional[WebhookEventNaming] = None, ) -> WebhookEndpoint: """Register a new webhook endpoint. @@ -497,6 +503,12 @@ def create( events: Event types to subscribe to (e.g. ``["render.succeeded"]``); an empty list subscribes to ALL events. description: Optional human-readable label (≤255 chars). + event_naming: Which spelling of the photo-mockup events this endpoint + receives: ``"current"`` (``photo_mockup.*``, + ``photo_mockup_render.*``) or ``"legacy"`` (``2d_mockup.*``, + ``2d_render.*``). Left out, the API pins a new endpoint to + ``"current"``; pass ``"legacy"`` for a handler that still reads + the older names. Returns: The created :class:`WebhookEndpoint` (includes the signing @@ -507,6 +519,8 @@ def create( body: dict[str, Any] = {"url": url, "event_types": events} if description is not None: body["description"] = description + if event_naming is not None: + body["event_naming"] = event_naming resp = self._transport.request("POST", "/api/v1/webhook-endpoints", json=body) # BARE endpoint object (no {success, data} envelope). return WebhookEndpoint.model_validate(resp.json()) @@ -547,8 +561,16 @@ def update( events: Optional[_StrList] = None, description: Optional[str] = None, enabled: Optional[bool] = None, + event_naming: Optional[WebhookEventNaming] = None, ) -> WebhookEndpoint: - """Update a webhook endpoint's URL, events, description, or enabled state.""" + """Update a webhook endpoint's URL, events, description, enabled state + or event naming. + + Args: + event_naming: Re-pin the endpoint to ``"current"`` or ``"legacy"`` + event names once its handler is ready for them. Only the fields + passed are sent. + """ body: dict[str, Any] = {} if url is not None: body["url"] = url @@ -558,6 +580,8 @@ def update( body["description"] = description if enabled is not None: body["enabled"] = enabled + if event_naming is not None: + body["event_naming"] = event_naming resp = self._transport.request("PATCH", f"/api/v1/webhook-endpoints/{uuid}", json=body) # BARE endpoint object (no {success, data} envelope). return WebhookEndpoint.model_validate(resp.json()) @@ -706,8 +730,12 @@ def wait_for_2d_mockup( except TimeoutError as exc: raise JobTimeoutError(job_id, timeout=timeout) from exc - if job.kind not in (None, "2d_create"): - raise SudoMockError(f"Job {job_id} has kind {job.kind!r}; expected '2d_create'") + if job.kind is not None and job.kind not in PHOTO_MOCKUP_CREATE_KINDS: + expected = " or ".join(repr(kind) for kind in PHOTO_MOCKUP_CREATE_KINDS) + raise SudoMockError( + f"Job {job_id} has kind {job.kind!r}; " + f"expected a photo-mockup create job ({expected})" + ) if job.failed: error_code, reason = job.failure_details() raise JobFailedError( diff --git a/src/sudomock/models.py b/src/sudomock/models.py index 83fc5cc..2cdb94d 100644 --- a/src/sudomock/models.py +++ b/src/sudomock/models.py @@ -478,6 +478,27 @@ class BackgroundRemoval(_Outcome): # Async jobs (is_async renders / uploads / video) # --------------------------------------------------------------------------- +# Every ``jobs.kind`` value the API admits, in the order the API lists them. +# A photo mockup has two spellings of its kind: the published one +# (``2d_create`` / ``2d_render``) and the family's own name +# (``photo_mockup_create`` / ``photo_mockup_render``). Both name the same work +# and both can come back from ``GET /api/v1/jobs/{job_id}``; a filter on +# ``jobs.list(kind=...)`` with either spelling selects both. +JobKind = Literal[ + "render", + "video", + "upload", + "2d_create", + "2d_render", + "photo_mockup_create", + "photo_mockup_render", +] + +# The spellings that mean "create a photo mockup" / "render a photo mockup". +# A consumer that branches on the kind checks the pair, never one literal. +PHOTO_MOCKUP_CREATE_KINDS: tuple[str, ...] = ("2d_create", "photo_mockup_create") +PHOTO_MOCKUP_RENDER_KINDS: tuple[str, ...] = ("2d_render", "photo_mockup_render") + class JobAccepted(_Outcome): """Acknowledgement returned by a ``202 Accepted`` async submission. @@ -486,6 +507,9 @@ class JobAccepted(_Outcome): is_async=True)``, ``renders.create_video(...)``, and ``ai.create(..., is_async=True)``. Poll for completion with :meth:`jobs.get` or :meth:`jobs.wait` using :attr:`job_id`. + + :attr:`kind` is one of :data:`JobKind` today; it stays a plain string so a + kind this SDK does not know yet still parses instead of raising. """ job_id: str @@ -514,7 +538,8 @@ class Job(_Outcome): The terminal states are ``"succeeded"`` and ``"failed"``; ``"queued"`` and ``"running"`` are non-terminal. The current state value is exposed on - :attr:`status` (the API field is ``status``). + :attr:`status` (the API field is ``status``). :attr:`kind` is one of + :data:`JobKind` today and stays a plain string for forward compatibility. """ job_id: Optional[str] = None @@ -628,13 +653,29 @@ class VideoOptions(_Outcome): # --------------------------------------------------------------------------- +# The two spellings of the photo-mockup events an endpoint can be pinned to: +# ``"current"`` delivers ``photo_mockup.*`` / ``photo_mockup_render.*`` and +# ``"legacy"`` delivers ``2d_mockup.*`` / ``2d_render.*``; the payload's +# ``kind`` follows the same pin. Every other event is spelled the same under +# both. The API pins a new endpoint to ``"current"`` unless told otherwise; +# an endpoint that predates the current names stays on ``"legacy"`` until it +# is re-pinned. +WebhookEventNaming = Literal["legacy", "current"] + + class WebhookEndpoint(_Base): """A registered outbound webhook endpoint. Mirrors the API's ``WebhookEndpointResponse``: the identifier is ``id``, - subscribed events are ``event_types`` (empty = subscribe to all), and the + subscribed events are ``event_types`` (empty = subscribe to all), the ``secret`` is masked (``whsec_****``) except on create / rotate - where the full value is returned once. + where the full value is returned once, and ``event_naming`` is the + spelling of the photo-mockup events this endpoint receives. + + :attr:`event_naming` is one of :data:`WebhookEventNaming` today; it stays + a plain string so a naming a later API release adds still parses instead + of raising ``ValidationError``. It is ``None`` on a deployment that + predates the field. """ id: str @@ -643,6 +684,7 @@ class WebhookEndpoint(_Base): description: Optional[str] = None event_types: list[str] = Field(default_factory=list) enabled: bool = True + event_naming: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/tests/conftest.py b/tests/conftest.py index 4a97950..e09e617 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -420,6 +420,9 @@ "description": None, "event_types": ["render.succeeded", "render.failed"], "enabled": True, + # The spelling of the photo-mockup events this endpoint is pinned to + # ('legacy' or 'current'); the API reports it on every endpoint response. + "event_naming": "current", "created_at": "2026-06-21T10:00:00Z", "updated_at": None, } diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 2e00639..496653c 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -437,9 +437,31 @@ async def test_ai_wait_for_2d_mockup_rejects_wrong_job_kind( ) async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - with pytest.raises(SudoMockError, match="expected '2d_create'"): + with pytest.raises(SudoMockError, match="expected a photo-mockup create job"): await client.ai.wait_for_2d_mockup("video-job-001", poll_interval=0.0) + async def test_ai_wait_for_2d_mockup_accepts_family_kind( + self, mock_api: respx.MockRouter + ) -> None: + """A job stamped with the family kind is the same work as ``2d_create``.""" + job_route = mock_api.get("/api/v1/jobs/2d-create-job-001").mock( + return_value=httpx.Response( + 200, + json={**MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE, "kind": "photo_mockup_create"}, + ) + ) + detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_GET_RESPONSE) + ) + + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = await client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + + assert isinstance(result, TwoDMockup) + assert result.mockup_id == "2d-mockup-001" + assert len(job_route.calls) == 1 + assert len(detail_route.calls) == 1 + async def test_ai_wait_for_2d_mockup_missing_mockup_uuid( self, mock_api: respx.MockRouter ) -> None: diff --git a/tests/test_async_client_surface.py b/tests/test_async_client_surface.py index e990155..55b8c29 100644 --- a/tests/test_async_client_surface.py +++ b/tests/test_async_client_surface.py @@ -170,3 +170,34 @@ async def test_rotate_and_replay(self, mock_api: respx.MockRouter) -> None: await client.webhook_endpoints.replay_delivery("wh-1", "dlv-1") assert secret.secret == "whsec_new" assert len(route.calls) == 1 + + async def test_create_with_event_naming(self, mock_api: respx.MockRouter) -> None: + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = await client.webhook_endpoints.create( + url="https://x.com/wh", events=["photo_mockup.ready"], event_naming="current" + ) + body = json.loads(route.calls.last.request.content) + assert body["event_naming"] == "current" + assert result.event_naming == "current" + + async def test_create_omits_event_naming_by_default(self, mock_api: respx.MockRouter) -> None: + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + await client.webhook_endpoints.create(url="https://x.com/wh", events=[]) + body = json.loads(route.calls.last.request.content) + assert "event_naming" not in body + + async def test_update_event_naming(self, mock_api: respx.MockRouter) -> None: + route = mock_api.patch("/api/v1/webhook-endpoints/wh-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + async with AsyncSudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = await client.webhook_endpoints.update("wh-1", event_naming="legacy") + body = json.loads(route.calls.last.request.content) + assert body == {"event_naming": "legacy"} + assert result.event_naming == "current" # what the API reported back diff --git a/tests/test_client.py b/tests/test_client.py index 7162483..ffd13fe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -562,9 +562,29 @@ def test_ai_wait_for_2d_mockup_rejects_wrong_job_kind(self, mock_api: respx.Mock ) with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: - with pytest.raises(SudoMockError, match="expected '2d_create'"): + with pytest.raises(SudoMockError, match="expected a photo-mockup create job"): client.ai.wait_for_2d_mockup("video-job-001", poll_interval=0.0) + def test_ai_wait_for_2d_mockup_accepts_family_kind(self, mock_api: respx.MockRouter) -> None: + """A job stamped with the family kind is the same work as ``2d_create``.""" + job_route = mock_api.get("/api/v1/jobs/2d-create-job-001").mock( + return_value=httpx.Response( + 200, + json={**MOCK_2D_MOCKUP_JOB_SUCCEEDED_RESPONSE, "kind": "photo_mockup_create"}, + ) + ) + detail_route = mock_api.get("/api/v1/sudoai/2d-mockups/2d-mockup-001").mock( + return_value=httpx.Response(200, json=MOCK_2D_MOCKUP_GET_RESPONSE) + ) + + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = client.ai.wait_for_2d_mockup("2d-create-job-001", poll_interval=0.0) + + assert isinstance(result, TwoDMockup) + assert result.mockup_id == "2d-mockup-001" + assert len(job_route.calls) == 1 + assert len(detail_route.calls) == 1 + def test_ai_wait_for_2d_mockup_missing_mockup_uuid(self, mock_api: respx.MockRouter) -> None: mock_api.get("/api/v1/jobs/2d-create-job-001").mock( return_value=httpx.Response( diff --git a/tests/test_models.py b/tests/test_models.py index e93dd86..5b33427 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,10 +2,15 @@ from __future__ import annotations +from typing import get_args + import pytest from pydantic import ValidationError as PydanticValidationError +import sudomock from sudomock.models import ( + PHOTO_MOCKUP_CREATE_KINDS, + PHOTO_MOCKUP_RENDER_KINDS, Account, AccountInfo, AIRender, @@ -14,6 +19,7 @@ FullSurface, Job, JobAccepted, + JobKind, Mockup, MockupList, PrintFile, @@ -29,6 +35,7 @@ VideoOptions, WebhookDelivery, WebhookEndpoint, + WebhookEventNaming, ) @@ -298,6 +305,35 @@ def test_url_empty_raises(self) -> None: _ = r.url +class TestJobKind: + def test_job_kind_lists_every_spelling_the_api_admits(self) -> None: + """``JobKind`` names every ``jobs.kind`` value, published and family, in API order.""" + assert get_args(JobKind) == ( + "render", + "video", + "upload", + "2d_create", + "2d_render", + "photo_mockup_create", + "photo_mockup_render", + ) + + def test_job_kind_is_exported_from_the_package(self) -> None: + assert "JobKind" in sudomock.__all__ + assert sudomock.JobKind is JobKind + + def test_photo_mockup_create_kinds_pair_both_spellings(self) -> None: + assert PHOTO_MOCKUP_CREATE_KINDS == ("2d_create", "photo_mockup_create") + assert PHOTO_MOCKUP_RENDER_KINDS == ("2d_render", "photo_mockup_render") + for kind in PHOTO_MOCKUP_CREATE_KINDS + PHOTO_MOCKUP_RENDER_KINDS: + assert kind in get_args(JobKind) + + def test_job_kind_is_not_a_response_gate(self) -> None: + """A kind this SDK does not know yet still parses: ``Job.kind`` stays a string.""" + job = Job(job_id="j-1", kind="some_future_kind", status="queued") + assert job.kind == "some_future_kind" + + class TestJobAccepted: def test_parse(self) -> None: j = JobAccepted( @@ -405,6 +441,22 @@ def test_endpoint(self) -> None: assert wh.id == "wh-1" assert wh.enabled is True assert wh.event_types == ["render.succeeded"] + # Absent on a deployment that predates the pin. + assert wh.event_naming is None + + def test_endpoint_event_naming_pin(self) -> None: + wh = WebhookEndpoint(id="wh-1", url="https://x.com/wh", event_naming="legacy") + assert wh.event_naming == "legacy" + + def test_endpoint_event_naming_stays_a_plain_string(self) -> None: + """A naming a later API release adds still parses instead of raising.""" + wh = WebhookEndpoint(id="wh-1", url="https://x.com/wh", event_naming="future") + assert wh.event_naming == "future" + + def test_event_naming_literal_and_export(self) -> None: + assert get_args(WebhookEventNaming) == ("legacy", "current") + assert "WebhookEventNaming" in sudomock.__all__ + assert sudomock.WebhookEventNaming is WebhookEventNaming def test_delivery(self) -> None: d = WebhookDelivery( diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 9c47b48..9a14295 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -128,6 +128,74 @@ def test_replay_delivery(self, mock_api: respx.MockRouter) -> None: assert len(route.calls) == 1 +class TestWebhookEndpointEventNaming: + """The endpoint's ``event_naming`` pin: chosen on create, changed on update, read back.""" + + def test_create_sends_event_naming_when_given(self, mock_api: respx.MockRouter) -> None: + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.create( + url="https://x.com/wh", events=["2d_mockup.ready"], event_naming="legacy" + ) + body = json.loads(route.calls.last.request.content) + assert body["event_naming"] == "legacy" + + def test_create_omits_event_naming_by_default(self, mock_api: respx.MockRouter) -> None: + """Left out, the field is not sent: the API pins a new endpoint to 'current'.""" + route = mock_api.post("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(201, json=MOCK_WEBHOOK_CREATE_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.create(url="https://x.com/wh", events=[]) + body = json.loads(route.calls.last.request.content) + assert "event_naming" not in body + + def test_update_sends_event_naming(self, mock_api: respx.MockRouter) -> None: + route = mock_api.patch("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_GET_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.update("wh-uuid-1", event_naming="current") + body = json.loads(route.calls.last.request.content) + assert body == {"event_naming": "current"} + + def test_update_without_event_naming_does_not_send_it(self, mock_api: respx.MockRouter) -> None: + route = mock_api.patch("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_GET_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + client.webhook_endpoints.update("wh-uuid-1", enabled=True) + body = json.loads(route.calls.last.request.content) + assert body == {"enabled": True} + + def test_endpoint_reports_its_event_naming_pin(self, mock_api: respx.MockRouter) -> None: + mock_api.get("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_GET_RESPONSE) + ) + mock_api.get("/api/v1/webhook-endpoints").mock( + return_value=httpx.Response(200, json=MOCK_WEBHOOK_LIST_RESPONSE) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + single = client.webhook_endpoints.get("wh-uuid-1") + listed = client.webhook_endpoints.list() + assert single.event_naming == "current" + assert listed.webhook_endpoints[0].event_naming == "current" + + def test_endpoint_that_predates_the_pin_parses_without_it( + self, mock_api: respx.MockRouter + ) -> None: + """A deployment without the field still parses; the pin reads as ``None``.""" + older = {k: v for k, v in MOCK_WEBHOOK_GET_RESPONSE.items() if k != "event_naming"} + mock_api.get("/api/v1/webhook-endpoints/wh-uuid-1").mock( + return_value=httpx.Response(200, json=older) + ) + with SudoMock(api_key=TEST_API_KEY, base_url=TEST_BASE_URL) as client: + result = client.webhook_endpoints.get("wh-uuid-1") + assert result.event_naming is None + + # --------------------------------------------------------------------------- # Signature verification # ---------------------------------------------------------------------------