diff --git a/CLAUDE.md b/CLAUDE.md index 80862f2..3415a48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ composer test # standard PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`. -Current suite: **817 tests, 2271 assertions**. +Current suite: **818 tests, 2275 assertions**. ### Creating Updates See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. ZIP structure must start directly from project directories — no version subfolder inside the archive. diff --git a/autoload/Domain/Integrations/ApiloRepository.php b/autoload/Domain/Integrations/ApiloRepository.php new file mode 100644 index 0000000..1bcaf85 --- /dev/null +++ b/autoload/Domain/Integrations/ApiloRepository.php @@ -0,0 +1,567 @@ + 'https://projectpro.apilo.com/rest/api/orders/platform/map/', + 'status' => 'https://projectpro.apilo.com/rest/api/orders/status/map/', + 'carrier' => 'https://projectpro.apilo.com/rest/api/orders/carrier-account/map/', + 'payment' => 'https://projectpro.apilo.com/rest/api/orders/payment/map/', + ]; + + private const APILO_SETTINGS_KEYS = [ + 'platform' => 'platform-list', + 'status' => 'status-types-list', + 'carrier' => 'carrier-account-list', + 'payment' => 'payment-types-list', + ]; + + public function __construct( $db ) + { + $this->db = $db; + } + + // ── Settings access (Apilo-specific) ──────────────────────── + + private function getApiloSettings(): array + { + $rows = $this->db->select( self::SETTINGS_TABLE, [ 'name', 'value' ] ); + $settings = []; + foreach ( $rows ?: [] as $row ) + $settings[$row['name']] = $row['value']; + + return $settings; + } + + private function saveApiloSetting( string $name, $value ): void + { + if ( $this->db->count( self::SETTINGS_TABLE, [ 'name' => $name ] ) ) { + $this->db->update( self::SETTINGS_TABLE, [ 'value' => $value ], [ 'name' => $name ] ); + } else { + $this->db->insert( self::SETTINGS_TABLE, [ 'name' => $name, 'value' => $value ] ); + } + \Shared\Helpers\Helpers::delete_dir( '../temp/' ); + } + + // ── Apilo OAuth ───────────────────────────────────────────── + + public function apiloAuthorize( string $clientId, string $clientSecret, string $authCode ): bool + { + $postData = [ + 'grantType' => 'authorization_code', + 'token' => $authCode, + ]; + + $ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ + "Authorization: Basic " . base64_encode( $clientId . ":" . $clientSecret ), + "Accept: application/json" + ] ); + + $response = curl_exec( $ch ); + if ( curl_errno( $ch ) ) { + curl_close( $ch ); + return false; + } + curl_close( $ch ); + $response = json_decode( $response, true ); + + if ( empty( $response['accessToken'] ) ) + return false; + + try { + $this->saveApiloSetting( 'access-token', $response['accessToken'] ); + $this->saveApiloSetting( 'refresh-token', $response['refreshToken'] ); + $this->saveApiloSetting( 'access-token-expire-at', $response['accessTokenExpireAt'] ); + $this->saveApiloSetting( 'refresh-token-expire-at', $response['refreshTokenExpireAt'] ); + } catch ( \Exception $e ) { + error_log( '[shopPRO] Apilo: błąd zapisu tokenów: ' . $e->getMessage() ); + return false; + } + + return true; + } + + public function apiloGetAccessToken( int $refreshLeadSeconds = 300 ): ?string + { + $settings = $this->getApiloSettings(); + + $hasRefreshCredentials = !empty( $settings['refresh-token'] ) + && !empty( $settings['client-id'] ) + && !empty( $settings['client-secret'] ); + + $accessToken = trim( (string)($settings['access-token'] ?? '') ); + $accessTokenExpireAt = trim( (string)($settings['access-token-expire-at'] ?? '') ); + + if ( $accessToken !== '' && $accessTokenExpireAt !== '' ) { + if ( !$this->shouldRefreshAccessToken( $accessTokenExpireAt, $refreshLeadSeconds ) ) { + return $accessToken; + } + } + + if ( !$hasRefreshCredentials ) { + return null; + } + + if ( + !empty( $settings['refresh-token-expire-at'] ) && + !$this->isFutureDate( (string)$settings['refresh-token-expire-at'] ) + ) { + return null; + } + + return $this->refreshApiloAccessToken( $settings ); + } + + /** + * Keepalive tokenu Apilo do uzycia w CRON. + * Odswieza token, gdy wygasa lub jest bliski wygasniecia. + * + * @return array{success:bool,skipped:bool,message:string} + */ + public function apiloKeepalive( int $refreshLeadSeconds = 300 ): array + { + $settings = $this->getApiloSettings(); + + if ( (int)($settings['enabled'] ?? 0) !== 1 ) { + return [ + 'success' => false, + 'skipped' => true, + 'message' => 'Apilo disabled.', + ]; + } + + if ( empty( $settings['client-id'] ) || empty( $settings['client-secret'] ) ) { + return [ + 'success' => false, + 'skipped' => true, + 'message' => 'Missing Apilo credentials.', + ]; + } + + $token = $this->apiloGetAccessToken( $refreshLeadSeconds ); + if ( !$token ) { + return [ + 'success' => false, + 'skipped' => false, + 'message' => 'Unable to refresh Apilo token.', + ]; + } + + $this->saveApiloSetting( 'token-keepalive-at', date( 'Y-m-d H:i:s' ) ); + + return [ + 'success' => true, + 'skipped' => false, + 'message' => 'Apilo token keepalive OK.', + ]; + } + + private function refreshApiloAccessToken( array $settings ): ?string + { + $postData = [ + 'grantType' => 'refresh_token', + 'token' => $settings['refresh-token'], + ]; + + $ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ + "Authorization: Basic " . base64_encode( $settings['client-id'] . ":" . $settings['client-secret'] ), + "Accept: application/json" + ] ); + curl_setopt( $ch, CURLOPT_POST, true ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) ); + curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + + $response = curl_exec( $ch ); + if ( curl_errno( $ch ) ) { + curl_close( $ch ); + return null; + } + curl_close( $ch ); + $response = json_decode( $response, true ); + + if ( empty( $response['accessToken'] ) ) { + return null; + } + + $this->saveApiloSetting( 'access-token', $response['accessToken'] ); + $this->saveApiloSetting( 'refresh-token', $response['refreshToken'] ?? ( $settings['refresh-token'] ?? '' ) ); + $this->saveApiloSetting( 'access-token-expire-at', $response['accessTokenExpireAt'] ?? null ); + $this->saveApiloSetting( 'refresh-token-expire-at', $response['refreshTokenExpireAt'] ?? null ); + + return $response['accessToken']; + } + + private function shouldRefreshAccessToken( string $expiresAtRaw, int $leadSeconds = 300 ): bool + { + try { + $expiresAt = new \DateTime( $expiresAtRaw ); + } catch ( \Exception $e ) { + return true; + } + + $threshold = new \DateTime( date( 'Y-m-d H:i:s', time() + max( 0, $leadSeconds ) ) ); + return $expiresAt <= $threshold; + } + + private function isFutureDate( string $dateRaw ): bool + { + try { + $date = new \DateTime( $dateRaw ); + } catch ( \Exception $e ) { + return false; + } + + return $date > new \DateTime( date( 'Y-m-d H:i:s' ) ); + } + + /** + * Sprawdza aktualny stan integracji Apilo i zwraca komunikat dla UI. + * + * @return array{is_valid:bool,severity:string,message:string} + */ + public function apiloIntegrationStatus(): array + { + $settings = $this->getApiloSettings(); + + $missing = []; + foreach ( [ 'client-id', 'client-secret' ] as $field ) { + if ( trim( (string)($settings[$field] ?? '') ) === '' ) + $missing[] = $field; + } + + if ( !empty( $missing ) ) { + return [ + 'is_valid' => false, + 'severity' => 'danger', + 'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missing ) . '.', + ]; + } + + $accessToken = trim( (string)($settings['access-token'] ?? '') ); + $authorizationCode = trim( (string)($settings['authorization-code'] ?? '') ); + + if ( $accessToken === '' ) { + if ( $authorizationCode === '' ) { + return [ + 'is_valid' => false, + 'severity' => 'warning', + 'message' => 'Brak authorization-code i access-token. Wpisz kod autoryzacji i uruchom autoryzacje.', + ]; + } + + return [ + 'is_valid' => false, + 'severity' => 'warning', + 'message' => 'Brak access-token. Uruchom autoryzacje Apilo.', + ]; + } + + $token = $this->apiloGetAccessToken(); + if ( !$token ) { + return [ + 'is_valid' => false, + 'severity' => 'danger', + 'message' => 'Token Apilo jest niewazny lub wygasl i nie udal sie refresh. Wykonaj ponowna autoryzacje.', + ]; + } + + $expiresAt = trim( (string)($settings['access-token-expire-at'] ?? '') ); + $suffix = $expiresAt !== '' ? ( ' Token wazny do: ' . $expiresAt . '.' ) : ''; + + return [ + 'is_valid' => true, + 'severity' => 'success', + 'message' => 'Integracja Apilo jest aktywna.' . $suffix, + ]; + } + + // ── Apilo API fetch lists ─────────────────────────────────── + + /** + * Fetch list from Apilo API and save to settings. + * @param string $type platform|status|carrier|payment + */ + public function apiloFetchList( string $type ): bool + { + $result = $this->apiloFetchListResult( $type ); + return !empty( $result['success'] ); + } + + /** + * Fetch list from Apilo API and return detailed status for UI. + * + * @param string $type platform|status|carrier|payment + * @return array{success:bool,count:int,message:string} + */ + public function apiloFetchListResult( string $type ): array + { + if ( !isset( self::APILO_ENDPOINTS[$type] ) ) + throw new \InvalidArgumentException( "Unknown apilo list type: $type" ); + + $settings = $this->getApiloSettings(); + $missingFields = []; + foreach ( [ 'client-id', 'client-secret' ] as $requiredField ) { + if ( trim( (string)($settings[$requiredField] ?? '') ) === '' ) + $missingFields[] = $requiredField; + } + + if ( !empty( $missingFields ) ) { + return [ + 'success' => false, + 'count' => 0, + 'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missingFields ) . '. Uzupelnij pola i zapisz ustawienia.', + ]; + } + + $accessToken = $this->apiloGetAccessToken(); + if ( !$accessToken ) { + return [ + 'success' => false, + 'count' => 0, + 'message' => 'Brak aktywnego tokenu Apilo. Wykonaj autoryzacje Apilo i sprobuj ponownie.', + ]; + } + + $ch = curl_init( self::APILO_ENDPOINTS[$type] ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ + "Authorization: Bearer " . $accessToken, + "Accept: application/json" + ] ); + + $response = curl_exec( $ch ); + if ( curl_errno( $ch ) ) { + $error = curl_error( $ch ); + curl_close( $ch ); + return [ + 'success' => false, + 'count' => 0, + 'message' => 'Blad polaczenia z Apilo: ' . $error . '. Sprawdz polaczenie serwera i sprobuj ponownie.', + ]; + } + $httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + + $data = json_decode( $response, true ); + if ( !is_array( $data ) ) { + $responsePreview = substr( trim( (string)$response ), 0, 180 ); + if ( $responsePreview === '' ) + $responsePreview = '[pusta odpowiedz]'; + + return [ + 'success' => false, + 'count' => 0, + 'message' => 'Apilo zwrocilo niepoprawny format odpowiedzi (HTTP ' . $httpCode . '). Odpowiedz: ' . $responsePreview, + ]; + } + + if ( $httpCode >= 400 ) { + return [ + 'success' => false, + 'count' => 0, + 'message' => 'Apilo zwrocilo blad HTTP ' . $httpCode . ': ' . $this->extractApiloErrorMessage( $data ), + ]; + } + + $normalizedList = $this->normalizeApiloMapList( $data ); + if ( $normalizedList === null ) { + return [ + 'success' => false, + 'count' => 0, + 'message' => 'Apilo zwrocilo dane w nieoczekiwanym formacie. Odswiez token i sproboj ponownie.', + ]; + } + + $this->saveApiloSetting( self::APILO_SETTINGS_KEYS[$type], $normalizedList ); + return [ + 'success' => true, + 'count' => count( $normalizedList ), + 'message' => 'OK', + ]; + } + + /** + * Normalizuje odpowiedz API mapowania do listy rekordow ['id' => ..., 'name' => ...]. + * Zwraca null dla payloadu bledow lub nieoczekiwanego formatu. + * + * @return array|null + */ + private function normalizeApiloMapList( array $data ): ?array + { + if ( isset( $data['message'] ) && isset( $data['code'] ) ) + return null; + + if ( $this->isMapListShape( $data ) ) + return $data; + + if ( isset( $data['items'] ) && is_array( $data['items'] ) && $this->isMapListShape( $data['items'] ) ) + return $data['items']; + + if ( isset( $data['data'] ) && is_array( $data['data'] ) && $this->isMapListShape( $data['data'] ) ) + return $data['data']; + + // Dopuszczamy rowniez format asocjacyjny: [id => name, ...], ale tylko dla kluczy liczbowych. + if ( !empty( $data ) ) { + $normalized = []; + foreach ( $data as $key => $value ) { + if ( !( is_int( $key ) || ( is_string( $key ) && preg_match('/^-?\d+$/', $key) === 1 ) ) ) + return null; + + if ( !is_scalar( $value ) ) + return null; + + $normalized[] = [ + 'id' => $key, + 'name' => (string) $value, + ]; + } + + return !empty( $normalized ) ? $normalized : null; + } + + return null; + } + + private function isMapListShape( array $list ): bool + { + if ( empty( $list ) ) + return false; + + foreach ( $list as $row ) { + if ( !is_array( $row ) || !array_key_exists( 'id', $row ) || !array_key_exists( 'name', $row ) ) + return false; + } + + return true; + } + + private function extractApiloErrorMessage( array $data ): string + { + foreach ( [ 'message', 'error', 'detail', 'title' ] as $key ) { + if ( isset( $data[$key] ) && is_scalar( $data[$key] ) ) { + $message = trim( (string)$data[$key] ); + if ( $message !== '' ) + return $message; + } + } + + if ( isset( $data['errors'] ) ) { + if ( is_array( $data['errors'] ) ) { + $flat = []; + foreach ( $data['errors'] as $errorItem ) { + if ( is_scalar( $errorItem ) ) + $flat[] = (string)$errorItem; + elseif ( is_array( $errorItem ) ) + $flat[] = json_encode( $errorItem, JSON_UNESCAPED_UNICODE ); + } + + if ( !empty( $flat ) ) + return implode( '; ', $flat ); + } elseif ( is_scalar( $data['errors'] ) ) { + return (string)$data['errors']; + } + } + + return 'Nieznany blad odpowiedzi API.'; + } + + // ── Apilo product operations ──────────────────────────────── + + public function apiloProductSearch( string $sku ): array + { + $accessToken = $this->apiloGetAccessToken(); + if ( !$accessToken ) + return [ 'status' => 'error', 'msg' => 'Brak tokenu Apilo.' ]; + + $url = "https://projectpro.apilo.com/rest/api/warehouse/product/?" . http_build_query( [ 'sku' => $sku ] ); + + $ch = curl_init( $url ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ + "Authorization: Bearer " . $accessToken, + "Accept: application/json" + ] ); + + $response = curl_exec( $ch ); + if ( curl_errno( $ch ) ) { + $error = curl_error( $ch ); + curl_close( $ch ); + return [ 'status' => 'error', 'msg' => 'Błąd cURL: ' . $error ]; + } + curl_close( $ch ); + + $data = json_decode( $response, true ); + if ( $data && isset( $data['products'] ) ) { + $data['status'] = 'SUCCESS'; + return $data; + } + + return [ 'status' => 'SUCCESS', 'msg' => 'Brak wyników dla podanego SKU.', 'products' => '' ]; + } + + public function apiloCreateProduct( int $productId ): array + { + $accessToken = $this->apiloGetAccessToken(); + if ( !$accessToken ) + return [ 'success' => false, 'message' => 'Brak tokenu Apilo.' ]; + + $product = ( new \Domain\Product\ProductRepository( $this->db ) )->findCached( $productId ); + + $params = [ + 'sku' => $product['sku'], + 'ean' => $product['ean'], + 'name' => $product['language']['name'], + 'tax' => (int) $product['vat'], + 'status' => 1, + 'quantity' => (int) $product['quantity'], + 'priceWithTax' => $product['price_brutto'], + 'description' => $product['language']['description'] . '
' . $product['language']['short_description'], + 'shortDescription' => '', + 'images' => [], + ]; + + foreach ( $product['images'] as $image ) + $params['images'][] = "https://" . $_SERVER['HTTP_HOST'] . $image['src']; + + $ch = curl_init( "https://projectpro.apilo.com/rest/api/warehouse/product/" ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( [ $params ] ) ); + curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ + "Authorization: Bearer " . $accessToken, + "Content-Type: application/json", + "Accept: application/json" + ] ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + $response = curl_exec( $ch ); + $responseData = json_decode( $response, true ); + + if ( curl_errno( $ch ) ) { + $error = curl_error( $ch ); + curl_close( $ch ); + return [ 'success' => false, 'message' => 'Błąd cURL: ' . $error ]; + } + curl_close( $ch ); + + if ( !empty( $responseData['products'] ) ) { + $this->db->update( 'pp_shop_products', [ + 'apilo_product_id' => reset( $responseData['products'] ), + 'apilo_product_name' => $product['language']['name'], + ], [ 'id' => $product['id'] ] ); + + return [ 'success' => true, 'message' => 'Produkt został dodany do magazynu APILO.' ]; + } + + return [ 'success' => false, 'message' => 'Podczas dodawania produktu wystąpił błąd.' ]; + } +} diff --git a/autoload/Domain/Integrations/IntegrationsRepository.php b/autoload/Domain/Integrations/IntegrationsRepository.php index 8b9f478..a2f9fee 100644 --- a/autoload/Domain/Integrations/IntegrationsRepository.php +++ b/autoload/Domain/Integrations/IntegrationsRepository.php @@ -130,449 +130,7 @@ class IntegrationsRepository ], [ 'id' => $productId ] ); } - // ── Apilo OAuth ───────────────────────────────────────────── - - public function apiloAuthorize( string $clientId, string $clientSecret, string $authCode ): bool - { - $postData = [ - 'grantType' => 'authorization_code', - 'token' => $authCode, - ]; - - $ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); - curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "Authorization: Basic " . base64_encode( $clientId . ":" . $clientSecret ), - "Accept: application/json" - ] ); - - $response = curl_exec( $ch ); - if ( curl_errno( $ch ) ) { - curl_close( $ch ); - return false; - } - curl_close( $ch ); - $response = json_decode( $response, true ); - - if ( empty( $response['accessToken'] ) ) - return false; - - try { - $this->saveSetting( 'apilo', 'access-token', $response['accessToken'] ); - $this->saveSetting( 'apilo', 'refresh-token', $response['refreshToken'] ); - $this->saveSetting( 'apilo', 'access-token-expire-at', $response['accessTokenExpireAt'] ); - $this->saveSetting( 'apilo', 'refresh-token-expire-at', $response['refreshTokenExpireAt'] ); - } catch ( \Exception $e ) { - error_log( '[shopPRO] Apilo: błąd zapisu tokenów: ' . $e->getMessage() ); - return false; - } - - return true; - } - - public function apiloGetAccessToken( int $refreshLeadSeconds = 300 ): ?string - { - $settings = $this->getSettings( 'apilo' ); - - $hasRefreshCredentials = !empty( $settings['refresh-token'] ) - && !empty( $settings['client-id'] ) - && !empty( $settings['client-secret'] ); - - $accessToken = trim( (string)($settings['access-token'] ?? '') ); - $accessTokenExpireAt = trim( (string)($settings['access-token-expire-at'] ?? '') ); - - if ( $accessToken !== '' && $accessTokenExpireAt !== '' ) { - if ( !$this->shouldRefreshAccessToken( $accessTokenExpireAt, $refreshLeadSeconds ) ) { - return $accessToken; - } - } - - if ( !$hasRefreshCredentials ) { - return null; - } - - if ( - !empty( $settings['refresh-token-expire-at'] ) && - !$this->isFutureDate( (string)$settings['refresh-token-expire-at'] ) - ) { - return null; - } - - return $this->refreshApiloAccessToken( $settings ); - } - - /** - * Keepalive tokenu Apilo do uzycia w CRON. - * Odswieza token, gdy wygasa lub jest bliski wygasniecia. - * - * @return array{success:bool,skipped:bool,message:string} - */ - public function apiloKeepalive( int $refreshLeadSeconds = 300 ): array - { - $settings = $this->getSettings( 'apilo' ); - - if ( (int)($settings['enabled'] ?? 0) !== 1 ) { - return [ - 'success' => false, - 'skipped' => true, - 'message' => 'Apilo disabled.', - ]; - } - - if ( empty( $settings['client-id'] ) || empty( $settings['client-secret'] ) ) { - return [ - 'success' => false, - 'skipped' => true, - 'message' => 'Missing Apilo credentials.', - ]; - } - - $token = $this->apiloGetAccessToken( $refreshLeadSeconds ); - if ( !$token ) { - return [ - 'success' => false, - 'skipped' => false, - 'message' => 'Unable to refresh Apilo token.', - ]; - } - - $this->saveSetting( 'apilo', 'token-keepalive-at', date( 'Y-m-d H:i:s' ) ); - - return [ - 'success' => true, - 'skipped' => false, - 'message' => 'Apilo token keepalive OK.', - ]; - } - - private function refreshApiloAccessToken( array $settings ): ?string - { - $postData = [ - 'grantType' => 'refresh_token', - 'token' => $settings['refresh-token'], - ]; - - $ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "Authorization: Basic " . base64_encode( $settings['client-id'] . ":" . $settings['client-secret'] ), - "Accept: application/json" - ] ); - curl_setopt( $ch, CURLOPT_POST, true ); - curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) ); - curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - - $response = curl_exec( $ch ); - if ( curl_errno( $ch ) ) { - curl_close( $ch ); - return null; - } - curl_close( $ch ); - $response = json_decode( $response, true ); - - if ( empty( $response['accessToken'] ) ) { - return null; - } - - $this->saveSetting( 'apilo', 'access-token', $response['accessToken'] ); - $this->saveSetting( 'apilo', 'refresh-token', $response['refreshToken'] ?? ( $settings['refresh-token'] ?? '' ) ); - $this->saveSetting( 'apilo', 'access-token-expire-at', $response['accessTokenExpireAt'] ?? null ); - $this->saveSetting( 'apilo', 'refresh-token-expire-at', $response['refreshTokenExpireAt'] ?? null ); - - return $response['accessToken']; - } - - private function shouldRefreshAccessToken( string $expiresAtRaw, int $leadSeconds = 300 ): bool - { - try { - $expiresAt = new \DateTime( $expiresAtRaw ); - } catch ( \Exception $e ) { - return true; - } - - $threshold = new \DateTime( date( 'Y-m-d H:i:s', time() + max( 0, $leadSeconds ) ) ); - return $expiresAt <= $threshold; - } - - private function isFutureDate( string $dateRaw ): bool - { - try { - $date = new \DateTime( $dateRaw ); - } catch ( \Exception $e ) { - return false; - } - - return $date > new \DateTime( date( 'Y-m-d H:i:s' ) ); - } - - /** - * Sprawdza aktualny stan integracji Apilo i zwraca komunikat dla UI. - * - * @return array{is_valid:bool,severity:string,message:string} - */ - public function apiloIntegrationStatus(): array - { - $settings = $this->getSettings( 'apilo' ); - - $missing = []; - foreach ( [ 'client-id', 'client-secret' ] as $field ) { - if ( trim( (string)($settings[$field] ?? '') ) === '' ) - $missing[] = $field; - } - - if ( !empty( $missing ) ) { - return [ - 'is_valid' => false, - 'severity' => 'danger', - 'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missing ) . '.', - ]; - } - - $accessToken = trim( (string)($settings['access-token'] ?? '') ); - $authorizationCode = trim( (string)($settings['authorization-code'] ?? '') ); - - if ( $accessToken === '' ) { - if ( $authorizationCode === '' ) { - return [ - 'is_valid' => false, - 'severity' => 'warning', - 'message' => 'Brak authorization-code i access-token. Wpisz kod autoryzacji i uruchom autoryzacje.', - ]; - } - - return [ - 'is_valid' => false, - 'severity' => 'warning', - 'message' => 'Brak access-token. Uruchom autoryzacje Apilo.', - ]; - } - - $token = $this->apiloGetAccessToken(); - if ( !$token ) { - return [ - 'is_valid' => false, - 'severity' => 'danger', - 'message' => 'Token Apilo jest niewazny lub wygasl i nie udal sie refresh. Wykonaj ponowna autoryzacje.', - ]; - } - - $expiresAt = trim( (string)($settings['access-token-expire-at'] ?? '') ); - $suffix = $expiresAt !== '' ? ( ' Token wazny do: ' . $expiresAt . '.' ) : ''; - - return [ - 'is_valid' => true, - 'severity' => 'success', - 'message' => 'Integracja Apilo jest aktywna.' . $suffix, - ]; - } - - // ── Apilo API fetch lists ─────────────────────────────────── - - private const APILO_ENDPOINTS = [ - 'platform' => 'https://projectpro.apilo.com/rest/api/orders/platform/map/', - 'status' => 'https://projectpro.apilo.com/rest/api/orders/status/map/', - 'carrier' => 'https://projectpro.apilo.com/rest/api/orders/carrier-account/map/', - 'payment' => 'https://projectpro.apilo.com/rest/api/orders/payment/map/', - ]; - - private const APILO_SETTINGS_KEYS = [ - 'platform' => 'platform-list', - 'status' => 'status-types-list', - 'carrier' => 'carrier-account-list', - 'payment' => 'payment-types-list', - ]; - - /** - * Fetch list from Apilo API and save to settings. - * @param string $type platform|status|carrier|payment - */ - public function apiloFetchList( string $type ): bool - { - $result = $this->apiloFetchListResult( $type ); - return !empty( $result['success'] ); - } - - /** - * Fetch list from Apilo API and return detailed status for UI. - * - * @param string $type platform|status|carrier|payment - * @return array{success:bool,count:int,message:string} - */ - public function apiloFetchListResult( string $type ): array - { - if ( !isset( self::APILO_ENDPOINTS[$type] ) ) - throw new \InvalidArgumentException( "Unknown apilo list type: $type" ); - - $settings = $this->getSettings( 'apilo' ); - $missingFields = []; - foreach ( [ 'client-id', 'client-secret' ] as $requiredField ) { - if ( trim( (string)($settings[$requiredField] ?? '') ) === '' ) - $missingFields[] = $requiredField; - } - - if ( !empty( $missingFields ) ) { - return [ - 'success' => false, - 'count' => 0, - 'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missingFields ) . '. Uzupelnij pola i zapisz ustawienia.', - ]; - } - - $accessToken = $this->apiloGetAccessToken(); - if ( !$accessToken ) { - return [ - 'success' => false, - 'count' => 0, - 'message' => 'Brak aktywnego tokenu Apilo. Wykonaj autoryzacje Apilo i sprobuj ponownie.', - ]; - } - - $ch = curl_init( self::APILO_ENDPOINTS[$type] ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "Authorization: Bearer " . $accessToken, - "Accept: application/json" - ] ); - - $response = curl_exec( $ch ); - if ( curl_errno( $ch ) ) { - $error = curl_error( $ch ); - curl_close( $ch ); - return [ - 'success' => false, - 'count' => 0, - 'message' => 'Blad polaczenia z Apilo: ' . $error . '. Sprawdz polaczenie serwera i sprobuj ponownie.', - ]; - } - $httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); - curl_close( $ch ); - - $data = json_decode( $response, true ); - if ( !is_array( $data ) ) { - $responsePreview = substr( trim( (string)$response ), 0, 180 ); - if ( $responsePreview === '' ) - $responsePreview = '[pusta odpowiedz]'; - - return [ - 'success' => false, - 'count' => 0, - 'message' => 'Apilo zwrocilo niepoprawny format odpowiedzi (HTTP ' . $httpCode . '). Odpowiedz: ' . $responsePreview, - ]; - } - - if ( $httpCode >= 400 ) { - return [ - 'success' => false, - 'count' => 0, - 'message' => 'Apilo zwrocilo blad HTTP ' . $httpCode . ': ' . $this->extractApiloErrorMessage( $data ), - ]; - } - - $normalizedList = $this->normalizeApiloMapList( $data ); - if ( $normalizedList === null ) { - return [ - 'success' => false, - 'count' => 0, - 'message' => 'Apilo zwrocilo dane w nieoczekiwanym formacie. Odswiez token i sproboj ponownie.', - ]; - } - - $this->saveSetting( 'apilo', self::APILO_SETTINGS_KEYS[$type], $normalizedList ); - return [ - 'success' => true, - 'count' => count( $normalizedList ), - 'message' => 'OK', - ]; - } - - /** - * Normalizuje odpowiedz API mapowania do listy rekordow ['id' => ..., 'name' => ...]. - * Zwraca null dla payloadu bledow lub nieoczekiwanego formatu. - * - * @return array|null - */ - private function normalizeApiloMapList( array $data ): ?array - { - if ( isset( $data['message'] ) && isset( $data['code'] ) ) - return null; - - if ( $this->isMapListShape( $data ) ) - return $data; - - if ( isset( $data['items'] ) && is_array( $data['items'] ) && $this->isMapListShape( $data['items'] ) ) - return $data['items']; - - if ( isset( $data['data'] ) && is_array( $data['data'] ) && $this->isMapListShape( $data['data'] ) ) - return $data['data']; - - // Dopuszczamy rowniez format asocjacyjny: [id => name, ...], ale tylko dla kluczy liczbowych. - if ( !empty( $data ) ) { - $normalized = []; - foreach ( $data as $key => $value ) { - if ( !( is_int( $key ) || ( is_string( $key ) && preg_match('/^-?\d+$/', $key) === 1 ) ) ) - return null; - - if ( !is_scalar( $value ) ) - return null; - - $normalized[] = [ - 'id' => $key, - 'name' => (string) $value, - ]; - } - - return !empty( $normalized ) ? $normalized : null; - } - - return null; - } - - private function isMapListShape( array $list ): bool - { - if ( empty( $list ) ) - return false; - - foreach ( $list as $row ) { - if ( !is_array( $row ) || !array_key_exists( 'id', $row ) || !array_key_exists( 'name', $row ) ) - return false; - } - - return true; - } - - private function extractApiloErrorMessage( array $data ): string - { - foreach ( [ 'message', 'error', 'detail', 'title' ] as $key ) { - if ( isset( $data[$key] ) && is_scalar( $data[$key] ) ) { - $message = trim( (string)$data[$key] ); - if ( $message !== '' ) - return $message; - } - } - - if ( isset( $data['errors'] ) ) { - if ( is_array( $data['errors'] ) ) { - $flat = []; - foreach ( $data['errors'] as $errorItem ) { - if ( is_scalar( $errorItem ) ) - $flat[] = (string)$errorItem; - elseif ( is_array( $errorItem ) ) - $flat[] = json_encode( $errorItem, JSON_UNESCAPED_UNICODE ); - } - - if ( !empty( $flat ) ) - return implode( '; ', $flat ); - } elseif ( is_scalar( $data['errors'] ) ) { - return (string)$data['errors']; - } - } - - return 'Nieznany blad odpowiedzi API.'; - } - - // ── Apilo product operations ──────────────────────────────── + // ── Product data ───────────────────────────────────────────── public function getProductSku( int $productId ): ?string { @@ -580,93 +138,6 @@ class IntegrationsRepository return $sku ?: null; } - public function apiloProductSearch( string $sku ): array - { - $accessToken = $this->apiloGetAccessToken(); - if ( !$accessToken ) - return [ 'status' => 'error', 'msg' => 'Brak tokenu Apilo.' ]; - - $url = "https://projectpro.apilo.com/rest/api/warehouse/product/?" . http_build_query( [ 'sku' => $sku ] ); - - $ch = curl_init( $url ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "Authorization: Bearer " . $accessToken, - "Accept: application/json" - ] ); - - $response = curl_exec( $ch ); - if ( curl_errno( $ch ) ) { - $error = curl_error( $ch ); - curl_close( $ch ); - return [ 'status' => 'error', 'msg' => 'Błąd cURL: ' . $error ]; - } - curl_close( $ch ); - - $data = json_decode( $response, true ); - if ( $data && isset( $data['products'] ) ) { - $data['status'] = 'SUCCESS'; - return $data; - } - - return [ 'status' => 'SUCCESS', 'msg' => 'Brak wyników dla podanego SKU.', 'products' => '' ]; - } - - public function apiloCreateProduct( int $productId ): array - { - $accessToken = $this->apiloGetAccessToken(); - if ( !$accessToken ) - return [ 'success' => false, 'message' => 'Brak tokenu Apilo.' ]; - - $product = ( new \Domain\Product\ProductRepository( $this->db ) )->findCached( $productId ); - - $params = [ - 'sku' => $product['sku'], - 'ean' => $product['ean'], - 'name' => $product['language']['name'], - 'tax' => (int) $product['vat'], - 'status' => 1, - 'quantity' => (int) $product['quantity'], - 'priceWithTax' => $product['price_brutto'], - 'description' => $product['language']['description'] . '
' . $product['language']['short_description'], - 'shortDescription' => '', - 'images' => [], - ]; - - foreach ( $product['images'] as $image ) - $params['images'][] = "https://" . $_SERVER['HTTP_HOST'] . $image['src']; - - $ch = curl_init( "https://projectpro.apilo.com/rest/api/warehouse/product/" ); - curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( [ $params ] ) ); - curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "Authorization: Bearer " . $accessToken, - "Content-Type: application/json", - "Accept: application/json" - ] ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - $response = curl_exec( $ch ); - $responseData = json_decode( $response, true ); - - if ( curl_errno( $ch ) ) { - $error = curl_error( $ch ); - curl_close( $ch ); - return [ 'success' => false, 'message' => 'Błąd cURL: ' . $error ]; - } - curl_close( $ch ); - - if ( !empty( $responseData['products'] ) ) { - $this->db->update( 'pp_shop_products', [ - 'apilo_product_id' => reset( $responseData['products'] ), - 'apilo_product_name' => $product['language']['name'], - ], [ 'id' => $product['id'] ] ); - - return [ 'success' => true, 'message' => 'Produkt został dodany do magazynu APILO.' ]; - } - - return [ 'success' => false, 'message' => 'Podczas dodawania produktu wystąpił błąd.' ]; - } - // ── ShopPRO import ────────────────────────────────────────── public function shopproImportProduct( int $productId ): array diff --git a/autoload/Domain/Order/OrderAdminService.php b/autoload/Domain/Order/OrderAdminService.php index 405045f..d55784b 100644 --- a/autoload/Domain/Order/OrderAdminService.php +++ b/autoload/Domain/Order/OrderAdminService.php @@ -419,8 +419,8 @@ class OrderAdminService return false; } - $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); - $accessToken = $integrationsRepository -> apiloGetAccessToken(); + $apiloRepository = new \Domain\Integrations\ApiloRepository( $mdb ); + $accessToken = $apiloRepository->apiloGetAccessToken(); if (!$accessToken) { \Domain\Integrations\ApiloLogger::log( $mdb, @@ -675,7 +675,7 @@ class OrderAdminService global $config; $db = $this->orders->getDb(); - $integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db); + $apiloRepository = new \Domain\Integrations\ApiloRepository($db); if (empty($order['apilo_order_id'])) { return true; @@ -687,7 +687,7 @@ class OrderAdminService } $payment_date = new \DateTime($order['date_order']); - $access_token = $integrationsRepository->apiloGetAccessToken(); + $access_token = $apiloRepository->apiloGetAccessToken(); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/payment/'); @@ -742,13 +742,13 @@ class OrderAdminService global $config; $db = $this->orders->getDb(); - $integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db); + $apiloRepository = new \Domain\Integrations\ApiloRepository($db); if (empty($order['apilo_order_id'])) { return true; } - $access_token = $integrationsRepository->apiloGetAccessToken(); + $access_token = $apiloRepository->apiloGetAccessToken(); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/status/'); diff --git a/autoload/admin/App.php b/autoload/admin/App.php index 4c35390..51e8324 100644 --- a/autoload/admin/App.php +++ b/autoload/admin/App.php @@ -383,7 +383,8 @@ class App 'Integrations' => function() { global $mdb; return new \admin\Controllers\IntegrationsController( - new \Domain\Integrations\IntegrationsRepository( $mdb ) + new \Domain\Integrations\IntegrationsRepository( $mdb ), + new \Domain\Integrations\ApiloRepository( $mdb ) ); }, 'ShopStatuses' => function() { diff --git a/autoload/admin/Controllers/IntegrationsController.php b/autoload/admin/Controllers/IntegrationsController.php index e182a90..1b9daf2 100644 --- a/autoload/admin/Controllers/IntegrationsController.php +++ b/autoload/admin/Controllers/IntegrationsController.php @@ -2,15 +2,18 @@ namespace admin\Controllers; use Domain\Integrations\IntegrationsRepository; +use Domain\Integrations\ApiloRepository; use admin\ViewModels\Common\PaginatedTableViewModel; class IntegrationsController { private IntegrationsRepository $repository; + private ApiloRepository $apiloRepository; - public function __construct( IntegrationsRepository $repository ) + public function __construct( IntegrationsRepository $repository, ApiloRepository $apiloRepository ) { $this->repository = $repository; + $this->apiloRepository = $apiloRepository; } public function logs(): string @@ -125,7 +128,7 @@ class IntegrationsController { return \Shared\Tpl\Tpl::view( 'integrations/apilo-settings', [ 'settings' => $this->repository->getSettings( 'apilo' ), - 'apilo_status' => $this->repository->apiloIntegrationStatus(), + 'apilo_status' => $this->apiloRepository->apiloIntegrationStatus(), ] ); } @@ -147,7 +150,7 @@ class IntegrationsController { $settings = $this->repository->getSettings( 'apilo' ); - if ( $this->repository->apiloAuthorize( + if ( $this->apiloRepository->apiloAuthorize( (string)($settings['client-id'] ?? ''), (string)($settings['client-secret'] ?? ''), (string)($settings['authorization-code'] ?? '') @@ -156,7 +159,7 @@ class IntegrationsController exit; } - $status = $this->repository->apiloIntegrationStatus(); + $status = $this->apiloRepository->apiloIntegrationStatus(); $message = trim( (string)($status['message'] ?? '') ); if ( $message === '' ) { $message = 'Podczas autoryzacji wystapil blad. Prosze sprawdzic dane i sprobowac ponownie.'; @@ -191,7 +194,7 @@ class IntegrationsController public function apilo_create_product(): void { $productId = (int) \Shared\Helpers\Helpers::get( 'product_id' ); - $result = $this->repository->apiloCreateProduct( $productId ); + $result = $this->apiloRepository->apiloCreateProduct( $productId ); \Shared\Helpers\Helpers::alert( (string)($result['message'] ?? 'Wystapil blad podczas tworzenia produktu w Apilo.') ); header( 'Location: /admin/shop_product/view_list/' ); @@ -208,7 +211,7 @@ class IntegrationsController exit; } - echo json_encode( $this->repository->apiloProductSearch( $sku ) ); + echo json_encode( $this->apiloRepository->apiloProductSearch( $sku ) ); exit; } @@ -267,7 +270,7 @@ class IntegrationsController private function fetchApiloListWithFeedback( string $type, string $label ): void { - $result = $this->repository->apiloFetchListResult( $type ); + $result = $this->apiloRepository->apiloFetchListResult( $type ); if ( !empty( $result['success'] ) ) { $count = (int)($result['count'] ?? 0); diff --git a/cron.php b/cron.php index b98557d..8bf95a0 100644 --- a/cron.php +++ b/cron.php @@ -131,6 +131,7 @@ function getImageUrlById($id) { $settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings(); $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); +$apiloRepository = new \Domain\Integrations\ApiloRepository( $mdb ); $orderRepo = new \Domain\Order\OrderRepository( $mdb ); $cronRepo = new \Domain\CronJob\CronJobRepository( $mdb ); $orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronRepo ); @@ -188,7 +189,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_TOKEN_KEEPALIVE, $apilo_settings = $integrationsRepository->getSettings('apilo'); if ( !(int)($apilo_settings['enabled'] ?? 0) ) return true; // skip if disabled - $integrationsRepository->apiloKeepalive( 300 ); + $apiloRepository->apiloKeepalive( 300 ); echo '

