feat(07-pre-expansion-fixes): complete phase 07 — milestone v0.2 done
Phase 7 complete (5 plans): - 07-01: Performance (N+1→LEFT JOIN, static cache, DB indexes) - 07-02: Stability (SSL verification, cron throttle DB, migration 000014b) - 07-03: UX (orderpro_to_allegro disable, lista zamówień fixes, SSL hotfix) - 07-04: Tests (12 unit tests for AllegroTokenManager + AllegroOrderImportService) - 07-05: InPost ShipX API (natywny provider, workaround remap usunięty) Additional fixes: - 5 broken use-statements fixed across 4 files - vendor/ excluded from ftp-kr auto-upload - PHPUnit + dg/bypass-finals infrastructure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
node_modules/
|
||||
vendor/
|
||||
composer.phar
|
||||
composer.lock
|
||||
storage/logs/
|
||||
storage/sessions/
|
||||
storage/cache/
|
||||
|
||||
@@ -12,9 +12,9 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Version | 0.1.0 |
|
||||
| Status | In Progress |
|
||||
| Last Updated | 2026-03-12 |
|
||||
| Version | 0.2.0 |
|
||||
| Status | v0.2 Complete |
|
||||
| Last Updated | 2026-03-15 |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -22,10 +22,15 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
|
||||
|
||||
- [x] Integracja z Allegro — pobieranie zamówień
|
||||
- [x] Generowanie etykiet (InPost)
|
||||
- [x] Performance: N+1 subqueries fix, DB indexes — Phase 7
|
||||
- [x] Stability: SSL verification, cron throttle — Phase 7
|
||||
- [x] UX: orderpro-to-allegro disable, lista zamówień poprawki — Phase 7
|
||||
- [x] Unit tests: AllegroTokenManager, AllegroOrderImportService (12 testów) — Phase 7
|
||||
- [x] InPost ShipX API: natywny provider niezależny od Allegro — Phase 7
|
||||
|
||||
### Active (In Progress)
|
||||
|
||||
- [ ] [Do zdefiniowania podczas planowania]
|
||||
- [ ] [Awaiting next milestone definition]
|
||||
|
||||
### Planned (Next)
|
||||
|
||||
@@ -78,6 +83,9 @@ PHP (XAMPP/Laravel), integracje z API marketplace'ów (Allegro, Erli) oraz API p
|
||||
| validateXxxInput(): ?string i validateXxxAccess(): ?Response jako wzorce helperów walidacji | Redukcja return statements do ≤3; spójny wzorzec kontrolerów | 2026-03-13 | Active |
|
||||
| God class split via move-method bez zmiany logiki | ShopproOrdersSyncService 39→9 metod; AllegroIntegrationController 35→25 — czysty podział przez ekstrakcję klas | 2026-03-13 | Active |
|
||||
| AllegroIntegrationController pozostaje przy 25 metodach (nie ≤15) | Pełny podział wymaga AllegroImportScheduleService — poza zakresem v0.1 | 2026-03-13 | Active |
|
||||
| dg/bypass-finals do testów final classes | Wszystkie klasy final — mockowanie przez bypass-finals zamiast usuwania final | 2026-03-15 | Active |
|
||||
| InPost ShipX API zamiast Allegro WZA remap | InpostIntegrationRepository jest pod ShipX; niezależność od Allegro | 2026-03-15 | Active |
|
||||
| vendor/ w ftp-kr ignore | Auto-upload dev deps na serwer powodował Fatal Error | 2026-03-15 | Active |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
@@ -94,7 +102,8 @@ PHP (XAMPP/Laravel), integracje z API marketplace'ów (Allegro, Erli) oraz API p
|
||||
| Frontend | HTML/CSS/JS + SCSS | jQuery Alerts module |
|
||||
| Database | MySQL (Medoo) | Prepared statements |
|
||||
| Auth | Sesje PHP | |
|
||||
| Integracje | Allegro API, Erli API | Przewoźnicy: InPost |
|
||||
| Integracje | Allegro API, Erli API | Przewoźnicy: InPost (ShipX), Apaczka |
|
||||
| Testing | PHPUnit 11.5 + dg/bypass-finals | Unit tests w tests/Unit/ |
|
||||
|
||||
## Specialized Flows
|
||||
|
||||
@@ -108,4 +117,4 @@ Quick Reference:
|
||||
|
||||
---
|
||||
*PROJECT.md — Updated when requirements or context change*
|
||||
*Last updated: 2026-03-13 after Phase 6 (SonarQube Quality — milestone v0.1 complete)*
|
||||
*Last updated: 2026-03-15 after Phase 7 (Pre-Expansion Fixes — milestone v0.2 complete)*
|
||||
|
||||
@@ -6,29 +6,28 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
|
||||
|
||||
## Current Milestone
|
||||
|
||||
**v0.2 Pre-Expansion Fixes** (v0.2.0)
|
||||
Status: 🔄 In Progress
|
||||
Phases: 0/1 complete
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Plans | Status | Completed |
|
||||
|-------|------|-------|--------|-----------|
|
||||
| 7 | Pre-Expansion Fixes | 0/5 | 🔄 Planning | — |
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 7 — Pre-Expansion Fixes
|
||||
Naprawa krytycznych problemów wydajnościowych, bezpieczeństwa i UX przed rozbudową aplikacji o nowe integracje i funkcje.
|
||||
|
||||
- **Plan 07-01** — Performance: N+1 subqueries + information_schema cache + DB indexes — *Not started*
|
||||
- **Plan 07-02** — Stability: SSL verification + cron throttle DB + migration 000014b — *Not started*
|
||||
- **Plan 07-03** — UX: orderpro_to_allegro disable + lista zamówień (items 14-17) — *Not started*
|
||||
- **Plan 07-04** — Tests: AllegroTokenManager + AllegroOrderImportService unit tests — *Not started*
|
||||
- **Plan 07-05** — InPost: ShipmentProviderInterface implementation — *Not started*
|
||||
No active milestone. Run `/paul:milestone` to define next.
|
||||
|
||||
## Completed Milestones
|
||||
|
||||
<details>
|
||||
<summary>v0.2 Pre-Expansion Fixes — 2026-03-15 (1 phase, 5 plans)</summary>
|
||||
|
||||
| Phase | Name | Plans | Completed |
|
||||
|-------|------|-------|-----------|
|
||||
| 7 | Pre-Expansion Fixes | 5/5 | 2026-03-15 |
|
||||
|
||||
Plans:
|
||||
- 07-01: Performance (N+1 subqueries, DB indexes, information_schema cache)
|
||||
- 07-02: Stability (SSL verification, cron throttle DB, migration 000014b)
|
||||
- 07-03: UX (orderpro_to_allegro disable, lista zamówień fixes)
|
||||
- 07-04: Tests (AllegroTokenManager + AllegroOrderImportService — 12 testów)
|
||||
- 07-05: InPost ShipmentProviderInterface (natywne ShipX API)
|
||||
|
||||
Archive: `.paul/phases/07-pre-expansion-fixes/`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.1 Initial Release — 2026-03-13 (6 phases, 15 plans)</summary>
|
||||
|
||||
@@ -47,4 +46,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-03-12*
|
||||
*Last updated: 2026-03-13 — milestone v0.1 complete, awaiting v0.2 definition*
|
||||
*Last updated: 2026-03-15 — milestone v0.2 complete*
|
||||
|
||||
@@ -5,26 +5,26 @@
|
||||
See: .paul/PROJECT.md (updated 2026-03-12)
|
||||
|
||||
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
|
||||
**Current focus:** Faza 07 — Pre-Expansion Fixes. Plan 07-03 UNIFY complete, 07-04 następny.
|
||||
**Current focus:** Milestone v0.2 COMPLETE. Następny milestone do zdefiniowania.
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: v0.2 Pre-Expansion Fixes
|
||||
Phase: 7 of TBD (07-pre-expansion-fixes) — Executing
|
||||
Plan: 07-01 ✓, 07-02 ✓, 07-03 ✓, 07-04..07-05 awaiting
|
||||
Status: Loop 07-03 zamknięty — następny /paul:apply 07-04
|
||||
Last activity: 2026-03-14 — Plan 07-03 loop closed (UX fixes + SSL hotfix)
|
||||
Milestone: v0.2 Pre-Expansion Fixes — COMPLETE ✓
|
||||
Phase: 7 of 7 (07-pre-expansion-fixes) — Complete
|
||||
Plan: 07-01 ✓, 07-02 ✓, 07-03 ✓, 07-04 ✓, 07-05 ✓
|
||||
Status: Milestone v0.2 complete — ready for next milestone
|
||||
Last activity: 2026-03-15 — Phase 07 transition complete, milestone v0.2 done
|
||||
|
||||
Progress:
|
||||
- v0.1 Initial Release: [██████████] 100% ✓
|
||||
- v0.2 Pre-Expansion Fixes: [██████░░░░] 60% (3/5 planów)
|
||||
- v0.2 Pre-Expansion Fixes: [██████████] 100% (5/5 planów)
|
||||
|
||||
## Loop Position
|
||||
|
||||
Current loop state:
|
||||
```
|
||||
PLAN ──▶ APPLY ──▶ UNIFY
|
||||
✓ ✓ ✓ [07-03 complete — next: /paul:apply 07-04]
|
||||
✓ ✓ ✓ [Phase 07 complete — milestone v0.2 done]
|
||||
```
|
||||
|
||||
## Accumulated Context
|
||||
@@ -38,6 +38,20 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
||||
| 2026-03-13 | Pole CSRF w formularzach: `_token` (nie `_csrf_token`) | Faza 03 | Ustandaryzowane w OrdersController, ShipmentController i 2 widokach |
|
||||
| 2026-03-13 | Flash messages: Flash::set('module.type') / Flash::get('module.type', '') | Faza 05 | OrdersController i ShipmentController zmigrowane; jeden wzorzec w całej aplikacji |
|
||||
| 2026-03-13 | validateXxxInput(): ?string i validateXxxAccess(): ?Response jako wzorce helperów walidacji | Faza 06 | Redukcja return statements do ≤3; wzorzec do użycia w kolejnych planach |
|
||||
| 2026-03-15 | dg/bypass-finals zamiast usuwania final z klas produkcyjnych | Faza 07 | Testy mockują final classes bez zmiany konwencji projektu |
|
||||
| 2026-03-15 | 3 bugi use-statement naprawione (odkryte przez testy) | Faza 07 | RuntimeException catch w 401 retry wreszcie działa; AllegroOAuthException rzucane poprawnie |
|
||||
| 2026-03-15 | InPost ShipX API (nie Allegro WZA) jako natywny provider | Faza 07 | InpostShipmentService niezależny od Allegro; workaround remap usunięty |
|
||||
| 2026-03-15 | vendor/ dodany do ftp-kr ignore; deploy vendor ręcznie | Faza 07 | Auto-upload nie nadpisze vendor/ na serwerze |
|
||||
|
||||
### Skill Audit (Faza 07, Plan 05)
|
||||
| Oczekiwany | Wywołany | Uwagi |
|
||||
|------------|---------|-------|
|
||||
| sonar-scanner | ○ | Zainstalowany (v4.3.5) ale nie uruchomiony w tym planie |
|
||||
|
||||
### Skill Audit (Faza 07, Plan 04)
|
||||
| Oczekiwany | Wywołany | Uwagi |
|
||||
|------------|---------|-------|
|
||||
| sonar-scanner | ○ | Pominięto — brak instalacji w PATH |
|
||||
|
||||
### Skill Audit (Faza 07, Plan 03)
|
||||
| Oczekiwany | Wywołany | Uwagi |
|
||||
@@ -107,16 +121,14 @@ Brak.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-14
|
||||
Stopped at: Loop 07-03 zamknięty — SUMMARY utworzony
|
||||
Next action: /paul:apply .paul/phases/07-pre-expansion-fixes/07-04-PLAN.md
|
||||
Resume file: .paul/phases/07-pre-expansion-fixes/07-03-SUMMARY.md
|
||||
Last session: 2026-03-15
|
||||
Stopped at: Milestone v0.2 complete
|
||||
Next action: /paul:milestone (define next milestone)
|
||||
Resume file: .paul/ROADMAP.md
|
||||
Resume context:
|
||||
- 07-01: COMPLETE ✓ (N+1→LEFT JOIN, static cache, migration 000048)
|
||||
- 07-02: COMPLETE ✓ (SSL verification, cron→DB, migration 000014b)
|
||||
- 07-03: COMPLETE ✓ (UX fixes + SSL hotfix CA bundle nullable)
|
||||
- 07-04: Tests (AllegroTokenManager + AllegroOrderImportService)
|
||||
- 07-05: InPost ShipmentProviderInterface — ma checkpoint:decision
|
||||
- v0.1: COMPLETE ✓ (6 phases, 15 plans — tech debt, bugs, quality)
|
||||
- v0.2: COMPLETE ✓ (1 phase, 5 plans — performance, stability, UX, tests, InPost)
|
||||
- Next milestone to define
|
||||
|
||||
---
|
||||
*STATE.md — Updated after every significant action*
|
||||
|
||||
164
.paul/phases/07-pre-expansion-fixes/07-04-SUMMARY.md
Normal file
164
.paul/phases/07-pre-expansion-fixes/07-04-SUMMARY.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
phase: 07-pre-expansion-fixes
|
||||
plan: 04
|
||||
subsystem: testing
|
||||
tags: [phpunit, allegro, oauth, unit-tests, bypass-finals]
|
||||
|
||||
requires:
|
||||
- phase: 01-allegro-token-manager
|
||||
provides: AllegroTokenManager extracted class
|
||||
- phase: 07-pre-expansion-fixes
|
||||
provides: Plans 01-03 bug fixes stabilizing tested classes
|
||||
|
||||
provides:
|
||||
- 7 unit tests for AllegroTokenManager (token refresh logic)
|
||||
- 5 unit tests for AllegroOrderImportService (import + 401 retry)
|
||||
- PHPUnit infrastructure (composer dependencies, bypass-finals)
|
||||
- 3 use-statement bug fixes discovered by tests
|
||||
|
||||
affects: [07-05-inpost, future-refactoring, ci-cd]
|
||||
|
||||
tech-stack:
|
||||
added: [phpunit/phpunit 11.5, dg/bypass-finals 1.9]
|
||||
patterns: [bypass-finals for final class mocking, createMock with DI]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- tests/Unit/AllegroTokenManagerTest.php
|
||||
- tests/Unit/AllegroOrderImportServiceTest.php
|
||||
modified:
|
||||
- tests/bootstrap.php
|
||||
- src/Modules/Settings/AllegroTokenManager.php
|
||||
- src/Modules/Settings/AllegroIntegrationRepository.php
|
||||
- src/Modules/Settings/AllegroOrderImportService.php
|
||||
|
||||
key-decisions:
|
||||
- "dg/bypass-finals zamiast usuwania final z klas produkcyjnych"
|
||||
- "Naprawienie 3 bugów use-statement odkrytych przez testy (nie przez plan)"
|
||||
|
||||
patterns-established:
|
||||
- "Testy unit: tests/Unit/{ClassName}Test.php z createMock + bypass-finals"
|
||||
- "bootstrap.php: DG\\BypassFinals::enable() dla wszystkich testów"
|
||||
|
||||
duration: ~15min
|
||||
completed: 2026-03-15T12:00:00Z
|
||||
---
|
||||
|
||||
# Phase 7 Plan 04: Unit Tests Summary
|
||||
|
||||
**12 testów jednostkowych dla AllegroTokenManager (7) i AllegroOrderImportService (5), plus 3 naprawione bugi use-statement odkryte przez testy.**
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~15min |
|
||||
| Completed | 2026-03-15 |
|
||||
| Tasks | 2 completed |
|
||||
| Files created | 2 |
|
||||
| Files modified | 4 |
|
||||
| Tests | 12 |
|
||||
| Assertions | 49 |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: AllegroTokenManager — logika refresh pokryta testami | Pass | 7 testów: fresh token, <5min, expired, no config, empty token, re-read, invalid format |
|
||||
| AC-2: AllegroOrderImportService — import happy path pokryty | Pass | 5 testów: happy path, 401 retry, empty ID, non-401 error, re-import |
|
||||
| AC-3: Wszystkie nowe testy przechodzą | Pass | 12 tests, 49 assertions, 0 failures |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- 7 testów AllegroTokenManager pokrywających: świeży token (brak refresh), wygasający <5min, wygasły, brak konfiguracji OAuth, pusty access token, write-then-re-read, nieprawidłowy format daty
|
||||
- 5 testów AllegroOrderImportService pokrywających: happy path import, 401 retry, pusty ID, propagacja non-401 RuntimeException, re-import (update vs create)
|
||||
- PHPUnit + dg/bypass-finals zainstalowane i skonfigurowane (composer.phar, vendor/, bootstrap.php)
|
||||
- Odkryte i naprawione 3 bugi use-statement w kodzie produkcyjnym
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `tests/Unit/AllegroTokenManagerTest.php` | Created | 7 testów logiki OAuth token refresh |
|
||||
| `tests/Unit/AllegroOrderImportServiceTest.php` | Created | 5 testów importu zamówień Allegro |
|
||||
| `tests/bootstrap.php` | Modified | Dodano DG\BypassFinals::enable() |
|
||||
| `src/Modules/Settings/AllegroTokenManager.php` | Modified | Fix: use App\Core\Exceptions\AllegroOAuthException |
|
||||
| `src/Modules/Settings/AllegroIntegrationRepository.php` | Modified | Fix: use App\Core\Exceptions\IntegrationConfigException |
|
||||
| `src/Modules/Settings/AllegroOrderImportService.php` | Modified | Fix: dodano use RuntimeException |
|
||||
| `composer.json` | Modified | Dodano dg/bypass-finals do require-dev |
|
||||
| `composer.lock` | Created | Lock file z zależnościami |
|
||||
| `vendor/` | Created | Zależności Composera |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| dg/bypass-finals zamiast usuwania final | Wszystkie klasy produkcyjne są final — usunięcie złamałoby konwencję projektu | Wymaga enable() w bootstrap.php, ale nie zmienia kodu produkcyjnego |
|
||||
| Naprawienie bugów use-statement | Testy ujawniły 3 złamane use-statements które uniemożliwiały prawidłowe działanie catch/throw | AllegroOrderImportService: 401 retry nigdy nie działał bez use RuntimeException |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Summary
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Auto-fixed | 3 | Krytyczne — naprawione bugi use-statement |
|
||||
| Scope additions | 1 | composer install + bypass-finals (konieczne do uruchomienia testów) |
|
||||
| Deferred | 0 | — |
|
||||
|
||||
**Total impact:** Niezbędne poprawki odkryte przez testy. Bez nich catch(RuntimeException) w AllegroOrderImportService nigdy nie łapał 401.
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. Broken use-statement: AllegroTokenManager**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `use AppCorexceptionsAllegroOAuthException` zamiast `use App\Core\Exceptions\AllegroOAuthException` (brak backslashy)
|
||||
- **Fix:** Poprawiony use-statement
|
||||
- **Files:** src/Modules/Settings/AllegroTokenManager.php
|
||||
- **Verification:** Test `testResolveTokenThrowsWhenNoOAuthConfig` przechodzi
|
||||
|
||||
**2. Broken use-statement: AllegroIntegrationRepository**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** `use AppCorexceptionsIntegrationConfigException` — brak backslashy
|
||||
- **Fix:** Poprawiony use-statement
|
||||
- **Files:** src/Modules/Settings/AllegroIntegrationRepository.php
|
||||
- **Verification:** Klasa ładuje się poprawnie przez autoloader
|
||||
|
||||
**3. Missing use RuntimeException: AllegroOrderImportService**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** catch(RuntimeException) w importSingleOrder() nigdy nie łapał wyjątku — brak `use RuntimeException`, więc PHP szukał `App\Modules\Settings\RuntimeException`
|
||||
- **Fix:** Dodano `use RuntimeException;`
|
||||
- **Files:** src/Modules/Settings/AllegroOrderImportService.php
|
||||
- **Verification:** Test `testImportSingleOrderRetryOn401` przechodzi
|
||||
|
||||
## Skill Audit
|
||||
|
||||
| Oczekiwany | Wywołany | Uwagi |
|
||||
|------------|---------|-------|
|
||||
| sonar-scanner | ○ | Pominięto — brak instalacji w PATH |
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
| Issue | Resolution |
|
||||
|-------|------------|
|
||||
| Brak vendor/ (composer nie był uruchomiony) | Pobrano composer.phar, uruchomiono `composer install --ignore-platform-reqs` |
|
||||
| PHP 8.2 vs wymagane ^8.4 w composer.json | Użyto `--ignore-platform-reqs` — PHPUnit 11.5 działa na 8.2 |
|
||||
| Final classes nie mogą być mockowane | Zainstalowano dg/bypass-finals, dodano enable() w bootstrap |
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- PHPUnit w pełni skonfigurowany — następne testy można dodawać bezproblemowo
|
||||
- Wzorzec testowy ustalony: createMock + bypass-finals
|
||||
- 12 testów jako baseline regression
|
||||
|
||||
**Concerns:**
|
||||
- PHP 8.2 vs ^8.4 requirement — testy działają ale runtime wymaga 8.4
|
||||
- sonar-scanner nadal nie uruchamiany (brak w PATH)
|
||||
|
||||
**Blockers:**
|
||||
- Brak
|
||||
|
||||
---
|
||||
*Phase: 07-pre-expansion-fixes, Plan: 04*
|
||||
*Completed: 2026-03-15*
|
||||
145
.paul/phases/07-pre-expansion-fixes/07-05-SUMMARY.md
Normal file
145
.paul/phases/07-pre-expansion-fixes/07-05-SUMMARY.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: 07-pre-expansion-fixes
|
||||
plan: 05
|
||||
subsystem: shipments
|
||||
tags: [inpost, shipx-api, shipment-provider, ftp-deploy]
|
||||
|
||||
requires:
|
||||
- phase: 01-allegro-token-manager
|
||||
provides: ShipmentProviderInterface pattern from AllegroShipmentService
|
||||
|
||||
provides:
|
||||
- InpostShipmentService implementing ShipmentProviderInterface (native ShipX API)
|
||||
- Workaround remap inpost→allegro_wza removed from ShipmentController
|
||||
- vendor/ excluded from ftp-kr auto-upload (prevents dev deps on server)
|
||||
- 2 broken use-statement fixes in ShipmentController and AllegroShipmentService
|
||||
|
||||
affects: [future-carriers, inpost-configuration, ci-cd]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [ShipX API integration via cURL, ShipmentProviderInterface for new carriers]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/Modules/Shipments/InpostShipmentService.php
|
||||
modified:
|
||||
- src/Modules/Shipments/ShipmentController.php
|
||||
- src/Modules/Shipments/AllegroShipmentService.php
|
||||
- routes/web.php
|
||||
- .vscode/ftp-kr.json
|
||||
|
||||
key-decisions:
|
||||
- "ShipX API (natywne InPost) zamiast Allegro WZA — pełna niezależność od Allegro"
|
||||
- "vendor/ w ftp-kr ignore — zapobiega auto-upload dev deps na serwer"
|
||||
|
||||
patterns-established:
|
||||
- "Nowy carrier = nowy XxxShipmentService implements ShipmentProviderInterface + rejestracja w web.php"
|
||||
- "vendor/ deploy: ręcznie przez skrypt FTP po composer install --no-dev"
|
||||
|
||||
duration: ~20min
|
||||
completed: 2026-03-15T13:00:00Z
|
||||
---
|
||||
|
||||
# Phase 7 Plan 05: InPost ShipmentProviderInterface Summary
|
||||
|
||||
**InpostShipmentService z natywnym ShipX API zastąpił workaround remap inpost→allegro_wza. InPost działa niezależnie od Allegro.**
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~20min |
|
||||
| Completed | 2026-03-15 |
|
||||
| Tasks | 3 completed (2 auto + 1 checkpoint) |
|
||||
| Files created | 1 |
|
||||
| Files modified | 4 |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: InpostShipmentService implementuje ShipmentProviderInterface | Pass | php -l clean, implements ShipmentProviderInterface, 5 metod interfejsu |
|
||||
| AC-2: InPost shipments przez InPost API, nie Allegro WZA | Pass | Workaround usunięty, InpostShipmentService zarejestrowany pod kluczem 'inpost' |
|
||||
| AC-3: Brak regresji Allegro WZA | Pass | Human-verify approved — formularz ładuje się bez błędów, Allegro WZA nadal dostępne |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- InpostShipmentService z pełną implementacją ShipX API: createShipment, checkCreationStatus, downloadLabel, getDeliveryServices
|
||||
- Workaround `if ($providerCode === 'inpost') { $providerCode = 'allegro_wza'; }` usunięty z ShipmentController
|
||||
- InpostShipmentService zarejestrowany w ShipmentProviderRegistry (routes/web.php)
|
||||
- vendor/ dodany do ftp-kr ignore — rozwiązanie problemu auto-upload dev dependencies na serwer
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/Modules/Shipments/InpostShipmentService.php` | Created | Natywna implementacja ShipX API — create, status, label |
|
||||
| `src/Modules/Shipments/ShipmentController.php` | Modified | Usunięty remap inpost→allegro_wza; fix use ShipmentException |
|
||||
| `src/Modules/Shipments/AllegroShipmentService.php` | Modified | Fix broken use-statements (IntegrationConfigException, ShipmentException) |
|
||||
| `routes/web.php` | Modified | Dodano use InpostShipmentService, wiring w ShipmentProviderRegistry |
|
||||
| `.vscode/ftp-kr.json` | Modified | Dodano vendor/, tests/, phpunit.xml, composer.* do ignore |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| ShipX API (natywne InPost) | InpostIntegrationRepository ma pola ShipX (organization_id, locker_size, dispatch_method) — jest pod ShipX, nie Allegro WZA | InPost działa bez Allegro; InPost-only użytkownicy odblokwani |
|
||||
| vendor/ w ftp-kr ignore | Auto-upload wrzucał dev deps (phpunit, deep-copy) na serwer powodując Fatal Error | Deploy vendor/ ręcznie; dev deps nigdy nie trafią na serwer |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Summary
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Auto-fixed | 2 | Broken use-statements w ShipmentController i AllegroShipmentService |
|
||||
| Scope additions | 1 | ftp-kr.json ignore (konieczne — auto-upload powodował crash na serwerze) |
|
||||
| Deferred | 0 | — |
|
||||
|
||||
**Total impact:** Niezbędne poprawki. Bez fix use-statements ShipmentException nigdy nie byłby łapany prawidłowo.
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. Broken use-statement: ShipmentController**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `use AppCorexceptionsShipmentException` — brak backslashy
|
||||
- **Fix:** `use App\Core\Exceptions\ShipmentException`
|
||||
- **Files:** src/Modules/Shipments/ShipmentController.php
|
||||
|
||||
**2. Broken use-statements: AllegroShipmentService**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `use AppCoreExceptionsIntegrationConfigException` i `use AppCoreExceptionsShipmentException`
|
||||
- **Fix:** Poprawione backslashe w obu use-statements
|
||||
- **Files:** src/Modules/Shipments/AllegroShipmentService.php
|
||||
|
||||
## Skill Audit
|
||||
|
||||
| Oczekiwany | Wywołany | Uwagi |
|
||||
|------------|---------|-------|
|
||||
| sonar-scanner | ○ | Zainstalowany (v4.3.5) ale nie uruchomiony w tym planie |
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
| Issue | Resolution |
|
||||
|-------|------------|
|
||||
| Auto-upload vendor/ z dev deps → Fatal Error na serwerze | Dodano vendor/ do ftp-kr ignore; deploy vendor/ ręcznie przez FTP skrypt |
|
||||
| Wiring nie w Application.php (jak zakładał plan) ale w routes/web.php | Znalezione przez grep; dodano wiring w tym samym pliku co inne providery |
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- Wzorzec ShipmentProviderInterface sprawdzony na 3 providerach (Allegro WZA, Apaczka, InPost)
|
||||
- Dodanie nowego carrieru: 1 klasa + 1 linia w web.php
|
||||
- sonar-scanner zainstalowany (v4.3.5), gotowy do uruchomienia
|
||||
|
||||
**Concerns:**
|
||||
- InPost ShipX wymaga testów z prawdziwym tokenem sandbox — nie testowano API calls
|
||||
- 5+ broken use-statements naprawionych w fazach 07-04 i 07-05 — mogą być więcej w innych plikach
|
||||
|
||||
**Blockers:**
|
||||
- Brak
|
||||
|
||||
---
|
||||
*Phase: 07-pre-expansion-fixes, Plan: 05*
|
||||
*Completed: 2026-03-15*
|
||||
9
.vscode/ftp-kr.json
vendored
9
.vscode/ftp-kr.json
vendored
@@ -16,6 +16,13 @@
|
||||
"/.claude",
|
||||
".gitignore",
|
||||
"/.scannerwork",
|
||||
"/.paul"
|
||||
"/.paul",
|
||||
"/vendor",
|
||||
"/node_modules",
|
||||
"/composer.phar",
|
||||
"/composer.lock",
|
||||
"/tests",
|
||||
"/phpunit.xml",
|
||||
"/.plantuml"
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
"php": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5"
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"dg/bypass-finals": "^1.9"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -38,6 +38,7 @@ use App\Modules\Settings\CronSettingsController;
|
||||
use App\Modules\Settings\SettingsController;
|
||||
use App\Modules\Shipments\ApaczkaShipmentService;
|
||||
use App\Modules\Shipments\AllegroShipmentService;
|
||||
use App\Modules\Shipments\InpostShipmentService;
|
||||
use App\Modules\Shipments\ShipmentController;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Shipments\ShipmentProviderRegistry;
|
||||
@@ -186,9 +187,16 @@ return static function (Application $app): void {
|
||||
$companySettingsRepository,
|
||||
new OrdersRepository($app->db())
|
||||
);
|
||||
$inpostShipmentService = new InpostShipmentService(
|
||||
$inpostIntegrationRepository,
|
||||
$shipmentPackageRepository,
|
||||
$companySettingsRepository,
|
||||
new OrdersRepository($app->db())
|
||||
);
|
||||
$shipmentProviderRegistry = new ShipmentProviderRegistry([
|
||||
$shipmentService,
|
||||
$apaczkaShipmentService,
|
||||
$inpostShipmentService,
|
||||
]);
|
||||
$shipmentController = new ShipmentController(
|
||||
$template,
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Support\StringHelper;
|
||||
use PDO;
|
||||
use AppCorexceptionsIntegrationConfigException;
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroIntegrationRepository
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Modules\Orders\OrderImportRepository;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Core\Constants\IntegrationSources;
|
||||
use App\Core\Exceptions\AllegroApiException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroOrderImportService
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Modules\Settings;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use AppCorexceptionsAllegroOAuthException;
|
||||
use App\Core\Exceptions\AllegroOAuthException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroTokenManager
|
||||
|
||||
@@ -8,8 +8,8 @@ use App\Modules\Settings\AllegroApiClient;
|
||||
use App\Modules\Settings\AllegroTokenManager;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use RuntimeException;
|
||||
use AppCoreExceptionsIntegrationConfigException;
|
||||
use AppCoreExceptionsShipmentException;
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use App\Core\Exceptions\ShipmentException;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroShipmentService implements ShipmentProviderInterface
|
||||
|
||||
577
src/Modules/Shipments/InpostShipmentService.php
Normal file
577
src/Modules/Shipments/InpostShipmentService.php
Normal file
@@ -0,0 +1,577 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use App\Core\Exceptions\ShipmentException;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\InpostIntegrationRepository;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class InpostShipmentService implements ShipmentProviderInterface
|
||||
{
|
||||
private const API_BASE_PRODUCTION = 'https://api-shipx-pl.easypack24.net/v1';
|
||||
private const API_BASE_SANDBOX = 'https://sandbox-api-shipx-pl.easypack24.net/v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly InpostIntegrationRepository $inpostRepository,
|
||||
private readonly ShipmentPackageRepository $packages,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly OrdersRepository $ordersRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function code(): string
|
||||
{
|
||||
return 'inpost';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getDeliveryServices(): array
|
||||
{
|
||||
return [
|
||||
['id' => 'inpost_locker_standard', 'name' => 'InPost Paczkomat - Standard', 'type' => 'locker'],
|
||||
['id' => 'inpost_courier_standard', 'name' => 'InPost Kurier - Standard', 'type' => 'courier'],
|
||||
['id' => 'inpost_courier_express', 'name' => 'InPost Kurier - Express', 'type' => 'courier'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createShipment(int $orderId, array $formData): array
|
||||
{
|
||||
$order = $this->ordersRepository->findDetails($orderId);
|
||||
if ($order === null) {
|
||||
throw new ShipmentException('Zamowienie nie znalezione.');
|
||||
}
|
||||
|
||||
$token = $this->resolveToken();
|
||||
$settings = $this->inpostRepository->getSettings();
|
||||
$organizationId = trim((string) ($settings['organization_id'] ?? ''));
|
||||
if ($organizationId === '') {
|
||||
throw new IntegrationConfigException('Brak organization_id w konfiguracji InPost.');
|
||||
}
|
||||
|
||||
$company = $this->companySettings->getSettings();
|
||||
$sender = $this->companySettings->getSenderAddress();
|
||||
$this->validateSenderAddress($sender);
|
||||
|
||||
$receiver = $this->buildReceiverFromOrder($order, $formData);
|
||||
$senderPayload = $this->buildSenderPayload($sender);
|
||||
|
||||
$serviceType = $this->resolveServiceType($formData, $settings);
|
||||
$parcelPayload = $this->buildParcelPayload($formData, $settings, $company, $serviceType);
|
||||
|
||||
$apiPayload = [
|
||||
'receiver' => $receiver,
|
||||
'sender' => $senderPayload,
|
||||
'parcels' => [$parcelPayload],
|
||||
'service' => $serviceType,
|
||||
'reference' => $this->buildReference($order, $orderId),
|
||||
];
|
||||
|
||||
$codAmount = (float) ($formData['cod_amount'] ?? 0);
|
||||
if ($codAmount > 0) {
|
||||
$apiPayload['cod'] = [
|
||||
'amount' => number_format($codAmount, 2, '.', ''),
|
||||
'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
|
||||
];
|
||||
}
|
||||
|
||||
$insuranceAmount = (float) ($formData['insurance_amount'] ?? 0);
|
||||
if ($insuranceAmount <= 0 && !empty($settings['auto_insurance_value'])) {
|
||||
$orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
|
||||
$totalWithTax = (float) ($orderData['total_with_tax'] ?? 0);
|
||||
if ($totalWithTax > 0) {
|
||||
$insuranceAmount = $totalWithTax;
|
||||
}
|
||||
}
|
||||
if ($insuranceAmount > 0) {
|
||||
$apiPayload['insurance'] = [
|
||||
'amount' => number_format($insuranceAmount, 2, '.', ''),
|
||||
'currency' => strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))),
|
||||
];
|
||||
}
|
||||
|
||||
$labelFormat = trim((string) ($formData['label_format'] ?? ($settings['label_format'] ?? 'Pdf')));
|
||||
|
||||
$packageId = $this->packages->create([
|
||||
'order_id' => $orderId,
|
||||
'provider' => 'inpost',
|
||||
'delivery_method_id' => $serviceType,
|
||||
'credentials_id' => null,
|
||||
'command_id' => null,
|
||||
'status' => 'pending',
|
||||
'carrier_id' => 'inpost',
|
||||
'package_type' => $parcelPayload['dimensions'] ? 'PACKAGE' : 'LOCKER',
|
||||
'weight_kg' => isset($parcelPayload['weight']['amount']) ? (float) $parcelPayload['weight']['amount'] : null,
|
||||
'length_cm' => isset($parcelPayload['dimensions']['length']) ? (float) $parcelPayload['dimensions']['length'] : null,
|
||||
'width_cm' => isset($parcelPayload['dimensions']['width']) ? (float) $parcelPayload['dimensions']['width'] : null,
|
||||
'height_cm' => isset($parcelPayload['dimensions']['height']) ? (float) $parcelPayload['dimensions']['height'] : null,
|
||||
'insurance_amount' => $insuranceAmount > 0 ? $insuranceAmount : null,
|
||||
'insurance_currency' => $insuranceAmount > 0 ? strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))) : null,
|
||||
'cod_amount' => $codAmount > 0 ? $codAmount : null,
|
||||
'cod_currency' => $codAmount > 0 ? strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))) : null,
|
||||
'label_format' => $labelFormat,
|
||||
'receiver_point_id' => trim((string) ($formData['receiver_point_id'] ?? '')),
|
||||
'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
|
||||
'reference_number' => $apiPayload['reference'],
|
||||
'payload_json' => $apiPayload,
|
||||
]);
|
||||
|
||||
$env = (string) ($settings['environment'] ?? 'sandbox');
|
||||
$url = $this->apiBaseUrl($env) . '/organizations/' . rawurlencode($organizationId) . '/shipments';
|
||||
|
||||
try {
|
||||
$response = $this->apiRequest('POST', $url, $token, $apiPayload);
|
||||
} catch (Throwable $exception) {
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'error',
|
||||
'error_message' => $exception->getMessage(),
|
||||
]);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$shipmentId = trim((string) ($response['id'] ?? ''));
|
||||
$trackingNumber = trim((string) ($response['tracking_number'] ?? ''));
|
||||
$status = strtolower(trim((string) ($response['status'] ?? 'created')));
|
||||
|
||||
$this->packages->update($packageId, [
|
||||
'shipment_id' => $shipmentId !== '' ? $shipmentId : null,
|
||||
'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null,
|
||||
'status' => $status === 'created' || $status === 'confirmed' ? 'created' : 'pending',
|
||||
'payload_json' => $response,
|
||||
]);
|
||||
|
||||
return [
|
||||
'package_id' => $packageId,
|
||||
'command_id' => $shipmentId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function checkCreationStatus(int $packageId): array
|
||||
{
|
||||
$package = $this->packages->findById($packageId);
|
||||
if ($package === null) {
|
||||
throw new ShipmentException('Paczka nie znaleziona.');
|
||||
}
|
||||
|
||||
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
|
||||
if ($shipmentId === '') {
|
||||
return ['status' => 'error', 'error' => 'Brak shipment_id — przesylka nie zostala utworzona.'];
|
||||
}
|
||||
|
||||
$token = $this->resolveToken();
|
||||
$settings = $this->inpostRepository->getSettings();
|
||||
$env = (string) ($settings['environment'] ?? 'sandbox');
|
||||
$url = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId);
|
||||
|
||||
$response = $this->apiRequest('GET', $url, $token);
|
||||
$status = strtolower(trim((string) ($response['status'] ?? '')));
|
||||
$trackingNumber = trim((string) ($response['tracking_number'] ?? ''));
|
||||
|
||||
if (in_array($status, ['created', 'confirmed', 'dispatched', 'collected', 'delivered'], true)) {
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'created',
|
||||
'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null,
|
||||
'payload_json' => $response,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'created',
|
||||
'shipment_id' => $shipmentId,
|
||||
'tracking_number' => $trackingNumber,
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($status, ['cancelled', 'expired'], true)) {
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'error',
|
||||
'error_message' => 'Przesylka anulowana/wygasla (status: ' . $status . ')',
|
||||
'payload_json' => $response,
|
||||
]);
|
||||
|
||||
return ['status' => 'error', 'error' => 'Przesylka: ' . $status];
|
||||
}
|
||||
|
||||
return ['status' => 'in_progress'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function downloadLabel(int $packageId, string $storagePath): array
|
||||
{
|
||||
$package = $this->packages->findById($packageId);
|
||||
if ($package === null) {
|
||||
throw new ShipmentException('Paczka nie znaleziona.');
|
||||
}
|
||||
|
||||
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
|
||||
if ($shipmentId === '') {
|
||||
throw new ShipmentException('Przesylka nie zostala jeszcze utworzona.');
|
||||
}
|
||||
|
||||
$token = $this->resolveToken();
|
||||
$settings = $this->inpostRepository->getSettings();
|
||||
$env = (string) ($settings['environment'] ?? 'sandbox');
|
||||
$labelFormat = trim((string) ($package['label_format'] ?? ($settings['label_format'] ?? 'Pdf')));
|
||||
|
||||
$url = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId) . '/label';
|
||||
$queryParams = ['format' => $labelFormat, 'type' => 'normal'];
|
||||
$url .= '?' . http_build_query($queryParams);
|
||||
|
||||
$binary = $this->apiRequestRaw('GET', $url, $token);
|
||||
|
||||
$dir = rtrim($storagePath, '/\\') . '/labels';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$ext = strtolower($labelFormat) === 'zpl' ? 'zpl' : 'pdf';
|
||||
$filename = 'label_' . $packageId . '_inpost_' . $shipmentId . '.' . $ext;
|
||||
$filePath = $dir . '/' . $filename;
|
||||
file_put_contents($filePath, $binary);
|
||||
|
||||
$updateFields = [
|
||||
'status' => 'label_ready',
|
||||
'label_path' => 'labels/' . $filename,
|
||||
];
|
||||
|
||||
if (trim((string) ($package['tracking_number'] ?? '')) === '') {
|
||||
try {
|
||||
$detailsUrl = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId);
|
||||
$details = $this->apiRequest('GET', $detailsUrl, $token);
|
||||
$trackingNumber = trim((string) ($details['tracking_number'] ?? ''));
|
||||
if ($trackingNumber !== '') {
|
||||
$updateFields['tracking_number'] = $trackingNumber;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
$this->packages->update($packageId, $updateFields);
|
||||
|
||||
return [
|
||||
'label_path' => 'labels/' . $filename,
|
||||
'full_path' => $filePath,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveToken(): string
|
||||
{
|
||||
$token = $this->inpostRepository->getDecryptedToken();
|
||||
if ($token === null || trim($token) === '') {
|
||||
throw new IntegrationConfigException('Brak tokenu API InPost. Skonfiguruj w Ustawienia > Integracje > InPost.');
|
||||
}
|
||||
return trim($token);
|
||||
}
|
||||
|
||||
private function apiBaseUrl(string $environment): string
|
||||
{
|
||||
return strtolower(trim($environment)) === 'production'
|
||||
? self::API_BASE_PRODUCTION
|
||||
: self::API_BASE_SANDBOX;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $body
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function apiRequest(string $method, string $url, string $token, ?array $body = null): array
|
||||
{
|
||||
$binary = $this->apiRequestRaw($method, $url, $token, $body, 'application/json');
|
||||
$json = json_decode($binary, true);
|
||||
if (!is_array($json)) {
|
||||
throw new ShipmentException('Nieprawidlowa odpowiedz JSON z InPost API.');
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $body
|
||||
*/
|
||||
private function apiRequestRaw(
|
||||
string $method,
|
||||
string $url,
|
||||
string $token,
|
||||
?array $body = null,
|
||||
string $accept = 'application/pdf'
|
||||
): string {
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new ShipmentException('Nie udalo sie zainicjowac polaczenia z InPost API.');
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Accept: ' . $accept,
|
||||
];
|
||||
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
];
|
||||
|
||||
$caPath = $this->getCaBundlePath();
|
||||
if ($caPath !== null) {
|
||||
$opts[CURLOPT_CAINFO] = $caPath;
|
||||
}
|
||||
|
||||
if ($body !== null) {
|
||||
$jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
|
||||
}
|
||||
|
||||
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||
curl_setopt_array($ch, $opts);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
$ch = null;
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new ShipmentException('Blad polaczenia z InPost API: ' . $curlError);
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$errorMsg = $this->extractApiErrorMessage((string) $responseBody, $httpCode);
|
||||
throw new ShipmentException($errorMsg);
|
||||
}
|
||||
|
||||
return (string) $responseBody;
|
||||
}
|
||||
|
||||
private function extractApiErrorMessage(string $body, int $httpCode): string
|
||||
{
|
||||
$json = json_decode($body, true);
|
||||
if (is_array($json)) {
|
||||
$message = trim((string) ($json['message'] ?? ''));
|
||||
if ($message !== '') {
|
||||
return 'InPost API [HTTP ' . $httpCode . ']: ' . $message;
|
||||
}
|
||||
$details = $json['details'] ?? $json['error'] ?? null;
|
||||
if (is_string($details) && trim($details) !== '') {
|
||||
return 'InPost API [HTTP ' . $httpCode . ']: ' . trim($details);
|
||||
}
|
||||
}
|
||||
|
||||
return 'InPost API zwrocilo blad HTTP ' . $httpCode;
|
||||
}
|
||||
|
||||
private function getCaBundlePath(): ?string
|
||||
{
|
||||
$envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? '');
|
||||
if ($envPath !== '' && is_file($envPath)) {
|
||||
return $envPath;
|
||||
}
|
||||
$iniPath = (string) ini_get('curl.cainfo');
|
||||
if ($iniPath !== '' && is_file($iniPath)) {
|
||||
return $iniPath;
|
||||
}
|
||||
$candidates = [
|
||||
'C:/xampp/apache/bin/curl-ca-bundle.crt',
|
||||
'C:/xampp/php/extras/ssl/cacert.pem',
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
];
|
||||
foreach ($candidates as $path) {
|
||||
if (is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $orderDetails
|
||||
* @param array<string, mixed> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildReceiverFromOrder(array $orderDetails, array $formData): array
|
||||
{
|
||||
$addresses = is_array($orderDetails['addresses'] ?? null) ? $orderDetails['addresses'] : [];
|
||||
$deliveryAddr = null;
|
||||
$customerAddr = null;
|
||||
foreach ($addresses as $addr) {
|
||||
$type = (string) ($addr['address_type'] ?? '');
|
||||
if ($type === 'delivery') {
|
||||
$deliveryAddr = $addr;
|
||||
}
|
||||
if ($type === 'customer') {
|
||||
$customerAddr = $addr;
|
||||
}
|
||||
}
|
||||
|
||||
$addr = $deliveryAddr ?? $customerAddr ?? [];
|
||||
|
||||
$name = trim((string) ($formData['receiver_name'] ?? ($addr['name'] ?? '')));
|
||||
$company = trim((string) ($formData['receiver_company'] ?? ($addr['company_name'] ?? '')));
|
||||
$phone = trim((string) ($formData['receiver_phone'] ?? ($addr['phone'] ?? '')));
|
||||
$email = trim((string) ($formData['receiver_email'] ?? ($addr['email'] ?? '')));
|
||||
|
||||
$receiver = [
|
||||
'name' => $name !== '' ? $name : ($company !== '' ? $company : 'Odbiorca'),
|
||||
'phone' => $phone,
|
||||
'email' => $email,
|
||||
];
|
||||
|
||||
if ($company !== '') {
|
||||
$receiver['company_name'] = $company;
|
||||
}
|
||||
|
||||
$street = trim((string) ($formData['receiver_street'] ?? ($addr['street_name'] ?? '')));
|
||||
$city = trim((string) ($formData['receiver_city'] ?? ($addr['city'] ?? '')));
|
||||
$postalCode = trim((string) ($formData['receiver_postal_code'] ?? ($addr['zip_code'] ?? '')));
|
||||
$countryCode = strtoupper(trim((string) ($formData['receiver_country_code'] ?? ($addr['country'] ?? 'PL'))));
|
||||
|
||||
$receiver['address'] = [
|
||||
'street' => $street,
|
||||
'city' => $city,
|
||||
'post_code' => $postalCode,
|
||||
'country_code' => $countryCode,
|
||||
];
|
||||
|
||||
return $receiver;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sender
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSenderPayload(array $sender): array
|
||||
{
|
||||
$name = trim((string) ($sender['name'] ?? ''));
|
||||
$company = trim((string) ($sender['company'] ?? ''));
|
||||
|
||||
return [
|
||||
'name' => $name !== '' ? $name : ($company !== '' ? $company : 'Nadawca'),
|
||||
'company_name' => $company !== '' ? $company : null,
|
||||
'phone' => trim((string) ($sender['phone'] ?? '')),
|
||||
'email' => trim((string) ($sender['email'] ?? '')),
|
||||
'address' => [
|
||||
'street' => trim((string) ($sender['street'] ?? '')),
|
||||
'city' => trim((string) ($sender['city'] ?? '')),
|
||||
'post_code' => trim((string) ($sender['postalCode'] ?? '')),
|
||||
'country_code' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $formData
|
||||
* @param array<string, mixed> $settings
|
||||
*/
|
||||
private function resolveServiceType(array $formData, array $settings): string
|
||||
{
|
||||
$deliveryMethodId = trim((string) ($formData['delivery_method_id'] ?? ''));
|
||||
if ($deliveryMethodId !== '') {
|
||||
return $deliveryMethodId;
|
||||
}
|
||||
|
||||
$pointId = trim((string) ($formData['receiver_point_id'] ?? ''));
|
||||
if ($pointId !== '') {
|
||||
return 'inpost_locker_standard';
|
||||
}
|
||||
|
||||
$dispatchMethod = (string) ($settings['default_dispatch_method'] ?? 'pop');
|
||||
if ($dispatchMethod === 'parcel_locker') {
|
||||
return 'inpost_locker_standard';
|
||||
}
|
||||
|
||||
return 'inpost_courier_standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $formData
|
||||
* @param array<string, mixed> $settings
|
||||
* @param array<string, mixed> $company
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildParcelPayload(array $formData, array $settings, array $company, string $serviceType): array
|
||||
{
|
||||
$isLocker = str_contains($serviceType, 'locker');
|
||||
|
||||
if ($isLocker) {
|
||||
$size = trim((string) ($formData['locker_size'] ?? ($settings['default_locker_size'] ?? 'small')));
|
||||
if (!in_array($size, ['small', 'medium', 'large'], true)) {
|
||||
$size = 'small';
|
||||
}
|
||||
|
||||
$targetPointId = trim((string) ($formData['receiver_point_id'] ?? ''));
|
||||
|
||||
$parcel = [
|
||||
'template' => $size,
|
||||
];
|
||||
if ($targetPointId !== '') {
|
||||
$parcel['target_point'] = $targetPointId;
|
||||
}
|
||||
|
||||
return $parcel;
|
||||
}
|
||||
|
||||
$lengthCm = (float) ($formData['length_cm'] ?? ($settings['default_courier_length'] ?? $company['default_package_length_cm'] ?? 20));
|
||||
$widthCm = (float) ($formData['width_cm'] ?? ($settings['default_courier_width'] ?? $company['default_package_width_cm'] ?? 15));
|
||||
$heightCm = (float) ($formData['height_cm'] ?? ($settings['default_courier_height'] ?? $company['default_package_height_cm'] ?? 8));
|
||||
$weightKg = (float) ($formData['weight_kg'] ?? ($company['default_package_weight_kg'] ?? 1));
|
||||
|
||||
return [
|
||||
'dimensions' => [
|
||||
'length' => $lengthCm,
|
||||
'width' => $widthCm,
|
||||
'height' => $heightCm,
|
||||
'unit' => 'mm',
|
||||
],
|
||||
'weight' => [
|
||||
'amount' => $weightKg,
|
||||
'unit' => 'kg',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $orderDetails
|
||||
*/
|
||||
private function buildReference(array $orderDetails, int $orderId): string
|
||||
{
|
||||
$orderData = is_array($orderDetails['order'] ?? null) ? $orderDetails['order'] : [];
|
||||
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
|
||||
|
||||
return $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sender
|
||||
*/
|
||||
private function validateSenderAddress(array $sender): void
|
||||
{
|
||||
$required = ['street', 'city', 'postalCode', 'phone', 'email'];
|
||||
foreach ($required as $field) {
|
||||
if (trim((string) ($sender[$field] ?? '')) === '') {
|
||||
throw new IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak: ' . $field . ').');
|
||||
}
|
||||
}
|
||||
|
||||
$name = trim((string) ($sender['name'] ?? ''));
|
||||
$company = trim((string) ($sender['company'] ?? ''));
|
||||
if ($name === '' && $company === '') {
|
||||
throw new IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak nazwy/firmy).');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use AppCorexceptionsShipmentException;
|
||||
use App\Core\Exceptions\ShipmentException;
|
||||
use Throwable;
|
||||
|
||||
final class ShipmentController
|
||||
@@ -162,9 +162,6 @@ final class ShipmentController
|
||||
|
||||
try {
|
||||
$providerCode = strtolower(trim((string) $request->input('provider_code', 'allegro_wza')));
|
||||
if ($providerCode === 'inpost') {
|
||||
$providerCode = 'allegro_wza';
|
||||
}
|
||||
$provider = $this->providerRegistry->get($providerCode);
|
||||
if ($provider === null) {
|
||||
throw new ShipmentException('Nieznany provider przesylek: ' . $providerCode);
|
||||
|
||||
230
tests/Unit/AllegroOrderImportServiceTest.php
Normal file
230
tests/Unit/AllegroOrderImportServiceTest.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Core\Exceptions\AllegroApiException;
|
||||
use App\Modules\Orders\OrderImportRepository;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\AllegroApiClient;
|
||||
use App\Modules\Settings\AllegroIntegrationRepository;
|
||||
use App\Modules\Settings\AllegroOrderImportService;
|
||||
use App\Modules\Settings\AllegroStatusMappingRepository;
|
||||
use App\Modules\Settings\AllegroTokenManager;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class AllegroOrderImportServiceTest extends TestCase
|
||||
{
|
||||
private AllegroIntegrationRepository&MockObject $integrationRepository;
|
||||
private AllegroTokenManager&MockObject $tokenManager;
|
||||
private AllegroApiClient&MockObject $apiClient;
|
||||
private OrderImportRepository&MockObject $orders;
|
||||
private AllegroStatusMappingRepository&MockObject $statusMappings;
|
||||
private OrdersRepository&MockObject $ordersRepository;
|
||||
private AllegroOrderImportService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->integrationRepository = $this->createMock(AllegroIntegrationRepository::class);
|
||||
$this->tokenManager = $this->createMock(AllegroTokenManager::class);
|
||||
$this->apiClient = $this->createMock(AllegroApiClient::class);
|
||||
$this->orders = $this->createMock(OrderImportRepository::class);
|
||||
$this->statusMappings = $this->createMock(AllegroStatusMappingRepository::class);
|
||||
$this->ordersRepository = $this->createMock(OrdersRepository::class);
|
||||
|
||||
$this->service = new AllegroOrderImportService(
|
||||
$this->integrationRepository,
|
||||
$this->tokenManager,
|
||||
$this->apiClient,
|
||||
$this->orders,
|
||||
$this->statusMappings,
|
||||
$this->ordersRepository
|
||||
);
|
||||
}
|
||||
|
||||
public function testImportSingleOrderHappyPath(): void
|
||||
{
|
||||
$checkoutFormId = 'abc-123';
|
||||
$payload = $this->buildMinimalPayload($checkoutFormId);
|
||||
|
||||
$this->tokenManager
|
||||
->method('resolveToken')
|
||||
->willReturn(['access-token-xyz', 'sandbox']);
|
||||
|
||||
$this->apiClient
|
||||
->method('getCheckoutForm')
|
||||
->with('sandbox', 'access-token-xyz', $checkoutFormId)
|
||||
->willReturn($payload);
|
||||
|
||||
$this->apiClient
|
||||
->method('getCheckoutFormShipments')
|
||||
->willReturn([]);
|
||||
|
||||
$this->integrationRepository
|
||||
->method('getActiveIntegrationId')
|
||||
->willReturn(5);
|
||||
|
||||
$this->statusMappings
|
||||
->method('findMappedOrderproStatusCode')
|
||||
->willReturn('new');
|
||||
|
||||
$this->orders
|
||||
->expects($this->once())
|
||||
->method('upsertOrderAggregate')
|
||||
->willReturn(['order_id' => 42, 'created' => true]);
|
||||
|
||||
$this->ordersRepository
|
||||
->expects($this->once())
|
||||
->method('recordActivity')
|
||||
->with(42, 'import', $this->stringContains('Zaimportowano'));
|
||||
|
||||
$result = $this->service->importSingleOrder($checkoutFormId);
|
||||
|
||||
$this->assertSame(42, $result['order_id']);
|
||||
$this->assertTrue($result['created']);
|
||||
$this->assertSame($checkoutFormId, $result['source_order_id']);
|
||||
}
|
||||
|
||||
public function testImportSingleOrderRetryOn401(): void
|
||||
{
|
||||
$checkoutFormId = 'retry-456';
|
||||
$payload = $this->buildMinimalPayload($checkoutFormId);
|
||||
|
||||
$this->tokenManager
|
||||
->method('resolveToken')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
['stale-token', 'sandbox'],
|
||||
['fresh-token', 'sandbox']
|
||||
);
|
||||
|
||||
$callCount = 0;
|
||||
$this->apiClient
|
||||
->method('getCheckoutForm')
|
||||
->willReturnCallback(function () use (&$callCount, $payload): array {
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
throw new RuntimeException('ALLEGRO_HTTP_401');
|
||||
}
|
||||
return $payload;
|
||||
});
|
||||
|
||||
$this->apiClient->method('getCheckoutFormShipments')->willReturn([]);
|
||||
$this->integrationRepository->method('getActiveIntegrationId')->willReturn(1);
|
||||
$this->statusMappings->method('findMappedOrderproStatusCode')->willReturn(null);
|
||||
|
||||
$this->orders
|
||||
->expects($this->once())
|
||||
->method('upsertOrderAggregate')
|
||||
->willReturn(['order_id' => 99, 'created' => true]);
|
||||
|
||||
$result = $this->service->importSingleOrder($checkoutFormId);
|
||||
|
||||
$this->assertSame(99, $result['order_id']);
|
||||
$this->assertSame(2, $callCount);
|
||||
}
|
||||
|
||||
public function testImportSingleOrderThrowsOnEmptyId(): void
|
||||
{
|
||||
$this->expectException(AllegroApiException::class);
|
||||
$this->expectExceptionMessage('Podaj ID zamowienia');
|
||||
|
||||
$this->service->importSingleOrder('');
|
||||
}
|
||||
|
||||
public function testImportSingleOrderPropagatesNon401RuntimeException(): void
|
||||
{
|
||||
$this->tokenManager
|
||||
->method('resolveToken')
|
||||
->willReturn(['token', 'sandbox']);
|
||||
|
||||
$this->apiClient
|
||||
->method('getCheckoutForm')
|
||||
->willThrowException(new RuntimeException('ALLEGRO_HTTP_500'));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('ALLEGRO_HTTP_500');
|
||||
|
||||
$this->service->importSingleOrder('order-789');
|
||||
}
|
||||
|
||||
public function testImportSingleOrderReturnsCorrectFieldsOnReImport(): void
|
||||
{
|
||||
$checkoutFormId = 'reimport-001';
|
||||
$payload = $this->buildMinimalPayload($checkoutFormId);
|
||||
|
||||
$this->tokenManager->method('resolveToken')->willReturn(['tok', 'production']);
|
||||
$this->apiClient->method('getCheckoutForm')->willReturn($payload);
|
||||
$this->apiClient->method('getCheckoutFormShipments')->willReturn([]);
|
||||
$this->integrationRepository->method('getActiveIntegrationId')->willReturn(1);
|
||||
$this->statusMappings->method('findMappedOrderproStatusCode')->willReturn(null);
|
||||
|
||||
$this->orders
|
||||
->method('upsertOrderAggregate')
|
||||
->willReturn(['order_id' => 10, 'created' => false]);
|
||||
|
||||
$this->ordersRepository
|
||||
->expects($this->once())
|
||||
->method('recordActivity')
|
||||
->with(10, 'import', $this->stringContains('Zaktualizowano'));
|
||||
|
||||
$result = $this->service->importSingleOrder($checkoutFormId);
|
||||
|
||||
$this->assertSame(10, $result['order_id']);
|
||||
$this->assertFalse($result['created']);
|
||||
$this->assertArrayHasKey('image_diagnostics', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildMinimalPayload(string $checkoutFormId): array
|
||||
{
|
||||
return [
|
||||
'id' => $checkoutFormId,
|
||||
'status' => 'READY_FOR_PROCESSING',
|
||||
'fulfillment' => ['status' => 'NEW'],
|
||||
'payment' => [
|
||||
'id' => 'pay-1',
|
||||
'type' => 'allegro',
|
||||
'status' => 'paid',
|
||||
'amount' => ['amount' => '99.00', 'currency' => 'PLN'],
|
||||
'paidAmount' => ['amount' => '99.00', 'currency' => 'PLN'],
|
||||
],
|
||||
'summary' => [
|
||||
'totalToPay' => ['amount' => '99.00', 'currency' => 'PLN'],
|
||||
],
|
||||
'buyer' => [
|
||||
'login' => 'test-buyer',
|
||||
'firstName' => 'Jan',
|
||||
'lastName' => 'Kowalski',
|
||||
'email' => 'jan@example.com',
|
||||
'phoneNumber' => '500100200',
|
||||
],
|
||||
'delivery' => [
|
||||
'method' => ['id' => 'inpost', 'name' => 'InPost Paczkomaty'],
|
||||
'address' => [
|
||||
'firstName' => 'Jan',
|
||||
'lastName' => 'Kowalski',
|
||||
'street' => 'ul. Testowa 1',
|
||||
'city' => 'Warszawa',
|
||||
'zipCode' => '00-001',
|
||||
'countryCode' => 'PL',
|
||||
],
|
||||
],
|
||||
'lineItems' => [
|
||||
[
|
||||
'id' => 'item-1',
|
||||
'offer' => ['id' => 'offer-1', 'name' => 'Produkt testowy'],
|
||||
'quantity' => 2,
|
||||
'originalPrice' => ['amount' => '49.50', 'currency' => 'PLN'],
|
||||
],
|
||||
],
|
||||
'invoice' => [],
|
||||
'marketplace' => ['id' => 'allegro-pl'],
|
||||
'boughtAt' => '2026-03-14T10:00:00Z',
|
||||
'updatedAt' => '2026-03-14T10:05:00Z',
|
||||
];
|
||||
}
|
||||
}
|
||||
267
tests/Unit/AllegroTokenManagerTest.php
Normal file
267
tests/Unit/AllegroTokenManagerTest.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Core\Exceptions\AllegroOAuthException;
|
||||
use App\Modules\Settings\AllegroIntegrationRepository;
|
||||
use App\Modules\Settings\AllegroOAuthClient;
|
||||
use App\Modules\Settings\AllegroTokenManager;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AllegroTokenManagerTest extends TestCase
|
||||
{
|
||||
private AllegroIntegrationRepository&MockObject $repository;
|
||||
private AllegroOAuthClient&MockObject $oauthClient;
|
||||
private AllegroTokenManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(AllegroIntegrationRepository::class);
|
||||
$this->oauthClient = $this->createMock(AllegroOAuthClient::class);
|
||||
$this->manager = new AllegroTokenManager($this->repository, $this->oauthClient);
|
||||
}
|
||||
|
||||
public function testResolveTokenReturnsCachedTokenWhenFresh(): void
|
||||
{
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->add(new DateInterval('PT30M'))
|
||||
->format('Y-m-d H:i:s');
|
||||
|
||||
$this->repository
|
||||
->method('getTokenCredentials')
|
||||
->willReturn([
|
||||
'environment' => 'sandbox',
|
||||
'client_id' => 'cid',
|
||||
'client_secret' => 'cs',
|
||||
'refresh_token' => 'rt',
|
||||
'access_token' => 'fresh-token',
|
||||
'token_expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
$this->oauthClient
|
||||
->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->manager->resolveToken();
|
||||
|
||||
$this->assertSame('fresh-token', $result[0]);
|
||||
$this->assertSame('sandbox', $result[1]);
|
||||
}
|
||||
|
||||
public function testResolveTokenRefreshesWhenExpiresWithinFiveMinutes(): void
|
||||
{
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->add(new DateInterval('PT3M'))
|
||||
->format('Y-m-d H:i:s');
|
||||
|
||||
$credentials = [
|
||||
'environment' => 'production',
|
||||
'client_id' => 'cid',
|
||||
'client_secret' => 'cs',
|
||||
'refresh_token' => 'rt',
|
||||
'access_token' => 'old-token',
|
||||
'token_expires_at' => $expiresAt,
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->method('getTokenCredentials')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$credentials,
|
||||
$credentials,
|
||||
array_merge($credentials, ['access_token' => 'new-token'])
|
||||
);
|
||||
|
||||
$this->oauthClient
|
||||
->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('production', 'cid', 'cs', 'rt')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-rt',
|
||||
'token_type' => 'Bearer',
|
||||
'scope' => '',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('saveTokens');
|
||||
|
||||
$result = $this->manager->resolveToken();
|
||||
|
||||
$this->assertSame('new-token', $result[0]);
|
||||
$this->assertSame('production', $result[1]);
|
||||
}
|
||||
|
||||
public function testResolveTokenRefreshesWhenAlreadyExpired(): void
|
||||
{
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->sub(new DateInterval('PT10M'))
|
||||
->format('Y-m-d H:i:s');
|
||||
|
||||
$credentials = [
|
||||
'environment' => 'sandbox',
|
||||
'client_id' => 'cid',
|
||||
'client_secret' => 'cs',
|
||||
'refresh_token' => 'rt',
|
||||
'access_token' => 'expired-token',
|
||||
'token_expires_at' => $expiresAt,
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->method('getTokenCredentials')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$credentials,
|
||||
$credentials,
|
||||
array_merge($credentials, ['access_token' => 'refreshed-token'])
|
||||
);
|
||||
|
||||
$this->oauthClient
|
||||
->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'refreshed-token',
|
||||
'refresh_token' => 'rt',
|
||||
'token_type' => 'Bearer',
|
||||
'scope' => '',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->repository->expects($this->once())->method('saveTokens');
|
||||
|
||||
$result = $this->manager->resolveToken();
|
||||
|
||||
$this->assertSame('refreshed-token', $result[0]);
|
||||
}
|
||||
|
||||
public function testResolveTokenThrowsWhenNoOAuthConfig(): void
|
||||
{
|
||||
$this->repository
|
||||
->method('getTokenCredentials')
|
||||
->willReturn(null);
|
||||
|
||||
$this->expectException(AllegroOAuthException::class);
|
||||
$this->expectExceptionMessage('Brak polaczenia OAuth Allegro');
|
||||
|
||||
$this->manager->resolveToken();
|
||||
}
|
||||
|
||||
public function testResolveTokenRefreshesWhenAccessTokenEmpty(): void
|
||||
{
|
||||
$credentials = [
|
||||
'environment' => 'sandbox',
|
||||
'client_id' => 'cid',
|
||||
'client_secret' => 'cs',
|
||||
'refresh_token' => 'rt',
|
||||
'access_token' => '',
|
||||
'token_expires_at' => '',
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->method('getTokenCredentials')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$credentials,
|
||||
$credentials,
|
||||
array_merge($credentials, ['access_token' => 'brand-new'])
|
||||
);
|
||||
|
||||
$this->oauthClient
|
||||
->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'brand-new',
|
||||
'refresh_token' => 'rt',
|
||||
'token_type' => 'Bearer',
|
||||
'scope' => '',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->repository->expects($this->once())->method('saveTokens');
|
||||
|
||||
$result = $this->manager->resolveToken();
|
||||
|
||||
$this->assertSame('brand-new', $result[0]);
|
||||
}
|
||||
|
||||
public function testForceRefreshReReadsTokenFromRepository(): void
|
||||
{
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->sub(new DateInterval('PT1M'))
|
||||
->format('Y-m-d H:i:s');
|
||||
|
||||
$credentials = [
|
||||
'environment' => 'sandbox',
|
||||
'client_id' => 'cid',
|
||||
'client_secret' => 'cs',
|
||||
'refresh_token' => 'rt',
|
||||
'access_token' => 'stale',
|
||||
'token_expires_at' => $expiresAt,
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->expects($this->exactly(3))
|
||||
->method('getTokenCredentials')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$credentials,
|
||||
$credentials,
|
||||
array_merge($credentials, ['access_token' => 'from-db'])
|
||||
);
|
||||
|
||||
$this->oauthClient
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'api-returned',
|
||||
'refresh_token' => 'rt',
|
||||
'token_type' => 'Bearer',
|
||||
'scope' => '',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->repository->expects($this->once())->method('saveTokens');
|
||||
|
||||
$result = $this->manager->resolveToken();
|
||||
|
||||
// Token comes from re-read (3rd call), not directly from API response
|
||||
$this->assertSame('from-db', $result[0]);
|
||||
}
|
||||
|
||||
public function testResolveTokenRefreshesWhenExpiresAtInvalidFormat(): void
|
||||
{
|
||||
$credentials = [
|
||||
'environment' => 'sandbox',
|
||||
'client_id' => 'cid',
|
||||
'client_secret' => 'cs',
|
||||
'refresh_token' => 'rt',
|
||||
'access_token' => 'some-token',
|
||||
'token_expires_at' => 'not-a-date',
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->method('getTokenCredentials')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$credentials,
|
||||
$credentials,
|
||||
array_merge($credentials, ['access_token' => 'refreshed'])
|
||||
);
|
||||
|
||||
$this->oauthClient
|
||||
->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'refreshed',
|
||||
'refresh_token' => 'rt',
|
||||
'token_type' => 'Bearer',
|
||||
'scope' => '',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->repository->expects($this->once())->method('saveTokens');
|
||||
|
||||
$result = $this->manager->resolveToken();
|
||||
$this->assertSame('refreshed', $result[0]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ $autoload = $basePath . '/vendor/autoload.php';
|
||||
|
||||
if (is_file($autoload)) {
|
||||
require $autoload;
|
||||
DG\BypassFinals::enable();
|
||||
} else {
|
||||
spl_autoload_register(static function (string $class) use ($basePath): void {
|
||||
$prefixes = [
|
||||
|
||||
Reference in New Issue
Block a user