posthook-python 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {posthook_python-1.0.0 → posthook_python-1.1.0}/PKG-INFO +69 -1
  2. {posthook_python-1.0.0 → posthook_python-1.1.0}/README.md +68 -0
  3. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/__init__.py +10 -0
  4. posthook_python-1.1.0/src/posthook/_callbacks.py +127 -0
  5. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_errors.py +13 -0
  6. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_models.py +19 -0
  7. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_resources/_signatures.py +7 -0
  8. posthook_python-1.1.0/src/posthook/_version.py +1 -0
  9. posthook_python-1.1.0/tests/test_callbacks.py +260 -0
  10. {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_signatures.py +29 -0
  11. posthook_python-1.0.0/src/posthook/_version.py +0 -1
  12. {posthook_python-1.0.0 → posthook_python-1.1.0}/.gitignore +0 -0
  13. {posthook_python-1.0.0 → posthook_python-1.1.0}/LICENSE +0 -0
  14. {posthook_python-1.0.0 → posthook_python-1.1.0}/RELEASING.md +0 -0
  15. {posthook_python-1.0.0 → posthook_python-1.1.0}/pyproject.toml +0 -0
  16. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_client.py +0 -0
  17. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_http.py +0 -0
  18. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_resources/__init__.py +0 -0
  19. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/_resources/_hooks.py +0 -0
  20. {posthook_python-1.0.0 → posthook_python-1.1.0}/src/posthook/py.typed +0 -0
  21. {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/conftest.py +0 -0
  22. {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_client.py +0 -0
  23. {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_errors.py +0 -0
  24. {posthook_python-1.0.0 → posthook_python-1.1.0}/tests/test_hooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthook-python
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: The official Python client library for the Posthook API
5
5
  Project-URL: Homepage, https://posthook.io
6
6
  Project-URL: Documentation, https://posthook.io/docs
@@ -354,6 +354,74 @@ delivery = client.signatures.parse_delivery(
354
354
  )
355
355
  ```
356
356
 
357
+ ## Async Hooks
358
+
359
+ When [async hooks](https://posthook.io/docs/essentials/async-hooks) are enabled, `parse_delivery()` populates `ack_url` and `nack_url` on the delivery object. Return 202 from your handler and call back when processing completes.
360
+
361
+ ### FastAPI
362
+
363
+ ```python
364
+ from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
365
+ from fastapi.responses import Response
366
+ import posthook
367
+
368
+ app = FastAPI()
369
+ client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
370
+
371
+ async def process_and_ack(delivery):
372
+ try:
373
+ await process_video(delivery.data["video_id"])
374
+ result = await posthook.async_ack(delivery.ack_url)
375
+ print(f"Applied: {result.applied}")
376
+ except Exception as e:
377
+ await posthook.async_nack(delivery.nack_url, {"error": str(e)})
378
+
379
+ @app.post("/webhooks/process-video")
380
+ async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
381
+ body = await request.body()
382
+ try:
383
+ delivery = client.signatures.parse_delivery(body=body, headers=dict(request.headers))
384
+ except posthook.SignatureVerificationError:
385
+ raise HTTPException(status_code=401)
386
+
387
+ background_tasks.add_task(process_and_ack, delivery)
388
+ return Response(status_code=202)
389
+ ```
390
+
391
+ ### Callback functions
392
+
393
+ The SDK provides standalone callback functions -- pass the URL from the delivery object:
394
+
395
+ ```python
396
+ # Sync (Flask, Django, background workers)
397
+ result = posthook.ack(delivery.ack_url)
398
+ result = posthook.nack(delivery.nack_url, {"error": "processing failed"})
399
+
400
+ # Async (FastAPI, etc.)
401
+ result = await posthook.async_ack(delivery.ack_url)
402
+ result = await posthook.async_nack(delivery.nack_url, {"error": "processing failed"})
403
+ ```
404
+
405
+ Both return a `CallbackResult`:
406
+
407
+ ```python
408
+ result = posthook.ack(delivery.ack_url)
409
+ print(result.applied) # True if state changed, False if already resolved
410
+ print(result.status) # "completed", "not_found", "conflict", etc.
411
+ ```
412
+
413
+ `ack()` and `nack()` return normally for `200`, `404`, and `409` responses. They raise `CallbackError` for `401` (invalid token) and `410` (expired).
414
+
415
+ If processing happens in a separate worker, use the raw callback URLs instead:
416
+
417
+ ```python
418
+ queue.enqueue("transcode", {
419
+ "video_id": delivery.data["video_id"],
420
+ "ack_url": delivery.ack_url,
421
+ "nack_url": delivery.nack_url,
422
+ })
423
+ ```
424
+
357
425
  ## Error Handling
358
426
 
359
427
  All API errors extend `PosthookError` and can be caught with `isinstance` or `except`:
@@ -330,6 +330,74 @@ delivery = client.signatures.parse_delivery(
330
330
  )
331
331
  ```
332
332
 
333
+ ## Async Hooks
334
+
335
+ When [async hooks](https://posthook.io/docs/essentials/async-hooks) are enabled, `parse_delivery()` populates `ack_url` and `nack_url` on the delivery object. Return 202 from your handler and call back when processing completes.
336
+
337
+ ### FastAPI
338
+
339
+ ```python
340
+ from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
341
+ from fastapi.responses import Response
342
+ import posthook
343
+
344
+ app = FastAPI()
345
+ client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
346
+
347
+ async def process_and_ack(delivery):
348
+ try:
349
+ await process_video(delivery.data["video_id"])
350
+ result = await posthook.async_ack(delivery.ack_url)
351
+ print(f"Applied: {result.applied}")
352
+ except Exception as e:
353
+ await posthook.async_nack(delivery.nack_url, {"error": str(e)})
354
+
355
+ @app.post("/webhooks/process-video")
356
+ async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
357
+ body = await request.body()
358
+ try:
359
+ delivery = client.signatures.parse_delivery(body=body, headers=dict(request.headers))
360
+ except posthook.SignatureVerificationError:
361
+ raise HTTPException(status_code=401)
362
+
363
+ background_tasks.add_task(process_and_ack, delivery)
364
+ return Response(status_code=202)
365
+ ```
366
+
367
+ ### Callback functions
368
+
369
+ The SDK provides standalone callback functions -- pass the URL from the delivery object:
370
+
371
+ ```python
372
+ # Sync (Flask, Django, background workers)
373
+ result = posthook.ack(delivery.ack_url)
374
+ result = posthook.nack(delivery.nack_url, {"error": "processing failed"})
375
+
376
+ # Async (FastAPI, etc.)
377
+ result = await posthook.async_ack(delivery.ack_url)
378
+ result = await posthook.async_nack(delivery.nack_url, {"error": "processing failed"})
379
+ ```
380
+
381
+ Both return a `CallbackResult`:
382
+
383
+ ```python
384
+ result = posthook.ack(delivery.ack_url)
385
+ print(result.applied) # True if state changed, False if already resolved
386
+ print(result.status) # "completed", "not_found", "conflict", etc.
387
+ ```
388
+
389
+ `ack()` and `nack()` return normally for `200`, `404`, and `409` responses. They raise `CallbackError` for `401` (invalid token) and `410` (expired).
390
+
391
+ If processing happens in a separate worker, use the raw callback URLs instead:
392
+
393
+ ```python
394
+ queue.enqueue("transcode", {
395
+ "video_id": delivery.data["video_id"],
396
+ "ack_url": delivery.ack_url,
397
+ "nack_url": delivery.nack_url,
398
+ })
399
+ ```
400
+
333
401
  ## Error Handling
334
402
 
335
403
  All API errors extend `PosthookError` and can be caught with `isinstance` or `except`:
@@ -1,9 +1,11 @@
1
1
  """Posthook Python SDK — schedule, manage, and verify webhooks."""
2
2
 
3
+ from ._callbacks import ack, async_ack, async_nack, nack
3
4
  from ._client import AsyncPosthook, Posthook
4
5
  from ._errors import (
5
6
  AuthenticationError,
6
7
  BadRequestError,
8
+ CallbackError,
7
9
  ForbiddenError,
8
10
  InternalServerError,
9
11
  NotFoundError,
@@ -25,6 +27,7 @@ from ._models import (
25
27
  STRATEGY_EXPONENTIAL,
26
28
  STRATEGY_FIXED,
27
29
  BulkActionResult,
30
+ CallbackResult,
28
31
  Delivery,
29
32
  Hook,
30
33
  HookRetryOverride,
@@ -42,7 +45,13 @@ __all__ = [
42
45
  "HookRetryOverride",
43
46
  "QuotaInfo",
44
47
  "BulkActionResult",
48
+ "CallbackResult",
45
49
  "Delivery",
50
+ # Callbacks
51
+ "ack",
52
+ "nack",
53
+ "async_ack",
54
+ "async_nack",
46
55
  # Resources
47
56
  "SignaturesService",
48
57
  "create_signatures",
@@ -61,6 +70,7 @@ __all__ = [
61
70
  "PosthookError",
62
71
  "BadRequestError",
63
72
  "AuthenticationError",
73
+ "CallbackError",
64
74
  "ForbiddenError",
65
75
  "NotFoundError",
66
76
  "PayloadTooLargeError",
@@ -0,0 +1,127 @@
1
+ """Sync and async helpers for ack/nack callbacks on async hook deliveries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from ._errors import CallbackError
11
+ from ._models import CallbackResult
12
+
13
+
14
+ def _parse_callback_response(
15
+ response: httpx.Response,
16
+ action: str,
17
+ expected_status: str,
18
+ ) -> CallbackResult:
19
+ """Parse an ack/nack HTTP response into a CallbackResult.
20
+
21
+ 2xx → parse ``{data: {status}}`` from JSON, ``applied = (status == expected)``.
22
+ 404 → ``CallbackResult(applied=False, status="not_found")``.
23
+ 409 → ``CallbackResult(applied=False, status="conflict")``.
24
+ Other → raise ``CallbackError``.
25
+ """
26
+ if response.is_success:
27
+ try:
28
+ data = response.json()
29
+ status = data.get("data", {}).get("status", "unknown")
30
+ except Exception:
31
+ status = "unknown"
32
+ return CallbackResult(applied=(status == expected_status), status=status)
33
+
34
+ if response.status_code == 404:
35
+ return CallbackResult(applied=False, status="not_found")
36
+ if response.status_code == 409:
37
+ return CallbackResult(applied=False, status="conflict")
38
+
39
+ text = response.text
40
+ suffix = f": {text}" if text else ""
41
+ raise CallbackError(
42
+ f"{action} failed: {response.status_code}{suffix}",
43
+ status_code=response.status_code,
44
+ )
45
+
46
+
47
+ def _prepare_request(body: Any) -> tuple[bytes | None, dict[str, str]]:
48
+ """Prepare content and headers for a callback request."""
49
+ if body is not None:
50
+ return json.dumps(body).encode(), {"Content-Type": "application/json"}
51
+ return None, {}
52
+
53
+
54
+ def ack(url: str, body: Any = None) -> CallbackResult:
55
+ """Acknowledge async processing completion (synchronous).
56
+
57
+ Args:
58
+ url: The ack callback URL from ``delivery.ack_url``.
59
+ body: Optional JSON-serializable body to send with the callback.
60
+ Posthook currently ignores ack bodies.
61
+
62
+ Returns:
63
+ A ``CallbackResult`` indicating whether the ack was applied.
64
+
65
+ Raises:
66
+ CallbackError: For unexpected failures (401, 410, 5xx).
67
+ """
68
+ content, headers = _prepare_request(body)
69
+ response = httpx.post(url, content=content, headers=headers)
70
+ return _parse_callback_response(response, "ack", "completed")
71
+
72
+
73
+ def nack(url: str, body: Any = None) -> CallbackResult:
74
+ """Reject async processing — triggers retry or failure (synchronous).
75
+
76
+ Args:
77
+ url: The nack callback URL from ``delivery.nack_url``.
78
+ body: Optional JSON-serializable body explaining the failure.
79
+
80
+ Returns:
81
+ A ``CallbackResult`` indicating whether the nack was applied.
82
+
83
+ Raises:
84
+ CallbackError: For unexpected failures (401, 410, 5xx).
85
+ """
86
+ content, headers = _prepare_request(body)
87
+ response = httpx.post(url, content=content, headers=headers)
88
+ return _parse_callback_response(response, "nack", "nacked")
89
+
90
+
91
+ async def async_ack(url: str, body: Any = None) -> CallbackResult:
92
+ """Acknowledge async processing completion (asynchronous).
93
+
94
+ Args:
95
+ url: The ack callback URL from ``delivery.ack_url``.
96
+ body: Optional JSON-serializable body to send with the callback.
97
+ Posthook currently ignores ack bodies.
98
+
99
+ Returns:
100
+ A ``CallbackResult`` indicating whether the ack was applied.
101
+
102
+ Raises:
103
+ CallbackError: For unexpected failures (401, 410, 5xx).
104
+ """
105
+ content, headers = _prepare_request(body)
106
+ async with httpx.AsyncClient() as client:
107
+ response = await client.post(url, content=content, headers=headers)
108
+ return _parse_callback_response(response, "ack", "completed")
109
+
110
+
111
+ async def async_nack(url: str, body: Any = None) -> CallbackResult:
112
+ """Reject async processing — triggers retry or failure (asynchronous).
113
+
114
+ Args:
115
+ url: The nack callback URL from ``delivery.nack_url``.
116
+ body: Optional JSON-serializable body explaining the failure.
117
+
118
+ Returns:
119
+ A ``CallbackResult`` indicating whether the nack was applied.
120
+
121
+ Raises:
122
+ CallbackError: For unexpected failures (401, 410, 5xx).
123
+ """
124
+ content, headers = _prepare_request(body)
125
+ async with httpx.AsyncClient() as client:
126
+ response = await client.post(url, content=content, headers=headers)
127
+ return _parse_callback_response(response, "nack", "nacked")
@@ -97,6 +97,19 @@ class SignatureVerificationError(PosthookError):
97
97
  super().__init__(message, code="signature_verification_error")
98
98
 
99
99
 
100
+ class CallbackError(PosthookError):
101
+ """Raised when an ack/nack callback fails unexpectedly.
102
+
103
+ This is thrown for non-recoverable failures such as invalid tokens (401),
104
+ expired tokens (410), or server errors (5xx). Expected no-ops like 404
105
+ (hook deleted) and 409 (stale token) are returned as ``CallbackResult``
106
+ with ``applied=False`` instead.
107
+ """
108
+
109
+ def __init__(self, message: str, status_code: int | None = None) -> None:
110
+ super().__init__(message, status_code=status_code, code="callback_error")
111
+
112
+
100
113
  def _create_error(
101
114
  status_code: int,
102
115
  message: str,
@@ -148,6 +148,21 @@ class BulkActionResult:
148
148
 
149
149
 
150
150
  @dataclass(frozen=True)
151
+ class CallbackResult:
152
+ """Result of an ack or nack callback.
153
+
154
+ Both ``ack()`` and ``nack()`` return this for all expected outcomes,
155
+ including race conditions where the hook already resolved. Check
156
+ ``applied`` to see if your callback changed the hook's state.
157
+ """
158
+
159
+ applied: bool
160
+ """Whether the callback changed the hook's state."""
161
+ status: str
162
+ """The hook's current status (e.g. ``"completed"``, ``"nacked"``, ``"not_found"``)."""
163
+
164
+
165
+ @dataclass
151
166
  class Delivery:
152
167
  """A parsed and verified webhook delivery."""
153
168
 
@@ -160,3 +175,7 @@ class Delivery:
160
175
  posted_at: datetime
161
176
  created_at: datetime
162
177
  updated_at: datetime
178
+ ack_url: str | None = None
179
+ """Callback URL for acknowledging async processing. Present when both ack and nack headers exist."""
180
+ nack_url: str | None = None
181
+ """Callback URL for negative acknowledgement. Present when both ack and nack headers exist."""
@@ -128,6 +128,11 @@ class SignaturesService:
128
128
  f"Failed to parse delivery payload: {exc}"
129
129
  )
130
130
 
131
+ # Extract async callback URLs (set both or neither).
132
+ ack_url = _get_header(headers, "Posthook-Ack-URL")
133
+ nack_url = _get_header(headers, "Posthook-Nack-URL")
134
+ has_callbacks = bool(ack_url and nack_url)
135
+
131
136
  return Delivery(
132
137
  hook_id=hook_id,
133
138
  timestamp=timestamp,
@@ -138,6 +143,8 @@ class SignaturesService:
138
143
  posted_at=_parse_dt(payload.get("postedAt", "")),
139
144
  created_at=_parse_dt(payload.get("createdAt", "")),
140
145
  updated_at=_parse_dt(payload.get("updatedAt", "")),
146
+ ack_url=ack_url if has_callbacks else None,
147
+ nack_url=nack_url if has_callbacks else None,
141
148
  )
142
149
 
143
150
 
@@ -0,0 +1 @@
1
+ VERSION = "1.1.0"
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ import posthook
9
+ from posthook import CallbackError, CallbackResult
10
+ from posthook._callbacks import ack, async_ack, async_nack, nack
11
+
12
+
13
+ # ─── Helpers ────────────────────────────────────────────────────────
14
+
15
+
16
+ def _mock_response(
17
+ status_code: int,
18
+ body: dict | None = None,
19
+ text: str = "",
20
+ ) -> httpx.Response:
21
+ if body is not None:
22
+ return httpx.Response(status_code, json=body)
23
+ return httpx.Response(status_code, text=text)
24
+
25
+
26
+ # ─── Sync ack() ─────────────────────────────────────────────────────
27
+
28
+
29
+ class TestAckSync:
30
+ def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
31
+ monkeypatch.setattr(
32
+ httpx, "post",
33
+ lambda url, **kw: _mock_response(200, {"data": {"status": "completed"}}),
34
+ )
35
+ result = ack("https://api.posthook.io/ack/token123")
36
+ assert result == CallbackResult(applied=True, status="completed")
37
+
38
+ def test_success_not_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
39
+ """Server returns 200 but status is not 'completed' (idempotent no-op)."""
40
+ monkeypatch.setattr(
41
+ httpx, "post",
42
+ lambda url, **kw: _mock_response(200, {"data": {"status": "nacked"}}),
43
+ )
44
+ result = ack("https://api.posthook.io/ack/token123")
45
+ assert result == CallbackResult(applied=False, status="nacked")
46
+
47
+ def test_404_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
48
+ monkeypatch.setattr(
49
+ httpx, "post",
50
+ lambda url, **kw: _mock_response(404),
51
+ )
52
+ result = ack("https://api.posthook.io/ack/token123")
53
+ assert result == CallbackResult(applied=False, status="not_found")
54
+
55
+ def test_409_conflict(self, monkeypatch: pytest.MonkeyPatch) -> None:
56
+ monkeypatch.setattr(
57
+ httpx, "post",
58
+ lambda url, **kw: _mock_response(409),
59
+ )
60
+ result = ack("https://api.posthook.io/ack/token123")
61
+ assert result == CallbackResult(applied=False, status="conflict")
62
+
63
+ def test_401_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
64
+ monkeypatch.setattr(
65
+ httpx, "post",
66
+ lambda url, **kw: _mock_response(401, text="unauthorized"),
67
+ )
68
+ with pytest.raises(CallbackError, match="ack failed: 401"):
69
+ ack("https://api.posthook.io/ack/token123")
70
+
71
+ def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
72
+ monkeypatch.setattr(
73
+ httpx, "post",
74
+ lambda url, **kw: _mock_response(410, text="gone"),
75
+ )
76
+ with pytest.raises(CallbackError, match="ack failed: 410"):
77
+ ack("https://api.posthook.io/ack/token123")
78
+
79
+ def test_500_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
80
+ monkeypatch.setattr(
81
+ httpx, "post",
82
+ lambda url, **kw: _mock_response(500, text="internal error"),
83
+ )
84
+ with pytest.raises(CallbackError, match="ack failed: 500"):
85
+ ack("https://api.posthook.io/ack/token123")
86
+
87
+ def test_json_body_sent(self, monkeypatch: pytest.MonkeyPatch) -> None:
88
+ """Verify that a JSON body is serialized and Content-Type is set."""
89
+ captured: dict = {}
90
+
91
+ def mock_post(url: str, **kwargs) -> httpx.Response:
92
+ captured["content"] = kwargs.get("content")
93
+ captured["headers"] = kwargs.get("headers")
94
+ return _mock_response(200, {"data": {"status": "completed"}})
95
+
96
+ monkeypatch.setattr(httpx, "post", mock_post)
97
+ ack("https://api.posthook.io/ack/token123", body={"done": True})
98
+
99
+ assert json.loads(captured["content"]) == {"done": True}
100
+ assert captured["headers"]["Content-Type"] == "application/json"
101
+
102
+ def test_no_body_no_content_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
103
+ """Without a body, no Content-Type header should be sent."""
104
+ captured: dict = {}
105
+
106
+ def mock_post(url: str, **kwargs) -> httpx.Response:
107
+ captured["content"] = kwargs.get("content")
108
+ captured["headers"] = kwargs.get("headers")
109
+ return _mock_response(200, {"data": {"status": "completed"}})
110
+
111
+ monkeypatch.setattr(httpx, "post", mock_post)
112
+ ack("https://api.posthook.io/ack/token123")
113
+
114
+ assert captured["content"] is None
115
+ assert captured["headers"] == {}
116
+
117
+
118
+ # ─── Sync nack() ────────────────────────────────────────────────────
119
+
120
+
121
+ class TestNackSync:
122
+ def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
123
+ monkeypatch.setattr(
124
+ httpx, "post",
125
+ lambda url, **kw: _mock_response(200, {"data": {"status": "nacked"}}),
126
+ )
127
+ result = nack("https://api.posthook.io/nack/token123")
128
+ assert result == CallbackResult(applied=True, status="nacked")
129
+
130
+ def test_success_not_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
131
+ monkeypatch.setattr(
132
+ httpx, "post",
133
+ lambda url, **kw: _mock_response(200, {"data": {"status": "completed"}}),
134
+ )
135
+ result = nack("https://api.posthook.io/nack/token123")
136
+ assert result == CallbackResult(applied=False, status="completed")
137
+
138
+ def test_404_not_found(self, monkeypatch: pytest.MonkeyPatch) -> None:
139
+ monkeypatch.setattr(
140
+ httpx, "post",
141
+ lambda url, **kw: _mock_response(404),
142
+ )
143
+ result = nack("https://api.posthook.io/nack/token123")
144
+ assert result == CallbackResult(applied=False, status="not_found")
145
+
146
+ def test_409_conflict(self, monkeypatch: pytest.MonkeyPatch) -> None:
147
+ monkeypatch.setattr(
148
+ httpx, "post",
149
+ lambda url, **kw: _mock_response(409),
150
+ )
151
+ result = nack("https://api.posthook.io/nack/token123")
152
+ assert result == CallbackResult(applied=False, status="conflict")
153
+
154
+ def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
155
+ monkeypatch.setattr(
156
+ httpx, "post",
157
+ lambda url, **kw: _mock_response(410, text="gone"),
158
+ )
159
+ with pytest.raises(CallbackError, match="nack failed: 410"):
160
+ nack("https://api.posthook.io/nack/token123")
161
+
162
+
163
+ # ─── Async ack/nack ─────────────────────────────────────────────────
164
+
165
+
166
+ class _MockAsyncClient:
167
+ """Replaces httpx.AsyncClient for async tests."""
168
+
169
+ def __init__(self, handler):
170
+ self._handler = handler
171
+
172
+ async def __aenter__(self):
173
+ return self
174
+
175
+ async def __aexit__(self, *args):
176
+ pass
177
+
178
+ async def post(self, url, **kwargs):
179
+ return self._handler(url, **kwargs)
180
+
181
+
182
+ class TestAsyncAck:
183
+ async def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
184
+ monkeypatch.setattr(
185
+ httpx, "AsyncClient",
186
+ lambda **kw: _MockAsyncClient(
187
+ lambda url, **kw: _mock_response(200, {"data": {"status": "completed"}}),
188
+ ),
189
+ )
190
+ result = await async_ack("https://api.posthook.io/ack/token123")
191
+ assert result == CallbackResult(applied=True, status="completed")
192
+
193
+ async def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
194
+ monkeypatch.setattr(
195
+ httpx, "AsyncClient",
196
+ lambda **kw: _MockAsyncClient(
197
+ lambda url, **kw: _mock_response(410, text="gone"),
198
+ ),
199
+ )
200
+ with pytest.raises(CallbackError, match="ack failed: 410"):
201
+ await async_ack("https://api.posthook.io/ack/token123")
202
+
203
+ async def test_500_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
204
+ monkeypatch.setattr(
205
+ httpx, "AsyncClient",
206
+ lambda **kw: _MockAsyncClient(
207
+ lambda url, **kw: _mock_response(500, text="boom"),
208
+ ),
209
+ )
210
+ with pytest.raises(CallbackError, match="ack failed: 500"):
211
+ await async_ack("https://api.posthook.io/ack/token123")
212
+
213
+
214
+ class TestAsyncNack:
215
+ async def test_success_applied(self, monkeypatch: pytest.MonkeyPatch) -> None:
216
+ monkeypatch.setattr(
217
+ httpx, "AsyncClient",
218
+ lambda **kw: _MockAsyncClient(
219
+ lambda url, **kw: _mock_response(200, {"data": {"status": "nacked"}}),
220
+ ),
221
+ )
222
+ result = await async_nack("https://api.posthook.io/nack/token123")
223
+ assert result == CallbackResult(applied=True, status="nacked")
224
+
225
+ async def test_409_conflict(self, monkeypatch: pytest.MonkeyPatch) -> None:
226
+ monkeypatch.setattr(
227
+ httpx, "AsyncClient",
228
+ lambda **kw: _MockAsyncClient(
229
+ lambda url, **kw: _mock_response(409),
230
+ ),
231
+ )
232
+ result = await async_nack("https://api.posthook.io/nack/token123")
233
+ assert result == CallbackResult(applied=False, status="conflict")
234
+
235
+ async def test_410_raises_callback_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
236
+ monkeypatch.setattr(
237
+ httpx, "AsyncClient",
238
+ lambda **kw: _MockAsyncClient(
239
+ lambda url, **kw: _mock_response(410, text="gone"),
240
+ ),
241
+ )
242
+ with pytest.raises(CallbackError, match="nack failed: 410"):
243
+ await async_nack("https://api.posthook.io/nack/token123")
244
+
245
+
246
+ # ─── Importability ───────────────────────────────────────────────────
247
+
248
+
249
+ class TestExports:
250
+ def test_callback_result_importable(self) -> None:
251
+ assert hasattr(posthook, "CallbackResult")
252
+
253
+ def test_callback_error_importable(self) -> None:
254
+ assert hasattr(posthook, "CallbackError")
255
+
256
+ def test_ack_importable(self) -> None:
257
+ assert hasattr(posthook, "ack")
258
+ assert hasattr(posthook, "nack")
259
+ assert hasattr(posthook, "async_ack")
260
+ assert hasattr(posthook, "async_nack")
@@ -204,3 +204,32 @@ class TestCreateSignatures:
204
204
  def test_importable_from_package(self) -> None:
205
205
  assert hasattr(posthook, "create_signatures")
206
206
  assert posthook.create_signatures is create_signatures
207
+
208
+
209
+ class TestCallbackHeaders:
210
+ """Test that parse_delivery extracts ack/nack URLs from headers."""
211
+
212
+ def test_both_headers_present(self) -> None:
213
+ svc = SignaturesService("ph_sk_test")
214
+ body, headers = _make_delivery()
215
+ headers["Posthook-Ack-URL"] = "https://api.posthook.io/ack/token123"
216
+ headers["Posthook-Nack-URL"] = "https://api.posthook.io/nack/token123"
217
+ delivery = svc.parse_delivery(body, headers)
218
+ assert delivery.ack_url == "https://api.posthook.io/ack/token123"
219
+ assert delivery.nack_url == "https://api.posthook.io/nack/token123"
220
+
221
+ def test_no_headers(self) -> None:
222
+ svc = SignaturesService("ph_sk_test")
223
+ body, headers = _make_delivery()
224
+ delivery = svc.parse_delivery(body, headers)
225
+ assert delivery.ack_url is None
226
+ assert delivery.nack_url is None
227
+
228
+ def test_only_ack_header(self) -> None:
229
+ """When only one header is present, both should be None."""
230
+ svc = SignaturesService("ph_sk_test")
231
+ body, headers = _make_delivery()
232
+ headers["Posthook-Ack-URL"] = "https://api.posthook.io/ack/token123"
233
+ delivery = svc.parse_delivery(body, headers)
234
+ assert delivery.ack_url is None
235
+ assert delivery.nack_url is None
@@ -1 +0,0 @@
1
- VERSION = "1.0.0"
File without changes