python-sendparcel-inpost 0.1.1__tar.gz → 0.1.2__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 (32) hide show
  1. python_sendparcel_inpost-0.1.2/PKG-INFO +100 -0
  2. python_sendparcel_inpost-0.1.2/README.md +72 -0
  3. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/docs/configuration.md +6 -6
  4. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/pyproject.toml +2 -2
  5. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/providers/courier.py +16 -13
  6. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/providers/locker.py +17 -15
  7. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/status_mapping.py +17 -0
  8. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_courier_provider.py +26 -3
  9. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_enums.py +11 -5
  10. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_locker_provider.py +43 -17
  11. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_status_mapping.py +31 -0
  12. python_sendparcel_inpost-0.1.1/PKG-INFO +0 -371
  13. python_sendparcel_inpost-0.1.1/README.md +0 -343
  14. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/.gitignore +0 -0
  15. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/CHANGELOG.md +0 -0
  16. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/CONTRIBUTING.md +0 -0
  17. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/docs/api.md +0 -0
  18. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/docs/index.md +0 -0
  19. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/docs/quickstart.md +0 -0
  20. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/__init__.py +0 -0
  21. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/client.py +1 -1
  22. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/enums.py +0 -0
  23. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/exceptions.py +0 -0
  24. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/providers/__init__.py +0 -0
  25. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/src/sendparcel_inpost/types.py +0 -0
  26. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/__init__.py +0 -0
  27. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/conftest.py +0 -0
  28. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_client.py +0 -0
  29. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_config_schema.py +0 -0
  30. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_entry_points.py +0 -0
  31. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_exceptions.py +0 -0
  32. {python_sendparcel_inpost-0.1.1 → python_sendparcel_inpost-0.1.2}/tests/test_types.py +0 -0
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-sendparcel-inpost
3
+ Version: 0.1.2
4
+ Summary: InPost ShipX provider for python-sendparcel.
5
+ Author-email: Dominik Kozaczko <dominik@kozaczko.info>
6
+ License: MIT
7
+ Keywords: inpost,parcel,sendparcel,shipping,shipx
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Natural Language :: English
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: anyio>=4.0
19
+ Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: python-sendparcel>=0.1.1
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: respx>=0.22.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # python-sendparcel-inpost
30
+
31
+ InPost ShipX provider package for the `python-sendparcel` ecosystem.
32
+
33
+ > Alpha notice: this package tracks the still-changing `python-sendparcel` core.
34
+
35
+ ## What it provides
36
+
37
+ - `InPostLockerProvider` for locker shipments
38
+ - `InPostCourierProvider` for courier shipments
39
+ - `ShipXClient` for direct async ShipX API access
40
+ - ShipX-to-sendparcel status normalization helpers
41
+
42
+ ## Contract
43
+
44
+ This package follows the cleaned core contract:
45
+
46
+ - `create_shipment(...) -> ShipmentCreateResult`
47
+ - `create_label(...) -> LabelInfo`
48
+ - `handle_callback(...) -> ShipmentUpdateResult`
49
+ - `fetch_shipment_status(...) -> ShipmentUpdateResult`
50
+ - `cancel_shipment(...) -> bool`
51
+
52
+ Providers do not mutate shipment state directly. They translate ShipX responses into normalized results that the core flow applies.
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ uv add python-sendparcel-inpost
58
+ ```
59
+
60
+ or:
61
+
62
+ ```bash
63
+ pip install python-sendparcel-inpost
64
+ ```
65
+
66
+ ## Configuration
67
+
68
+ | Key | Type | Description |
69
+ |---|---|---|
70
+ | `token` | `str` | ShipX API bearer token |
71
+ | `organization_id` | `int` | ShipX organization ID |
72
+ | `sandbox` | `bool` | Use sandbox API |
73
+ | `base_url` | `str` | Optional API base override |
74
+ | `timeout` | `float` | Request timeout in seconds |
75
+
76
+ ## Status normalization
77
+
78
+ ShipX statuses are normalized to sendparcel shipment statuses.
79
+
80
+ - recognized ShipX statuses produce `{"status": ...}`
81
+ - tracking numbers are included when available
82
+ - unknown ShipX statuses do not invent fake sendparcel statuses
83
+
84
+ That means callback and polling updates can safely return only tracking data when ShipX introduces a new status the mapper does not know yet.
85
+
86
+ ## Labels
87
+
88
+ Labels are returned as payloads.
89
+
90
+ - PDF labels are returned as base64 content in `LabelInfo["content_base64"]`
91
+ - no label URL is persisted by the core contract
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ uv sync --extra dev
97
+ uv run pytest
98
+ uv run ruff check src tests
99
+ uv run mypy src tests
100
+ ```
@@ -0,0 +1,72 @@
1
+ # python-sendparcel-inpost
2
+
3
+ InPost ShipX provider package for the `python-sendparcel` ecosystem.
4
+
5
+ > Alpha notice: this package tracks the still-changing `python-sendparcel` core.
6
+
7
+ ## What it provides
8
+
9
+ - `InPostLockerProvider` for locker shipments
10
+ - `InPostCourierProvider` for courier shipments
11
+ - `ShipXClient` for direct async ShipX API access
12
+ - ShipX-to-sendparcel status normalization helpers
13
+
14
+ ## Contract
15
+
16
+ This package follows the cleaned core contract:
17
+
18
+ - `create_shipment(...) -> ShipmentCreateResult`
19
+ - `create_label(...) -> LabelInfo`
20
+ - `handle_callback(...) -> ShipmentUpdateResult`
21
+ - `fetch_shipment_status(...) -> ShipmentUpdateResult`
22
+ - `cancel_shipment(...) -> bool`
23
+
24
+ Providers do not mutate shipment state directly. They translate ShipX responses into normalized results that the core flow applies.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ uv add python-sendparcel-inpost
30
+ ```
31
+
32
+ or:
33
+
34
+ ```bash
35
+ pip install python-sendparcel-inpost
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ | Key | Type | Description |
41
+ |---|---|---|
42
+ | `token` | `str` | ShipX API bearer token |
43
+ | `organization_id` | `int` | ShipX organization ID |
44
+ | `sandbox` | `bool` | Use sandbox API |
45
+ | `base_url` | `str` | Optional API base override |
46
+ | `timeout` | `float` | Request timeout in seconds |
47
+
48
+ ## Status normalization
49
+
50
+ ShipX statuses are normalized to sendparcel shipment statuses.
51
+
52
+ - recognized ShipX statuses produce `{"status": ...}`
53
+ - tracking numbers are included when available
54
+ - unknown ShipX statuses do not invent fake sendparcel statuses
55
+
56
+ That means callback and polling updates can safely return only tracking data when ShipX introduces a new status the mapper does not know yet.
57
+
58
+ ## Labels
59
+
60
+ Labels are returned as payloads.
61
+
62
+ - PDF labels are returned as base64 content in `LabelInfo["content_base64"]`
63
+ - no label URL is persisted by the core contract
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ uv sync --extra dev
69
+ uv run pytest
70
+ uv run ruff check src tests
71
+ uv run mypy src tests
72
+ ```
@@ -80,10 +80,10 @@ Both providers implement the full `BaseProvider` interface:
80
80
  |---|---|
81
81
  | `create_shipment(**kwargs)` | Create a shipment in ShipX |
82
82
  | `create_label(**kwargs)` | Download shipping label (PDF by default) |
83
- | `fetch_shipment_status(**kwargs)` | Poll ShipX API for current status |
83
+ | `fetch_shipment_status(**kwargs)` | Poll ShipX API and return `ShipmentUpdateResult` |
84
84
  | `cancel_shipment(**kwargs)` | Cancel the shipment (returns `True`/`False`) |
85
85
  | `verify_callback(data, headers, **kwargs)` | Verify webhook source IP |
86
- | `handle_callback(data, headers, **kwargs)` | Process webhook payload |
86
+ | `handle_callback(data, headers, **kwargs)` | Normalize webhook payload into `ShipmentUpdateResult` |
87
87
 
88
88
  ## ShipXClient
89
89
 
@@ -173,7 +173,8 @@ ShipX uses 24 internal statuses. These are mapped to 8 sendparcel statuses:
173
173
  | `RETURNED` | `returned_to_sender` |
174
174
  | `FAILED` | `rejected_by_receiver`, `undelivered`, `oversized`, `missing`, `claim_created` |
175
175
 
176
- Unrecognized statuses return `None` from `map_shipx_status()`.
176
+ Unrecognized statuses return `None` from `map_shipx_status()`. Use
177
+ `build_shipment_update()` to keep tracking data even when a status is unknown.
177
178
 
178
179
  ## Error handling
179
180
 
@@ -206,9 +207,8 @@ the `X-Forwarded-For` header (first entry). Invalid or missing IPs raise
206
207
  }
207
208
  ```
