nitroping 0.2.1__tar.gz → 0.2.3__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.
- {nitroping-0.2.1 → nitroping-0.2.3}/PKG-INFO +13 -18
- {nitroping-0.2.1 → nitroping-0.2.3}/README.md +12 -17
- {nitroping-0.2.1 → nitroping-0.2.3}/pyproject.toml +1 -1
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/__init__.py +19 -1
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/_client.py +94 -2
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/_devices.py +37 -4
- nitroping-0.2.3/src/nitroping/_events.py +53 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/_http.py +5 -2
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/_notifications.py +20 -1
- nitroping-0.2.3/src/nitroping/_track.py +55 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/types.py +43 -2
- {nitroping-0.2.1 → nitroping-0.2.3}/tests/test_devices.py +30 -0
- nitroping-0.2.3/tests/test_events.py +80 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/tests/test_notifications.py +41 -0
- nitroping-0.2.3/tests/test_track.py +49 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/.gitignore +0 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/errors.py +0 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/py.typed +0 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/src/nitroping/webhooks.py +0 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/tests/conftest.py +0 -0
- {nitroping-0.2.1 → nitroping-0.2.3}/tests/test_webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nitroping
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Zero-dependency Python SDK for nitroping push notifications. Send pushes, register devices, verify webhooks.
|
|
5
5
|
Project-URL: Homepage, https://nitroping.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/productdevbook/nitroping-sdk
|
|
@@ -24,22 +24,17 @@ Requires-Dist: pytest>=8; extra == 'dev'
|
|
|
24
24
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
> The PyPI package name (`nitroping`) is unchanged. See the [top-level README](../README.md) for SDKs in other languages.
|
|
27
|
+
# nitroping (Python SDK)
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/pyversions/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="Python versions"></a>
|
|
40
|
-
<a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/dm/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI downloads"></a>
|
|
41
|
-
<a href="https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/nitroping-sdk?style=flat&colorA=18181B&colorB=34d399" alt="license"></a>
|
|
42
|
-
</p>
|
|
29
|
+
[](https://pypi.org/project/nitroping/)
|
|
30
|
+
[](https://pypi.org/project/nitroping/)
|
|
31
|
+
[](https://pypi.org/project/nitroping/)
|
|
32
|
+
[](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE)
|
|
33
|
+
[](https://pypi.org/project/nitroping/)
|
|
34
|
+
|
|
35
|
+
> Zero-dependency Python SDK for [nitroping](https://nitroping.dev) push notifications. Send pushes, register devices, verify webhooks. Pure stdlib, runs on Python 3.10+.
|
|
36
|
+
|
|
37
|
+
> 📦 Part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo. The PyPI package name (`nitroping`) is unchanged. See the [root README](https://github.com/productdevbook/nitroping-sdk#readme) for SDKs in other languages.
|
|
43
38
|
|
|
44
39
|
## Why nitroping?
|
|
45
40
|
|
|
@@ -415,12 +410,12 @@ from nitroping import NotificationResult, RegisterDeviceResult, WebhookEvent
|
|
|
415
410
|
|
|
416
411
|
## License
|
|
417
412
|
|
|
418
|
-
[
|
|
413
|
+
MIT — see [LICENSE](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE). Copyright (c) 2026 productdevbook.
|
|
419
414
|
|
|
420
415
|
---
|
|
421
416
|
|
|
422
417
|
<p align="center">
|
|
423
418
|
<sub>
|
|
424
|
-
Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
|
|
419
|
+
Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping-sdk#readme">monorepo</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
|
|
425
420
|
</sub>
|
|
426
421
|
</p>
|
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
> The PyPI package name (`nitroping`) is unchanged. See the [top-level README](../README.md) for SDKs in other languages.
|
|
1
|
+
# nitroping (Python SDK)
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/pyversions/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="Python versions"></a>
|
|
14
|
-
<a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/dm/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI downloads"></a>
|
|
15
|
-
<a href="https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/nitroping-sdk?style=flat&colorA=18181B&colorB=34d399" alt="license"></a>
|
|
16
|
-
</p>
|
|
3
|
+
[](https://pypi.org/project/nitroping/)
|
|
4
|
+
[](https://pypi.org/project/nitroping/)
|
|
5
|
+
[](https://pypi.org/project/nitroping/)
|
|
6
|
+
[](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE)
|
|
7
|
+
[](https://pypi.org/project/nitroping/)
|
|
8
|
+
|
|
9
|
+
> Zero-dependency Python SDK for [nitroping](https://nitroping.dev) push notifications. Send pushes, register devices, verify webhooks. Pure stdlib, runs on Python 3.10+.
|
|
10
|
+
|
|
11
|
+
> 📦 Part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo. The PyPI package name (`nitroping`) is unchanged. See the [root README](https://github.com/productdevbook/nitroping-sdk#readme) for SDKs in other languages.
|
|
17
12
|
|
|
18
13
|
## Why nitroping?
|
|
19
14
|
|
|
@@ -389,12 +384,12 @@ from nitroping import NotificationResult, RegisterDeviceResult, WebhookEvent
|
|
|
389
384
|
|
|
390
385
|
## License
|
|
391
386
|
|
|
392
|
-
[
|
|
387
|
+
MIT — see [LICENSE](https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE). Copyright (c) 2026 productdevbook.
|
|
393
388
|
|
|
394
389
|
---
|
|
395
390
|
|
|
396
391
|
<p align="center">
|
|
397
392
|
<sub>
|
|
398
|
-
Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
|
|
393
|
+
Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping-sdk#readme">monorepo</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
|
|
399
394
|
</sub>
|
|
400
395
|
</p>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nitroping"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Zero-dependency Python SDK for nitroping push notifications. Send pushes, register devices, verify webhooks."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -21,8 +21,10 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
from ._client import AsyncNitroping, Nitroping
|
|
23
23
|
from ._devices import DevicesClient
|
|
24
|
+
from ._events import EventsClient
|
|
24
25
|
from ._http import DEFAULT_BASE_URL, HttpClient
|
|
25
26
|
from ._notifications import NotificationsClient
|
|
27
|
+
from ._track import TrackClient
|
|
26
28
|
from .errors import (
|
|
27
29
|
ApiError,
|
|
28
30
|
InvalidSignatureError,
|
|
@@ -32,27 +34,37 @@ from .errors import (
|
|
|
32
34
|
TimestampOutOfRangeError,
|
|
33
35
|
)
|
|
34
36
|
from .types import (
|
|
37
|
+
CancelNotificationResult,
|
|
35
38
|
DeactivateDeviceResult,
|
|
39
|
+
EngagementEvent,
|
|
36
40
|
NotificationAction,
|
|
37
41
|
NotificationResult,
|
|
38
42
|
NotificationTarget,
|
|
39
43
|
Platform,
|
|
40
44
|
RegisterDeviceResult,
|
|
45
|
+
ReportEventResult,
|
|
41
46
|
SendOptions,
|
|
42
47
|
TargetAll,
|
|
43
48
|
TargetDeviceIds,
|
|
49
|
+
TargetTags,
|
|
44
50
|
TargetUserIds,
|
|
51
|
+
TrackEvent,
|
|
52
|
+
TrackResult,
|
|
53
|
+
UpdateDeviceResult,
|
|
45
54
|
WebhookEvent,
|
|
46
55
|
)
|
|
47
56
|
|
|
48
|
-
__version__ = "0.
|
|
57
|
+
__version__ = "0.2.3"
|
|
49
58
|
|
|
50
59
|
__all__ = [
|
|
51
60
|
"DEFAULT_BASE_URL",
|
|
52
61
|
"ApiError",
|
|
53
62
|
"AsyncNitroping",
|
|
63
|
+
"CancelNotificationResult",
|
|
54
64
|
"DeactivateDeviceResult",
|
|
55
65
|
"DevicesClient",
|
|
66
|
+
"EngagementEvent",
|
|
67
|
+
"EventsClient",
|
|
56
68
|
"HttpClient",
|
|
57
69
|
"InvalidSignatureError",
|
|
58
70
|
"MissingSignatureHeaderError",
|
|
@@ -65,11 +77,17 @@ __all__ = [
|
|
|
65
77
|
"NotificationsClient",
|
|
66
78
|
"Platform",
|
|
67
79
|
"RegisterDeviceResult",
|
|
80
|
+
"ReportEventResult",
|
|
68
81
|
"SendOptions",
|
|
69
82
|
"TargetAll",
|
|
70
83
|
"TargetDeviceIds",
|
|
84
|
+
"TargetTags",
|
|
71
85
|
"TargetUserIds",
|
|
72
86
|
"TimestampOutOfRangeError",
|
|
87
|
+
"TrackClient",
|
|
88
|
+
"TrackEvent",
|
|
89
|
+
"TrackResult",
|
|
90
|
+
"UpdateDeviceResult",
|
|
73
91
|
"WebhookEvent",
|
|
74
92
|
"__version__",
|
|
75
93
|
]
|
|
@@ -7,16 +7,24 @@ import os
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from ._devices import DevicesClient
|
|
10
|
+
from ._events import EventsClient
|
|
10
11
|
from ._http import DEFAULT_BASE_URL, HttpClient
|
|
11
12
|
from ._notifications import NotificationsClient
|
|
13
|
+
from ._track import TrackClient
|
|
12
14
|
from .errors import NitropingError
|
|
13
15
|
from .types import (
|
|
16
|
+
CancelNotificationResult,
|
|
14
17
|
DeactivateDeviceResult,
|
|
18
|
+
EngagementEvent,
|
|
15
19
|
NotificationAction,
|
|
16
20
|
NotificationResult,
|
|
17
21
|
NotificationTarget,
|
|
18
22
|
Platform,
|
|
19
23
|
RegisterDeviceResult,
|
|
24
|
+
ReportEventResult,
|
|
25
|
+
TrackEvent,
|
|
26
|
+
TrackResult,
|
|
27
|
+
UpdateDeviceResult,
|
|
20
28
|
)
|
|
21
29
|
|
|
22
30
|
|
|
@@ -36,10 +44,14 @@ class Nitroping:
|
|
|
36
44
|
print(result["id"], result["status"])
|
|
37
45
|
"""
|
|
38
46
|
|
|
39
|
-
#: ``notifications`` resource — send, get.
|
|
47
|
+
#: ``notifications`` resource — send, get, cancel.
|
|
40
48
|
notifications: NotificationsClient
|
|
41
|
-
#: ``devices`` resource — register, deactivate.
|
|
49
|
+
#: ``devices`` resource — register, update, deactivate.
|
|
42
50
|
devices: DevicesClient
|
|
51
|
+
#: ``track`` resource — delivery/open/click callbacks (``POST /track``).
|
|
52
|
+
track: TrackClient
|
|
53
|
+
#: ``events`` resource — public engagement events (``POST /events``).
|
|
54
|
+
events: EventsClient
|
|
43
55
|
#: Internal HTTP client. Exposed for advanced use (custom requests).
|
|
44
56
|
http: HttpClient
|
|
45
57
|
|
|
@@ -68,6 +80,8 @@ class Nitroping:
|
|
|
68
80
|
)
|
|
69
81
|
self.notifications = NotificationsClient(self.http)
|
|
70
82
|
self.devices = DevicesClient(self.http)
|
|
83
|
+
self.track = TrackClient(self.http)
|
|
84
|
+
self.events = EventsClient(self.http)
|
|
71
85
|
|
|
72
86
|
|
|
73
87
|
class _AsyncNotificationsClient:
|
|
@@ -128,6 +142,12 @@ class _AsyncNotificationsClient:
|
|
|
128
142
|
None, self._inner.get, notification_id
|
|
129
143
|
)
|
|
130
144
|
|
|
145
|
+
async def cancel(self, notification_id: str) -> CancelNotificationResult:
|
|
146
|
+
loop = asyncio.get_running_loop()
|
|
147
|
+
return await loop.run_in_executor(
|
|
148
|
+
None, self._inner.cancel, notification_id
|
|
149
|
+
)
|
|
150
|
+
|
|
131
151
|
|
|
132
152
|
class _AsyncDevicesClient:
|
|
133
153
|
"""Awaitable façade over :class:`DevicesClient`."""
|
|
@@ -144,6 +164,7 @@ class _AsyncDevicesClient:
|
|
|
144
164
|
web_push_p256dh: str | None = None,
|
|
145
165
|
web_push_auth: str | None = None,
|
|
146
166
|
metadata: dict[str, Any] | None = None,
|
|
167
|
+
tags: list[str] | None = None,
|
|
147
168
|
) -> RegisterDeviceResult:
|
|
148
169
|
loop = asyncio.get_running_loop()
|
|
149
170
|
return await loop.run_in_executor(
|
|
@@ -155,9 +176,22 @@ class _AsyncDevicesClient:
|
|
|
155
176
|
web_push_p256dh=web_push_p256dh,
|
|
156
177
|
web_push_auth=web_push_auth,
|
|
157
178
|
metadata=metadata,
|
|
179
|
+
tags=tags,
|
|
158
180
|
),
|
|
159
181
|
)
|
|
160
182
|
|
|
183
|
+
async def update(
|
|
184
|
+
self,
|
|
185
|
+
device_id: str,
|
|
186
|
+
*,
|
|
187
|
+
tags: list[str] | None = None,
|
|
188
|
+
) -> UpdateDeviceResult:
|
|
189
|
+
loop = asyncio.get_running_loop()
|
|
190
|
+
return await loop.run_in_executor(
|
|
191
|
+
None,
|
|
192
|
+
lambda: self._inner.update(device_id, tags=tags),
|
|
193
|
+
)
|
|
194
|
+
|
|
161
195
|
async def deactivate(self, device_id: str) -> DeactivateDeviceResult:
|
|
162
196
|
loop = asyncio.get_running_loop()
|
|
163
197
|
return await loop.run_in_executor(
|
|
@@ -165,6 +199,60 @@ class _AsyncDevicesClient:
|
|
|
165
199
|
)
|
|
166
200
|
|
|
167
201
|
|
|
202
|
+
class _AsyncTrackClient:
|
|
203
|
+
"""Awaitable façade over :class:`TrackClient`."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, inner: TrackClient) -> None:
|
|
206
|
+
self._inner = inner
|
|
207
|
+
|
|
208
|
+
async def record(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
event: TrackEvent,
|
|
212
|
+
delivery_log_id: str | None = None,
|
|
213
|
+
notification_id: str | None = None,
|
|
214
|
+
device_token: str | None = None,
|
|
215
|
+
) -> TrackResult:
|
|
216
|
+
loop = asyncio.get_running_loop()
|
|
217
|
+
return await loop.run_in_executor(
|
|
218
|
+
None,
|
|
219
|
+
lambda: self._inner.record(
|
|
220
|
+
event=event,
|
|
221
|
+
delivery_log_id=delivery_log_id,
|
|
222
|
+
notification_id=notification_id,
|
|
223
|
+
device_token=device_token,
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _AsyncEventsClient:
|
|
229
|
+
"""Awaitable façade over :class:`EventsClient`."""
|
|
230
|
+
|
|
231
|
+
def __init__(self, inner: EventsClient) -> None:
|
|
232
|
+
self._inner = inner
|
|
233
|
+
|
|
234
|
+
async def report(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
notification_id: str,
|
|
238
|
+
device_id: str,
|
|
239
|
+
type: EngagementEvent,
|
|
240
|
+
action_id: str | None = None,
|
|
241
|
+
happened_at: str | None = None,
|
|
242
|
+
) -> ReportEventResult:
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
return await loop.run_in_executor(
|
|
245
|
+
None,
|
|
246
|
+
lambda: self._inner.report(
|
|
247
|
+
notification_id=notification_id,
|
|
248
|
+
device_id=device_id,
|
|
249
|
+
type=type,
|
|
250
|
+
action_id=action_id,
|
|
251
|
+
happened_at=happened_at,
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
168
256
|
class AsyncNitroping:
|
|
169
257
|
"""Async server-side SDK client.
|
|
170
258
|
|
|
@@ -180,6 +268,8 @@ class AsyncNitroping:
|
|
|
180
268
|
|
|
181
269
|
notifications: _AsyncNotificationsClient
|
|
182
270
|
devices: _AsyncDevicesClient
|
|
271
|
+
track: _AsyncTrackClient
|
|
272
|
+
events: _AsyncEventsClient
|
|
183
273
|
|
|
184
274
|
def __init__(
|
|
185
275
|
self,
|
|
@@ -197,6 +287,8 @@ class AsyncNitroping:
|
|
|
197
287
|
)
|
|
198
288
|
self.notifications = _AsyncNotificationsClient(self._sync.notifications)
|
|
199
289
|
self.devices = _AsyncDevicesClient(self._sync.devices)
|
|
290
|
+
self.track = _AsyncTrackClient(self._sync.track)
|
|
291
|
+
self.events = _AsyncEventsClient(self._sync.events)
|
|
200
292
|
|
|
201
293
|
@property
|
|
202
294
|
def http(self) -> HttpClient:
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""``devices`` resource client.
|
|
2
2
|
|
|
3
3
|
Mounted on :class:`nitroping.Nitroping` as ``np.devices``. Wraps
|
|
4
|
-
``POST /api/v1/devices
|
|
4
|
+
``POST /api/v1/devices``, ``PUT /api/v1/devices/:id``, and
|
|
5
|
+
``DELETE /api/v1/devices/:id``.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
from __future__ import annotations
|
|
@@ -10,11 +11,16 @@ from typing import Any, cast
|
|
|
10
11
|
from urllib.parse import quote
|
|
11
12
|
|
|
12
13
|
from ._http import HttpClient
|
|
13
|
-
from .types import
|
|
14
|
+
from .types import (
|
|
15
|
+
DeactivateDeviceResult,
|
|
16
|
+
Platform,
|
|
17
|
+
RegisterDeviceResult,
|
|
18
|
+
UpdateDeviceResult,
|
|
19
|
+
)
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
class DevicesClient:
|
|
17
|
-
"""Register and deactivate device rows."""
|
|
23
|
+
"""Register, update, and deactivate device rows."""
|
|
18
24
|
|
|
19
25
|
def __init__(self, http: HttpClient) -> None:
|
|
20
26
|
self._http = http
|
|
@@ -28,12 +34,15 @@ class DevicesClient:
|
|
|
28
34
|
web_push_p256dh: str | None = None,
|
|
29
35
|
web_push_auth: str | None = None,
|
|
30
36
|
metadata: dict[str, Any] | None = None,
|
|
37
|
+
tags: list[str] | None = None,
|
|
31
38
|
) -> RegisterDeviceResult:
|
|
32
39
|
"""Register (or update) a device with the secret API key.
|
|
33
40
|
|
|
34
41
|
Idempotent on ``(app_id, token, user_id)``. Returns
|
|
35
42
|
``{"id": ..., "created": True}`` when a new row was inserted,
|
|
36
43
|
``{"id": ..., "created": False}`` when an existing device matched.
|
|
44
|
+
|
|
45
|
+
``tags`` enables tag-based targeting (``target={"tags": [...]}``).
|
|
37
46
|
"""
|
|
38
47
|
wire: dict[str, Any] = {"token": token, "platform": platform}
|
|
39
48
|
if user_id is not None:
|
|
@@ -44,10 +53,34 @@ class DevicesClient:
|
|
|
44
53
|
wire["web_push_auth"] = web_push_auth
|
|
45
54
|
if metadata is not None:
|
|
46
55
|
wire["metadata"] = metadata
|
|
56
|
+
if tags is not None:
|
|
57
|
+
wire["tags"] = tags
|
|
47
58
|
|
|
48
|
-
|
|
59
|
+
path = "/api/v1/public/devices" if self._http.auth_scheme == "Public" else "/api/v1/devices"
|
|
60
|
+
response = self._http.request("POST", path, body=wire)
|
|
49
61
|
return cast(RegisterDeviceResult, response)
|
|
50
62
|
|
|
63
|
+
def update(
|
|
64
|
+
self,
|
|
65
|
+
device_id: str,
|
|
66
|
+
*,
|
|
67
|
+
tags: list[str] | None = None,
|
|
68
|
+
) -> UpdateDeviceResult:
|
|
69
|
+
"""Update a device (e.g. replace its tags).
|
|
70
|
+
|
|
71
|
+
Wraps ``PUT /api/v1/devices/:id``. Returns ``{"id": ..., "tags":
|
|
72
|
+
[...]}``. Raises :class:`~nitroping.errors.ApiError` with
|
|
73
|
+
``code = "not_found"`` if the id doesn't belong to your app.
|
|
74
|
+
"""
|
|
75
|
+
wire: dict[str, Any] = {}
|
|
76
|
+
if tags is not None:
|
|
77
|
+
wire["tags"] = tags
|
|
78
|
+
|
|
79
|
+
response = self._http.request(
|
|
80
|
+
"PUT", f"/api/v1/devices/{quote(device_id, safe='')}", body=wire
|
|
81
|
+
)
|
|
82
|
+
return cast(UpdateDeviceResult, response)
|
|
83
|
+
|
|
51
84
|
def deactivate(self, device_id: str) -> DeactivateDeviceResult:
|
|
52
85
|
"""Soft-delete a device (sets ``status = inactive``).
|
|
53
86
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""``events`` resource client.
|
|
2
|
+
|
|
3
|
+
Mounted on :class:`nitroping.Nitroping` as ``np.events``. Wraps
|
|
4
|
+
``POST /api/v1/events`` — the public, unauthenticated engagement
|
|
5
|
+
endpoint. The ``(notification_id, device_id)`` pair is the bearer
|
|
6
|
+
secret, so no ``Authorization`` header is required (and a ``pk_`` public
|
|
7
|
+
key is fine).
|
|
8
|
+
|
|
9
|
+
This is the endpoint a client app calls when a notification is opened or
|
|
10
|
+
a notification action is clicked.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from ._http import HttpClient
|
|
18
|
+
from .types import EngagementEvent, ReportEventResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventsClient:
|
|
22
|
+
"""Report public engagement events (``opened`` / ``clicked``)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, http: HttpClient) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
|
|
27
|
+
def report(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
notification_id: str,
|
|
31
|
+
device_id: str,
|
|
32
|
+
type: EngagementEvent,
|
|
33
|
+
action_id: str | None = None,
|
|
34
|
+
happened_at: str | None = None,
|
|
35
|
+
) -> ReportEventResult:
|
|
36
|
+
"""Report an engagement event (``opened`` or ``clicked``).
|
|
37
|
+
|
|
38
|
+
Returns ``{"accepted": True}`` on 202. Raises
|
|
39
|
+
:class:`~nitroping.errors.ApiError` with ``code = "not_found"``
|
|
40
|
+
(404) if the notification/device pair is unknown.
|
|
41
|
+
"""
|
|
42
|
+
wire: dict[str, Any] = {
|
|
43
|
+
"notification_id": notification_id,
|
|
44
|
+
"device_id": device_id,
|
|
45
|
+
"type": type,
|
|
46
|
+
}
|
|
47
|
+
if action_id is not None:
|
|
48
|
+
wire["action_id"] = action_id
|
|
49
|
+
if happened_at is not None:
|
|
50
|
+
wire["happened_at"] = happened_at
|
|
51
|
+
|
|
52
|
+
response = self._http.request("POST", "/api/v1/events", body=wire)
|
|
53
|
+
return cast(ReportEventResult, response)
|
|
@@ -23,7 +23,7 @@ DEFAULT_BASE_URL = "https://nitroping.dev"
|
|
|
23
23
|
|
|
24
24
|
#: SDK version — bumped via ``pyproject.toml``. Used in the User-Agent
|
|
25
25
|
#: header so requests can be attributed during incident investigation.
|
|
26
|
-
SDK_VERSION = "0.
|
|
26
|
+
SDK_VERSION = "0.2.3"
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class HttpClient:
|
|
@@ -36,6 +36,7 @@ class HttpClient:
|
|
|
36
36
|
|
|
37
37
|
base_url: str
|
|
38
38
|
api_key: str
|
|
39
|
+
auth_scheme: str
|
|
39
40
|
timeout: float
|
|
40
41
|
user_agent: str
|
|
41
42
|
|
|
@@ -46,10 +47,12 @@ class HttpClient:
|
|
|
46
47
|
base_url: str = DEFAULT_BASE_URL,
|
|
47
48
|
timeout: float = 30.0,
|
|
48
49
|
user_agent: str | None = None,
|
|
50
|
+
auth_scheme: str | None = None,
|
|
49
51
|
) -> None:
|
|
50
52
|
if not api_key:
|
|
51
53
|
raise NitropingError("api_key is required", code="invalid_argument")
|
|
52
54
|
self.api_key = api_key
|
|
55
|
+
self.auth_scheme = auth_scheme or ("Public" if api_key.startswith("pk_") else "ApiKey")
|
|
53
56
|
self.base_url = base_url.rstrip("/")
|
|
54
57
|
self.timeout = timeout
|
|
55
58
|
self.user_agent = user_agent or f"nitroping-python/{SDK_VERSION}"
|
|
@@ -73,7 +76,7 @@ class HttpClient:
|
|
|
73
76
|
body_bytes: bytes | None = None
|
|
74
77
|
|
|
75
78
|
all_headers: dict[str, str] = {
|
|
76
|
-
"Authorization": f"
|
|
79
|
+
"Authorization": f"{self.auth_scheme} {self.api_key}",
|
|
77
80
|
"Accept": "application/json",
|
|
78
81
|
"User-Agent": self.user_agent,
|
|
79
82
|
}
|
|
@@ -10,7 +10,12 @@ from typing import Any, cast
|
|
|
10
10
|
from urllib.parse import quote
|
|
11
11
|
|
|
12
12
|
from ._http import HttpClient
|
|
13
|
-
from .types import
|
|
13
|
+
from .types import (
|
|
14
|
+
CancelNotificationResult,
|
|
15
|
+
NotificationAction,
|
|
16
|
+
NotificationResult,
|
|
17
|
+
NotificationTarget,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
class NotificationsClient:
|
|
@@ -92,3 +97,17 @@ class NotificationsClient:
|
|
|
92
97
|
"GET", f"/api/v1/notifications/{quote(notification_id, safe='')}"
|
|
93
98
|
)
|
|
94
99
|
return cast(dict[str, Any], response)
|
|
100
|
+
|
|
101
|
+
def cancel(self, notification_id: str) -> CancelNotificationResult:
|
|
102
|
+
"""Cancel a scheduled or in-flight notification.
|
|
103
|
+
|
|
104
|
+
Wraps ``DELETE /api/v1/notifications/:id``. Returns
|
|
105
|
+
``{"id": ..., "status": "canceled"}``. Raises
|
|
106
|
+
:class:`~nitroping.errors.ApiError` with ``code = "cannot_cancel"``
|
|
107
|
+
(409) if the notification already reached a terminal state, or
|
|
108
|
+
``code = "not_found"`` (404).
|
|
109
|
+
"""
|
|
110
|
+
response = self._http.request(
|
|
111
|
+
"DELETE", f"/api/v1/notifications/{quote(notification_id, safe='')}"
|
|
112
|
+
)
|
|
113
|
+
return cast(CancelNotificationResult, response)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``track`` resource client.
|
|
2
|
+
|
|
3
|
+
Mounted on :class:`nitroping.Nitroping` as ``np.track``. Wraps
|
|
4
|
+
``POST /api/v1/track`` — the server SDK delivery/open/click callback.
|
|
5
|
+
Returns 202 immediately; the write is absorbed by a background worker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from ._http import HttpClient
|
|
13
|
+
from .errors import NitropingError
|
|
14
|
+
from .types import TrackEvent, TrackResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TrackClient:
|
|
18
|
+
"""Record delivery/open/click events against a delivery log."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, http: HttpClient) -> None:
|
|
21
|
+
self._http = http
|
|
22
|
+
|
|
23
|
+
def record(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
event: TrackEvent,
|
|
27
|
+
delivery_log_id: str | None = None,
|
|
28
|
+
notification_id: str | None = None,
|
|
29
|
+
device_token: str | None = None,
|
|
30
|
+
) -> TrackResult:
|
|
31
|
+
"""Record a delivery/open/click event against a delivery log.
|
|
32
|
+
|
|
33
|
+
Identify the target either by ``delivery_log_id``, or by
|
|
34
|
+
``notification_id`` + the device's ``device_token``. ``event`` is
|
|
35
|
+
one of ``delivered | opened | clicked``.
|
|
36
|
+
|
|
37
|
+
Returns ``{"accepted": True}`` on 202.
|
|
38
|
+
"""
|
|
39
|
+
if delivery_log_id is not None:
|
|
40
|
+
wire = {"delivery_log_id": delivery_log_id, "event": event}
|
|
41
|
+
elif notification_id is not None and device_token is not None:
|
|
42
|
+
wire = {
|
|
43
|
+
"notification_id": notification_id,
|
|
44
|
+
"device_token": device_token,
|
|
45
|
+
"event": event,
|
|
46
|
+
}
|
|
47
|
+
else:
|
|
48
|
+
raise NitropingError(
|
|
49
|
+
"track.record requires either delivery_log_id, or both "
|
|
50
|
+
"notification_id and device_token",
|
|
51
|
+
code="invalid_argument",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
response = self._http.request("POST", "/api/v1/track", body=wire)
|
|
55
|
+
return cast(TrackResult, response)
|
|
@@ -44,8 +44,14 @@ class TargetUserIds(TypedDict):
|
|
|
44
44
|
user_ids: list[str]
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
class TargetTags(TypedDict):
|
|
48
|
+
"""Hit every device row tagged with one of the given tags."""
|
|
49
|
+
|
|
50
|
+
tags: list[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: Target selector for a notification. Exactly one of the four.
|
|
54
|
+
NotificationTarget = TargetAll | TargetDeviceIds | TargetUserIds | TargetTags
|
|
49
55
|
|
|
50
56
|
|
|
51
57
|
class SendOptions(TypedDict, total=False):
|
|
@@ -79,6 +85,15 @@ class RegisterDeviceResult(TypedDict):
|
|
|
79
85
|
created: bool
|
|
80
86
|
|
|
81
87
|
|
|
88
|
+
class UpdateDeviceResult(TypedDict):
|
|
89
|
+
"""Response from ``PUT /api/v1/devices/:id``."""
|
|
90
|
+
|
|
91
|
+
#: UUID of the device row.
|
|
92
|
+
id: str
|
|
93
|
+
#: The device's tags after the update.
|
|
94
|
+
tags: list[str]
|
|
95
|
+
|
|
96
|
+
|
|
82
97
|
class DeactivateDeviceResult(TypedDict):
|
|
83
98
|
"""Response from ``DELETE /api/v1/devices/:id``."""
|
|
84
99
|
|
|
@@ -86,6 +101,32 @@ class DeactivateDeviceResult(TypedDict):
|
|
|
86
101
|
status: str
|
|
87
102
|
|
|
88
103
|
|
|
104
|
+
class CancelNotificationResult(TypedDict):
|
|
105
|
+
"""Response from ``DELETE /api/v1/notifications/:id``."""
|
|
106
|
+
|
|
107
|
+
id: str
|
|
108
|
+
status: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
#: Delivery-tracking event type for ``POST /api/v1/track``.
|
|
112
|
+
TrackEvent = Literal["delivered", "opened", "clicked"]
|
|
113
|
+
|
|
114
|
+
#: Engagement event type for ``POST /api/v1/events``.
|
|
115
|
+
EngagementEvent = Literal["opened", "clicked"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TrackResult(TypedDict):
|
|
119
|
+
"""Response from ``POST /api/v1/track``."""
|
|
120
|
+
|
|
121
|
+
accepted: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ReportEventResult(TypedDict):
|
|
125
|
+
"""Response from ``POST /api/v1/events``."""
|
|
126
|
+
|
|
127
|
+
accepted: bool
|
|
128
|
+
|
|
129
|
+
|
|
89
130
|
class WebhookEvent(TypedDict):
|
|
90
131
|
"""Outbound webhook event envelope (parsed from a
|
|
91
132
|
:func:`nitroping.webhooks.verify` call)."""
|
|
@@ -52,6 +52,36 @@ def test_register_web_with_p256dh_and_auth(mock_urlopen):
|
|
|
52
52
|
assert body["web_push_auth"] == "auth_secret_value"
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def test_register_with_tags(mock_urlopen):
|
|
56
|
+
"""tags kwarg lands on the wire as a `tags` array."""
|
|
57
|
+
mock_urlopen.enqueue_json(201, {"id": "dev-3", "created": True})
|
|
58
|
+
|
|
59
|
+
np = Nitroping(api_key="np_x")
|
|
60
|
+
np.devices.register(
|
|
61
|
+
platform="android",
|
|
62
|
+
token="fcm-token-xyz",
|
|
63
|
+
tags=["beta", "vip"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
body = mock_urlopen.calls[0].body_json
|
|
67
|
+
assert body is not None
|
|
68
|
+
assert body["tags"] == ["beta", "vip"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_update_sends_put_with_tags(mock_urlopen):
|
|
72
|
+
"""PUT /api/v1/devices/:id with {tags:[...]}, returns {id, tags}."""
|
|
73
|
+
mock_urlopen.enqueue_json(200, {"id": "dev-1", "tags": ["beta"]})
|
|
74
|
+
|
|
75
|
+
np = Nitroping(api_key="np_x")
|
|
76
|
+
result = np.devices.update("dev-1", tags=["beta"])
|
|
77
|
+
|
|
78
|
+
assert result == {"id": "dev-1", "tags": ["beta"]}
|
|
79
|
+
call = mock_urlopen.calls[0]
|
|
80
|
+
assert call.method == "PUT"
|
|
81
|
+
assert call.url == "https://nitroping.dev/api/v1/devices/dev-1"
|
|
82
|
+
assert call.body_json == {"tags": ["beta"]}
|
|
83
|
+
|
|
84
|
+
|
|
55
85
|
def test_deactivate_sends_delete(mock_urlopen):
|
|
56
86
|
"""DELETE /api/v1/devices/:id with proper URL-encoded id."""
|
|
57
87
|
mock_urlopen.enqueue_json(200, {"id": "dev-1", "status": "inactive"})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tests for ``np.events`` — mirrors js/test/events.test.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from nitroping import ApiError, AsyncNitroping, Nitroping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_report_opened(mock_urlopen):
|
|
11
|
+
"""events.report(...) → POST /api/v1/events with snake_case body."""
|
|
12
|
+
mock_urlopen.enqueue_json(202, {"accepted": True})
|
|
13
|
+
|
|
14
|
+
np = Nitroping(api_key="np_x")
|
|
15
|
+
result = np.events.report(
|
|
16
|
+
notification_id="n1",
|
|
17
|
+
device_id="d1",
|
|
18
|
+
type="opened",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
assert result == {"accepted": True}
|
|
22
|
+
call = mock_urlopen.calls[0]
|
|
23
|
+
assert call.method == "POST"
|
|
24
|
+
assert call.url == "https://nitroping.dev/api/v1/events"
|
|
25
|
+
assert call.body_json == {
|
|
26
|
+
"notification_id": "n1",
|
|
27
|
+
"device_id": "d1",
|
|
28
|
+
"type": "opened",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_report_clicked_with_action_and_timestamp(mock_urlopen):
|
|
33
|
+
"""Optional action_id / happened_at forwarded as snake_case fields."""
|
|
34
|
+
mock_urlopen.enqueue_json(202, {"accepted": True})
|
|
35
|
+
|
|
36
|
+
np = Nitroping(api_key="np_x")
|
|
37
|
+
np.events.report(
|
|
38
|
+
notification_id="n1",
|
|
39
|
+
device_id="d1",
|
|
40
|
+
type="clicked",
|
|
41
|
+
action_id="track",
|
|
42
|
+
happened_at="2026-06-13T10:00:00Z",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
assert mock_urlopen.calls[0].body_json == {
|
|
46
|
+
"notification_id": "n1",
|
|
47
|
+
"device_id": "d1",
|
|
48
|
+
"type": "clicked",
|
|
49
|
+
"action_id": "track",
|
|
50
|
+
"happened_at": "2026-06-13T10:00:00Z",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_report_404_raises_apierror(mock_urlopen):
|
|
55
|
+
"""Unknown notification/device pair → ApiError with not_found code."""
|
|
56
|
+
mock_urlopen.enqueue_error(
|
|
57
|
+
404,
|
|
58
|
+
{"error": {"code": "not_found", "message": "Unknown pair"}},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
np = Nitroping(api_key="np_x")
|
|
62
|
+
with pytest.raises(ApiError) as exc:
|
|
63
|
+
np.events.report(notification_id="n1", device_id="d1", type="opened")
|
|
64
|
+
assert exc.value.code == "not_found"
|
|
65
|
+
assert exc.value.status == 404
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def test_async_events_report(mock_urlopen):
|
|
69
|
+
"""AsyncNitroping.events.report awaits and returns the same result."""
|
|
70
|
+
mock_urlopen.enqueue_json(202, {"accepted": True})
|
|
71
|
+
|
|
72
|
+
np = AsyncNitroping(api_key="np_x")
|
|
73
|
+
result = await np.events.report(
|
|
74
|
+
notification_id="n1",
|
|
75
|
+
device_id="d1",
|
|
76
|
+
type="opened",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert result == {"accepted": True}
|
|
80
|
+
assert mock_urlopen.calls[0].url == "https://nitroping.dev/api/v1/events"
|
|
@@ -145,6 +145,47 @@ def test_send_target_device_ids(mock_urlopen):
|
|
|
145
145
|
assert mock_urlopen.calls[0].body_json["target"] == {"device_ids": ["d1", "d2"]}
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def test_send_target_tags(mock_urlopen):
|
|
149
|
+
"""target={'tags': [...]} passes through unchanged on the wire."""
|
|
150
|
+
mock_urlopen.enqueue_json(201, {"id": "n1", "status": "queued"})
|
|
151
|
+
|
|
152
|
+
np = Nitroping(api_key="np_x")
|
|
153
|
+
np.notifications.send(
|
|
154
|
+
title="x",
|
|
155
|
+
body="y",
|
|
156
|
+
target={"tags": ["beta", "vip"]},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert mock_urlopen.calls[0].body_json is not None
|
|
160
|
+
assert mock_urlopen.calls[0].body_json["target"] == {"tags": ["beta", "vip"]}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_cancel_sends_delete(mock_urlopen):
|
|
164
|
+
"""np.notifications.cancel(id) → DELETE /api/v1/notifications/<id>."""
|
|
165
|
+
mock_urlopen.enqueue_json(200, {"id": "n1", "status": "canceled"})
|
|
166
|
+
|
|
167
|
+
np = Nitroping(api_key="np_x")
|
|
168
|
+
result = np.notifications.cancel("n1")
|
|
169
|
+
|
|
170
|
+
assert result == {"id": "n1", "status": "canceled"}
|
|
171
|
+
assert mock_urlopen.calls[0].method == "DELETE"
|
|
172
|
+
assert mock_urlopen.calls[0].url == "https://nitroping.dev/api/v1/notifications/n1"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_cancel_409_raises_apierror(mock_urlopen):
|
|
176
|
+
"""409 with `cannot_cancel` code is surfaced as ApiError."""
|
|
177
|
+
mock_urlopen.enqueue_error(
|
|
178
|
+
409,
|
|
179
|
+
{"error": {"code": "cannot_cancel", "message": "Already delivered"}},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
np = Nitroping(api_key="np_x")
|
|
183
|
+
with pytest.raises(ApiError) as exc:
|
|
184
|
+
np.notifications.cancel("n1")
|
|
185
|
+
assert exc.value.code == "cannot_cancel"
|
|
186
|
+
assert exc.value.status == 409
|
|
187
|
+
|
|
188
|
+
|
|
148
189
|
def test_get_notification(mock_urlopen):
|
|
149
190
|
"""np.notifications.get(id) → GET /api/v1/notifications/<id>."""
|
|
150
191
|
mock_urlopen.enqueue_json(
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tests for ``np.track`` — mirrors js/test/track.test.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from nitroping import Nitroping, NitropingError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_record_by_delivery_log_id(mock_urlopen):
|
|
11
|
+
"""track.record(delivery_log_id=..., event=...) → POST /api/v1/track."""
|
|
12
|
+
mock_urlopen.enqueue_json(202, {"accepted": True})
|
|
13
|
+
|
|
14
|
+
np = Nitroping(api_key="np_x")
|
|
15
|
+
result = np.track.record(delivery_log_id="dl-1", event="delivered")
|
|
16
|
+
|
|
17
|
+
assert result == {"accepted": True}
|
|
18
|
+
call = mock_urlopen.calls[0]
|
|
19
|
+
assert call.method == "POST"
|
|
20
|
+
assert call.url == "https://nitroping.dev/api/v1/track"
|
|
21
|
+
assert call.body_json == {"delivery_log_id": "dl-1", "event": "delivered"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_record_by_notification_and_device_token(mock_urlopen):
|
|
25
|
+
"""The alternative shape: notification_id + device_token."""
|
|
26
|
+
mock_urlopen.enqueue_json(202, {"accepted": True})
|
|
27
|
+
|
|
28
|
+
np = Nitroping(api_key="np_x")
|
|
29
|
+
result = np.track.record(
|
|
30
|
+
notification_id="n1",
|
|
31
|
+
device_token="apns-abc",
|
|
32
|
+
event="opened",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert result == {"accepted": True}
|
|
36
|
+
assert mock_urlopen.calls[0].body_json == {
|
|
37
|
+
"notification_id": "n1",
|
|
38
|
+
"device_token": "apns-abc",
|
|
39
|
+
"event": "opened",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_record_requires_an_identifier(mock_urlopen):
|
|
44
|
+
"""Missing both identifiers raises NitropingError, no HTTP call made."""
|
|
45
|
+
np = Nitroping(api_key="np_x")
|
|
46
|
+
with pytest.raises(NitropingError) as exc:
|
|
47
|
+
np.track.record(event="clicked")
|
|
48
|
+
assert exc.value.code == "invalid_argument"
|
|
49
|
+
assert mock_urlopen.calls == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|