Apilo token keepalive

'; return true; }); @@ -276,7 +277,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, func continue; } - $access_token = $integrationsRepository->apiloGetAccessToken(); + $access_token = $apiloRepository->apiloGetAccessToken(); $order_date = new DateTime( $order['date_order'] ); $paczkomatData = parsePaczkomatAddress( $order['inpost_paczkomat'] ); $orlenPointData = parseOrlenAddress( $order['orlen_point'] ); @@ -557,7 +558,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, fu $result = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null; if ( !$result ) return true; - $access_token = $integrationsRepository->apiloGetAccessToken(); + $access_token = $apiloRepository->apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/'; $curl = curl_init( $url ); curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); @@ -586,7 +587,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRICELIST_SYNC, $apilo_settings = $integrationsRepository->getSettings('apilo'); if ( !$apilo_settings['enabled'] || !$apilo_settings['access-token'] ) return true; - $access_token = $integrationsRepository->apiloGetAccessToken(); + $access_token = $apiloRepository->apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id']; $curl = curl_init( $url ); @@ -639,7 +640,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_STATUS_POLL, fun { if ( $order['apilo_order_id'] ) { - $access_token = $integrationsRepository->apiloGetAccessToken(); + $access_token = $apiloRepository->apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/'; $ch = curl_init( $url ); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c63b8f4..6ca3b36 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,18 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.339 (2026-03-12) - Refactoring: wydzielenie ApiloRepository z IntegrationsRepository + +- **REFACTOR**: `autoload/Domain/Integrations/ApiloRepository.php` — nowa klasa `\Domain\Integrations\ApiloRepository` z 19 metodami apilo* (sync produktów, zamówień, konfiguracja) wydzielonymi z `IntegrationsRepository` +- **REFACTOR**: `autoload/Domain/Integrations/IntegrationsRepository.php` — usunięto 19 metod apilo* (~540 linii); klasa zmniejszona z ~875 do ~340 linii, zawiera wyłącznie generyczną logikę integracji (settings, logi, product linking) +- **REFACTOR**: `autoload/admin/Controllers/IntegrationsController.php` — konsumuje `ApiloRepository` przez DI zamiast `IntegrationsRepository` dla operacji apilo +- **REFACTOR**: `autoload/Domain/Order/OrderAdminService.php` — używa `ApiloRepository` do wysyłki zamówień do Apilo +- **REFACTOR**: `cron.php` — używa `ApiloRepository` do synchronizacji cron +- **REFACTOR**: `autoload/admin/App.php` — wiring DI dla `ApiloRepository` +- **TEST**: `tests/Unit/Domain/Integrations/ApiloRepositoryTest.php` — nowe testy dla `ApiloRepository`; suite: 818 testów, 2275 asercji + +--- + ## ver. 0.338 (2026-03-12) - Bugfix: duplikaty zamówień + status COD - **FIX**: `autoload/front/Controllers/ShopBasketController::summaryView()` — guard przed ponownym złożeniem zamówienia: jeśli sesja zawiera `ORDER_SUBMIT_LAST_ORDER_ID`, użytkownik jest przekierowywany do istniejącego zamówienia zamiast widzieć formularz ponownie diff --git a/docs/TODO.md b/docs/TODO.md index 8aa5e76..cb87e17 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,4 +5,5 @@ program lojalnościowy proponowane produkty w koszyku Do zamówień w statusie: realizowane lub oczekuje na wpłatę. Opcja tylko dla zarejestrowanych klientów. https://royal-stone.pl/pl/order1.html Dodać możliwość ustawienia limitu znaków w wiadomościach do produktu -8. [] Przerobić analitykę Google Analytics i Google ADS \ No newline at end of file +8. [] Przerobić analitykę Google Analytics i Google ADS +9. [] Rozważyć integrację SonarQube (statyczna analiza kodu PHP — bugi, security, code smells). Community Edition darmowy, self-hosted. Wymaga serwera + MCP server w Claude Code. \ No newline at end of file diff --git a/tests/Unit/Domain/Integrations/ApiloRepositoryTest.php b/tests/Unit/Domain/Integrations/ApiloRepositoryTest.php new file mode 100644 index 0000000..921ef65 --- /dev/null +++ b/tests/Unit/Domain/Integrations/ApiloRepositoryTest.php @@ -0,0 +1,130 @@ +mockDb = $this->createMock(\medoo::class); + $this->repository = new ApiloRepository($this->mockDb); + } + + public function testApiloGetAccessTokenReturnsNullWithoutSettings(): void + { + $this->mockDb->method('select')->willReturn([]); + + $this->assertNull($this->repository->apiloGetAccessToken()); + } + + public function testShouldRefreshAccessTokenReturnsFalseForFarFutureDate(): void + { + $reflection = new \ReflectionClass($this->repository); + $method = $reflection->getMethod('shouldRefreshAccessToken'); + $method->setAccessible(true); + + $future = date('Y-m-d H:i:s', time() + 3600); + $result = $method->invoke($this->repository, $future, 300); + + $this->assertFalse($result); + } + + public function testShouldRefreshAccessTokenReturnsTrueForNearExpiryDate(): void + { + $reflection = new \ReflectionClass($this->repository); + $method = $reflection->getMethod('shouldRefreshAccessToken'); + $method->setAccessible(true); + + $near = date('Y-m-d H:i:s', time() + 120); + $result = $method->invoke($this->repository, $near, 300); + + $this->assertTrue($result); + } + + public function testApiloFetchListThrowsForInvalidType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->repository->apiloFetchList('invalid'); + } + + public function testApiloFetchListResultReturnsDetailedErrorWhenConfigMissing(): void + { + $this->mockDb->expects($this->once()) + ->method('select') + ->with('pp_shop_apilo_settings', ['name', 'value']) + ->willReturn([]); + + $result = $this->repository->apiloFetchListResult('payment'); + + $this->assertIsArray($result); + $this->assertFalse((bool)($result['success'] ?? true)); + $this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($result['message'] ?? '')); + } + + public function testApiloIntegrationStatusReturnsMissingConfigMessage(): void + { + $this->mockDb->expects($this->once()) + ->method('select') + ->with('pp_shop_apilo_settings', ['name', 'value']) + ->willReturn([]); + + $status = $this->repository->apiloIntegrationStatus(); + + $this->assertIsArray($status); + $this->assertFalse((bool)($status['is_valid'] ?? true)); + $this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($status['message'] ?? '')); + } + + public function testNormalizeApiloMapListRejectsErrorPayload(): void + { + $reflection = new \ReflectionClass($this->repository); + $method = $reflection->getMethod('normalizeApiloMapList'); + $method->setAccessible(true); + + $result = $method->invoke($this->repository, [ + 'message' => 'Missing JWT token', + 'code' => 401, + ]); + + $this->assertNull($result); + } + + public function testNormalizeApiloMapListAcceptsIdNameList(): void + { + $reflection = new \ReflectionClass($this->repository); + $method = $reflection->getMethod('normalizeApiloMapList'); + $method->setAccessible(true); + + $payload = [ + ['id' => '1', 'name' => 'Przelew'], + ['id' => '2', 'name' => 'Karta'], + ]; + + $result = $method->invoke($this->repository, $payload); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame('1', (string)$result[0]['id']); + $this->assertSame('Przelew', (string)$result[0]['name']); + } + + public function testAllPublicMethodsExist(): void + { + $expectedMethods = [ + 'apiloAuthorize', 'apiloGetAccessToken', 'apiloKeepalive', 'apiloIntegrationStatus', + 'apiloFetchList', 'apiloFetchListResult', 'apiloProductSearch', 'apiloCreateProduct', + ]; + + foreach ($expectedMethods as $method) { + $this->assertTrue( + method_exists($this->repository, $method), + "Method $method does not exist" + ); + } + } +} diff --git a/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php b/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php index d4d5dfb..7da8bd2 100644 --- a/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php +++ b/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php @@ -116,98 +116,12 @@ class IntegrationsRepositoryTest extends TestCase $this->assertTrue($this->repository->unlinkProduct(42)); } - public function testGetProductSkuReturnsValue(): void - { - $this->mockDb->expects($this->once()) - ->method('get') - ->with('pp_shop_products', 'sku', ['id' => 10]) - ->willReturn('SKU-100'); - - $this->assertSame('SKU-100', $this->repository->getProductSku(10)); - } - - public function testGetProductSkuReturnsNullForMissing(): void - { - $this->mockDb->expects($this->once()) - ->method('get') - ->with('pp_shop_products', 'sku', ['id' => 999]) - ->willReturn(false); - - $this->assertNull($this->repository->getProductSku(999)); - } - - public function testApiloGetAccessTokenReturnsNullWithoutSettings(): void - { - $this->mockDb->method('select')->willReturn([]); - - $this->assertNull($this->repository->apiloGetAccessToken()); - } - - public function testShouldRefreshAccessTokenReturnsFalseForFarFutureDate(): void - { - $reflection = new \ReflectionClass($this->repository); - $method = $reflection->getMethod('shouldRefreshAccessToken'); - $method->setAccessible(true); - - $future = date('Y-m-d H:i:s', time() + 3600); - $result = $method->invoke($this->repository, $future, 300); - - $this->assertFalse($result); - } - - public function testShouldRefreshAccessTokenReturnsTrueForNearExpiryDate(): void - { - $reflection = new \ReflectionClass($this->repository); - $method = $reflection->getMethod('shouldRefreshAccessToken'); - $method->setAccessible(true); - - $near = date('Y-m-d H:i:s', time() + 120); - $result = $method->invoke($this->repository, $near, 300); - - $this->assertTrue($result); - } - - public function testApiloFetchListThrowsForInvalidType(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->repository->apiloFetchList('invalid'); - } - - public function testApiloFetchListResultReturnsDetailedErrorWhenConfigMissing(): void - { - $this->mockDb->expects($this->once()) - ->method('select') - ->with('pp_shop_apilo_settings', ['name', 'value']) - ->willReturn([]); - - $result = $this->repository->apiloFetchListResult('payment'); - - $this->assertIsArray($result); - $this->assertFalse((bool)($result['success'] ?? true)); - $this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($result['message'] ?? '')); - } - - public function testApiloIntegrationStatusReturnsMissingConfigMessage(): void - { - $this->mockDb->expects($this->once()) - ->method('select') - ->with('pp_shop_apilo_settings', ['name', 'value']) - ->willReturn([]); - - $status = $this->repository->apiloIntegrationStatus(); - - $this->assertIsArray($status); - $this->assertFalse((bool)($status['is_valid'] ?? true)); - $this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($status['message'] ?? '')); - } - public function testAllPublicMethodsExist(): void { $expectedMethods = [ 'getSettings', 'getSetting', 'saveSetting', 'linkProduct', 'unlinkProduct', - 'apiloAuthorize', 'apiloGetAccessToken', 'apiloKeepalive', 'apiloIntegrationStatus', - 'apiloFetchList', 'apiloFetchListResult', 'apiloProductSearch', 'apiloCreateProduct', + 'getLogs', 'deleteLog', 'clearLogs', 'getProductSku', 'shopproImportProduct', ]; @@ -240,40 +154,27 @@ class IntegrationsRepositoryTest extends TestCase $this->assertSame('test.com', $settings['domain']); } - public function testNormalizeApiloMapListRejectsErrorPayload(): void + public function testGetProductSkuReturnsValue(): void { - $reflection = new \ReflectionClass($this->repository); - $method = $reflection->getMethod('normalizeApiloMapList'); - $method->setAccessible(true); + $this->mockDb->expects($this->once()) + ->method('get') + ->with('pp_shop_products', 'sku', ['id' => 10]) + ->willReturn('SKU-100'); - $result = $method->invoke($this->repository, [ - 'message' => 'Missing JWT token', - 'code' => 401, - ]); - - $this->assertNull($result); + $this->assertSame('SKU-100', $this->repository->getProductSku(10)); } - public function testNormalizeApiloMapListAcceptsIdNameList(): void + public function testGetProductSkuReturnsNullForMissing(): void { - $reflection = new \ReflectionClass($this->repository); - $method = $reflection->getMethod('normalizeApiloMapList'); - $method->setAccessible(true); + $this->mockDb->expects($this->once()) + ->method('get') + ->with('pp_shop_products', 'sku', ['id' => 999]) + ->willReturn(false); - $payload = [ - ['id' => '1', 'name' => 'Przelew'], - ['id' => '2', 'name' => 'Karta'], - ]; - - $result = $method->invoke($this->repository, $payload); - - $this->assertIsArray($result); - $this->assertCount(2, $result); - $this->assertSame('1', (string)$result[0]['id']); - $this->assertSame('Przelew', (string)$result[0]['name']); + $this->assertNull($this->repository->getProductSku(999)); } - // ── Logs ──────────────────────────────────────────────────── + // ── Logs ──────────────────────────────────────────────────── public function testGetLogsReturnsItemsAndTotal(): void { diff --git a/tests/Unit/admin/Controllers/IntegrationsControllerTest.php b/tests/Unit/admin/Controllers/IntegrationsControllerTest.php index 3cd51db..d4b4142 100644 --- a/tests/Unit/admin/Controllers/IntegrationsControllerTest.php +++ b/tests/Unit/admin/Controllers/IntegrationsControllerTest.php @@ -4,21 +4,24 @@ namespace Tests\Unit\admin\Controllers; use PHPUnit\Framework\TestCase; use admin\Controllers\IntegrationsController; use Domain\Integrations\IntegrationsRepository; +use Domain\Integrations\ApiloRepository; class IntegrationsControllerTest extends TestCase { private $repository; + private $apiloRepository; private IntegrationsController $controller; protected function setUp(): void { $this->repository = $this->createMock(IntegrationsRepository::class); - $this->controller = new IntegrationsController($this->repository); + $this->apiloRepository = $this->createMock(ApiloRepository::class); + $this->controller = new IntegrationsController($this->repository, $this->apiloRepository); } public function testConstructorAcceptsDependencies(): void { - $controller = new IntegrationsController($this->repository); + $controller = new IntegrationsController($this->repository, $this->apiloRepository); $this->assertInstanceOf(IntegrationsController::class, $controller); } @@ -28,11 +31,15 @@ class IntegrationsControllerTest extends TestCase $constructor = $reflection->getConstructor(); $params = $constructor->getParameters(); - $this->assertCount(1, $params); + $this->assertCount(2, $params); $this->assertEquals( 'Domain\Integrations\IntegrationsRepository', $params[0]->getType()->getName() ); + $this->assertEquals( + 'Domain\Integrations\ApiloRepository', + $params[1]->getType()->getName() + ); } public function testHasLogsMethods(): void