208
209
 
209
- The `handle_callback` method extracts the status and maps it to a sendparcel
210
- status using `map_shipx_status()`. The actual FSM transition is handled by
211
- `ShipmentFlow`.
210
+ The `handle_callback` method normalizes ShipX payloads into
211
+ `ShipmentUpdateResult`. The core flow applies transitions.
212
212
 
213
213
  ## Enums
214
214
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-sendparcel-inpost"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "InPost ShipX provider for python-sendparcel."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Topic :: Software Development :: Libraries :: Python Modules",
19
19
  "Typing :: Typed",
20
20
  ]
21
- dependencies = ["python-sendparcel>=0.1.0", "httpx>=0.27.0", "anyio>=4.0"]
21
+ dependencies = ["python-sendparcel>=0.1.1", "httpx>=0.27.0", "anyio>=4.0"]
22
22
 
23
23
  [project.optional-dependencies]
24
24
  dev = [
@@ -19,12 +19,12 @@ from sendparcel.types import (
19
19
  LabelInfo,
20
20
  ParcelInfo,
21
21
  ShipmentCreateResult,
22
- ShipmentStatusResponse,
22
+ ShipmentUpdateResult,
23
23
  )
24
24
 
25
25
  from sendparcel_inpost.client import ShipXClient
26
26
  from sendparcel_inpost.exceptions import ShipXAPIError
27
- from sendparcel_inpost.status_mapping import map_shipx_status
27
+ from sendparcel_inpost.status_mapping import build_shipment_update
28
28
  from sendparcel_inpost.types import ShipXAddress, ShipXPeer
29
29
 
30
30
  logger = logging.getLogger(__name__)
@@ -256,23 +256,28 @@ class InPostCourierProvider(
256
256
  data: dict[str, Any],
257
257
  headers: dict[str, Any],
258
258
  **kwargs: Any,
259
- ) -> None:
259
+ ) -> ShipmentUpdateResult:
260
260
  """Process InPost webhook payload."""
261
261
  payload = data.get("payload", {})
262
- shipx_status = payload.get("status", "")
263
- sendparcel_status = map_shipx_status(shipx_status)
264
- if sendparcel_status:
262
+ shipx_status = str(payload.get("status", ""))
263
+ tracking_number = str(payload.get("tracking_number", ""))
264
+ update = build_shipment_update(
265
+ shipx_status,
266
+ tracking_number=tracking_number,
267
+ )
268
+ if update:
265
269
  logger.info(
266
270
  "InPost webhook: %s -> %s (shipment %s)",
267
271
  shipx_status,
268
- sendparcel_status,
272
+ update,
269
273
  payload.get("shipment_id"),
270
274
  )
275
+ return update
271
276
 
272
277
  async def fetch_shipment_status(
273
278
  self,
274
279
  **kwargs: Any,
275
- ) -> ShipmentStatusResponse:
280
+ ) -> ShipmentUpdateResult:
276
281
  """Fetch current status from ShipX API."""
277
282
  shipment_id = int(self.shipment.external_id)
278
283
 
@@ -284,11 +289,9 @@ class InPostCourierProvider(
284
289
  finally:
285
290
  await client.close()
286
291
 
287
- shipx_status = response.get("status", "")
288
- sendparcel_status = map_shipx_status(shipx_status)
289
-
290
- return ShipmentStatusResponse(
291
- status=sendparcel_status.value if sendparcel_status else None,
292
+ return build_shipment_update(
293
+ str(response.get("status", "")),
294
+ tracking_number=str(response.get("tracking_number", "")),
292
295
  )
293
296
 
294
297
  async def cancel_shipment(self, **kwargs: Any) -> bool:
@@ -19,12 +19,12 @@ from sendparcel.types import (
19
19
  LabelInfo,
20
20
  ParcelInfo,
21
21
  ShipmentCreateResult,
22
- ShipmentStatusResponse,
22
+ ShipmentUpdateResult,
23
23
  )
24
24
 
25
25
  from sendparcel_inpost.client import ShipXClient
26
26
  from sendparcel_inpost.exceptions import ShipXAPIError
27
- from sendparcel_inpost.status_mapping import map_shipx_status
27
+ from sendparcel_inpost.status_mapping import build_shipment_update
28
28
  from sendparcel_inpost.types import ShipXAddress, ShipXPeer
29
29
 
30
30
  logger = logging.getLogger(__name__)
@@ -269,27 +269,31 @@ class InPostLockerProvider(
269
269
  data: dict[str, Any],
270
270
  headers: dict[str, Any],
271
271
  **kwargs: Any,
272
- ) -> None:
272
+ ) -> ShipmentUpdateResult:
273
273
  """Process InPost webhook payload.
274
274
 
275
- The actual FSM transition is handled by ShipmentFlow.
276
- This method extracts and normalizes the status.
275
+ The core flow applies transitions. Providers only normalize payloads.
277
276
  """
278
277
  payload = data.get("payload", {})
279
- shipx_status = payload.get("status", "")
280
- sendparcel_status = map_shipx_status(shipx_status)
281
- if sendparcel_status:
278
+ shipx_status = str(payload.get("status", ""))
279
+ tracking_number = str(payload.get("tracking_number", ""))
280
+ update = build_shipment_update(
281
+ shipx_status,
282
+ tracking_number=tracking_number,
283
+ )
284
+ if update:
282
285
  logger.info(
283
286
  "InPost webhook: %s -> %s (shipment %s)",
284
287
  shipx_status,
285
- sendparcel_status,
288
+ update,
286
289
  payload.get("shipment_id"),
287
290
  )
291
+ return update
288
292
 
289
293
  async def fetch_shipment_status(
290
294
  self,
291
295
  **kwargs: Any,
292
- ) -> ShipmentStatusResponse:
296
+ ) -> ShipmentUpdateResult:
293
297
  """Fetch current status from ShipX API."""
294
298
  shipment_id = int(self.shipment.external_id)
295
299
 
@@ -299,11 +303,9 @@ class InPostLockerProvider(
299
303
  finally:
300
304
  await client.close()
301
305
 
302
- shipx_status = response.get("status", "")
303
- sendparcel_status = map_shipx_status(shipx_status)
304
-
305
- return ShipmentStatusResponse(
306
- status=sendparcel_status.value if sendparcel_status else None,
306
+ return build_shipment_update(
307
+ str(response.get("status", "")),
308
+ tracking_number=str(response.get("tracking_number", "")),
307
309
  )
308
310
 
309
311
  async def cancel_shipment(self, **kwargs: Any) -> bool:
@@ -1,6 +1,7 @@
1
1
  """ShipX status to sendparcel status mapping."""
2
2
 
3
3
  from sendparcel.enums import ShipmentStatus
4
+ from sendparcel.types import ShipmentUpdateResult
4
5
 
5
6
  SHIPX_TO_SENDPARCEL_STATUS: dict[str, ShipmentStatus] = {
6
7
  # CREATED
@@ -44,3 +45,19 @@ def map_shipx_status(shipx_status: str) -> ShipmentStatus | None:
44
45
  Returns None if the status is not recognized.
45
46
  """
46
47
  return SHIPX_TO_SENDPARCEL_STATUS.get(shipx_status)
48
+
49
+
50
+ def build_shipment_update(
51
+ shipx_status: str,
52
+ *,
53
+ tracking_number: str = "",
54
+ ) -> ShipmentUpdateResult:
55
+ """Build a normalized sendparcel update from ShipX data."""
56
+
57
+ update: ShipmentUpdateResult = {}
58
+ mapped_status = map_shipx_status(shipx_status)
59
+ if mapped_status is not None:
60
+ update["status"] = mapped_status.value
61
+ if tracking_number:
62
+ update["tracking_number"] = tracking_number
63
+ return update
@@ -53,7 +53,6 @@ class _FakeShipment:
53
53
  provider: str = "inpost_courier"
54
54
  external_id: str = ""
55
55
  tracking_number: str = ""
56
- label_url: str = ""
57
56
 
58
57
 
59
58
  class TestCourierProviderClassVars:
@@ -112,7 +111,7 @@ class TestCourierCreateShipment:
112
111
  )
113
112
 
114
113
  assert result["external_id"] == "888"
115
- assert result["tracking_number"] == "TRACK888"
114
+ assert result.get("tracking_number") == "TRACK888"
116
115
 
117
116
  call_kwargs = mock_client.create_shipment.call_args
118
117
  payload = call_kwargs.kwargs["payload"]
@@ -203,10 +202,34 @@ class TestCourierFetchStatus:
203
202
  return_value={
204
203
  "id": 888,
205
204
  "status": "taken_by_courier",
205
+ "tracking_number": "TRACK888",
206
206
  },
207
207
  )
208
208
  mock_client.close = AsyncMock()
209
209
 
210
210
  result = await provider.fetch_shipment_status()
211
211
 
212
- assert result["status"] == ShipmentStatus.IN_TRANSIT
212
+ assert result.get("status") == ShipmentStatus.IN_TRANSIT.value
213
+ assert result.get("tracking_number") == "TRACK888"
214
+
215
+
216
+ class TestCourierHandleCallback:
217
+ async def test_extracts_status_and_tracking_number(self) -> None:
218
+ shipment = _FakeShipment()
219
+ provider = InPostCourierProvider(shipment, config={})
220
+
221
+ update = await provider.handle_callback(
222
+ data={
223
+ "payload": {
224
+ "shipment_id": 888,
225
+ "status": "taken_by_courier",
226
+ "tracking_number": "TRACK888",
227
+ },
228
+ },
229
+ headers={},
230
+ )
231
+
232
+ assert update == {
233
+ "status": ShipmentStatus.IN_TRANSIT.value,
234
+ "tracking_number": "TRACK888",
235
+ }
@@ -5,10 +5,16 @@ from sendparcel_inpost.enums import ShipXParcelTemplate, ShipXService
5
5
 
6
6
  class TestShipXService:
7
7
  def test_locker_standard(self) -> None:
8
- assert ShipXService.INPOST_LOCKER_STANDARD == "inpost_locker_standard"
8
+ assert (
9
+ ShipXService.INPOST_LOCKER_STANDARD.value
10
+ == "inpost_locker_standard"
11
+ )
9
12
 
10
13
  def test_inpost_courier_standard(self) -> None:
11
- assert ShipXService.INPOST_COURIER_STANDARD == "inpost_courier_standard"
14
+ assert (
15
+ ShipXService.INPOST_COURIER_STANDARD.value
16
+ == "inpost_courier_standard"
17
+ )
12
18
 
13
19
  def test_is_str_enum(self) -> None:
14
20
  assert isinstance(ShipXService.INPOST_LOCKER_STANDARD, str)
@@ -16,10 +22,10 @@ class TestShipXService:
16
22
 
17
23
  class TestShipXParcelTemplate:
18
24
  def test_small(self) -> None:
19
- assert ShipXParcelTemplate.SMALL == "small"
25
+ assert ShipXParcelTemplate.SMALL.value == "small"
20
26
 
21
27
  def test_medium(self) -> None:
22
- assert ShipXParcelTemplate.MEDIUM == "medium"
28
+ assert ShipXParcelTemplate.MEDIUM.value == "medium"
23
29
 
24
30
  def test_large(self) -> None:
25
- assert ShipXParcelTemplate.LARGE == "large"
31
+ assert ShipXParcelTemplate.LARGE.value == "large"
@@ -45,7 +45,6 @@ class _FakeShipment:
45
45
  provider: str = "inpost_locker"
46
46
  external_id: str = ""
47
47
  tracking_number: str = ""
48
- label_url: str = ""
49
48
 
50
49
 
51
50
  class TestLockerProviderClassVars:
@@ -105,7 +104,7 @@ class TestLockerCreateShipment:
105
104
  )
106
105
 
107
106
  assert result["external_id"] == "999"
108
- assert result["tracking_number"] == "TRACK999"
107
+ assert result.get("tracking_number") == "TRACK999"
109
108
 
110
109
  # Verify the payload sent to client
111
110
  call_kwargs = mock_client.create_shipment.call_args
@@ -154,7 +153,7 @@ class TestLockerCreateLabel:
154
153
  label = await provider.create_label()
155
154
 
156
155
  assert label["format"] == "PDF"
157
- assert label["content_base64"] # non-empty base64 string
156
+ assert label.get("content_base64") # non-empty base64 string
158
157
 
159
158
 
160
159
  class TestLockerFetchStatus:
@@ -174,13 +173,18 @@ class TestLockerFetchStatus:
174
173
  ) as mock_get_client:
175
174
  mock_client = mock_get_client.return_value
176
175
  mock_client.get_shipment = AsyncMock(
177
- return_value={"id": 999, "status": "confirmed"},
176
+ return_value={
177
+ "id": 999,
178
+ "status": "confirmed",
179
+ "tracking_number": "TRACK999",
180
+ },
178
181
  )
179
182
  mock_client.close = AsyncMock()
180
183
 
181
184
  result = await provider.fetch_shipment_status()
182
185
 
183
- assert result["status"] == ShipmentStatus.LABEL_READY
186
+ assert result.get("status") == ShipmentStatus.LABEL_READY.value
187
+ assert result.get("tracking_number") == "TRACK999"
184
188
 
185
189
 
186
190
  class TestLockerCancelShipment:
@@ -260,10 +264,10 @@ class TestLockerVerifyCallback:
260
264
 
261
265
 
262
266
  class TestLockerHandleCallback:
263
- async def test_extracts_status(self) -> None:
267
+ async def test_extracts_status_and_tracking_number(self) -> None:
264
268
  shipment = _FakeShipment()
265
269
  provider = InPostLockerProvider(shipment, config={})
266
- await provider.handle_callback(
270
+ update = await provider.handle_callback(
267
271
  data={
268
272
  "payload": {
269
273
  "shipment_id": 999,
@@ -273,8 +277,30 @@ class TestLockerHandleCallback:
273
277
  },
274
278
  headers={},
275
279
  )
276
- # handle_callback should not raise; status resolution
277
- # is the flow's responsibility
280
+
281
+ assert update == {
282
+ "status": ShipmentStatus.LABEL_READY.value,
283
+ "tracking_number": "TRACK999",
284
+ }
285
+
286
+ async def test_unknown_callback_status_returns_tracking_number_only(
287
+ self,
288
+ ) -> None:
289
+ shipment = _FakeShipment()
290
+ provider = InPostLockerProvider(shipment, config={})
291
+
292
+ update = await provider.handle_callback(
293
+ data={
294
+ "payload": {
295
+ "shipment_id": 999,
296
+ "status": "brand_new_status",
297
+ "tracking_number": "TRACK999",
298
+ },
299
+ },
300
+ headers={},
301
+ )
302
+
303
+ assert update == {"tracking_number": "TRACK999"}
278
304
 
279
305
 
280
306
  class TestLockerAddressConversion:
@@ -293,11 +319,11 @@ class TestLockerAddressConversion:
293
319
  "country_code": "PL",
294
320
  }
295
321
  peer = provider._address_to_peer(addr)
296
- assert peer["first_name"] == "Jan"
297
- assert peer["last_name"] == "Kowalski"
298
- assert peer["phone"] == "500100200"
299
- assert peer["address"]["street"] == "Marszalkowska"
300
- assert peer["address"]["post_code"] == "00-001"
322
+ assert peer.get("first_name") == "Jan"
323
+ assert peer.get("last_name") == "Kowalski"
324
+ assert peer.get("phone") == "500100200"
325
+ assert peer.get("address", {}).get("street") == "Marszalkowska"
326
+ assert peer.get("address", {}).get("post_code") == "00-001"
301
327
 
302
328
  def test_converts_legacy_name_to_first_last(self) -> None:
303
329
  shipment = _FakeShipment()
@@ -312,6 +338,6 @@ class TestLockerAddressConversion:
312
338
  "country_code": "PL",
313
339
  }
314
340
  peer = provider._address_to_peer(addr)
315
- assert peer["first_name"] == "Jan"
316
- assert peer["last_name"] == "Kowalski"
317
- assert peer["address"]["street"] == "Marszalkowska 1"
341
+ assert peer.get("first_name") == "Jan"
342
+ assert peer.get("last_name") == "Kowalski"
343
+ assert peer.get("address", {}).get("street") == "Marszalkowska 1"
@@ -5,6 +5,7 @@ from sendparcel.enums import ShipmentStatus
5
5
 
6
6
  from sendparcel_inpost.status_mapping import (
7
7
  SHIPX_TO_SENDPARCEL_STATUS,
8
+ build_shipment_update,
8
9
  map_shipx_status,
9
10
  )
10
11
 
@@ -55,3 +56,33 @@ class TestStatusMapping:
55
56
  for shipx, sendparcel in SHIPX_TO_SENDPARCEL_STATUS.items():
56
57
  assert isinstance(shipx, str)
57
58
  assert isinstance(sendparcel, ShipmentStatus)
59
+
60
+
61
+ class TestBuildShipmentUpdate:
62
+ def test_returns_normalized_status_and_tracking_number(self) -> None:
63
+ update = build_shipment_update(
64
+ "confirmed",
65
+ tracking_number="TRACK123",
66
+ )
67
+
68
+ assert update == {
69
+ "status": ShipmentStatus.LABEL_READY.value,
70
+ "tracking_number": "TRACK123",
71
+ }
72
+
73
+ def test_returns_tracking_number_without_status_for_unknown_mapping(
74
+ self,
75
+ ) -> None:
76
+ update = build_shipment_update(
77
+ "brand_new_shipx_status",
78
+ tracking_number="TRACK999",
79
+ )
80
+
81
+ assert update == {"tracking_number": "TRACK999"}
82
+
83
+ def test_returns_empty_update_for_unknown_status_without_tracking_number(
84
+ self,
85
+ ) -> None:
86
+ update = build_shipment_update("brand_new_shipx_status")
87
+
88
+ assert update == {}