diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0ad534a..cadb3ab 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -37,6 +37,32 @@ jobs:
- name: Run tests
run: vendor/bin/phpunit --colors=always
+ phpstan:
+ runs-on: ubuntu-latest
+ name: PHPStan
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1', '8.2', '8.3', '8.4', '8.5']
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: curl, json
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Run PHPStan
+ run: vendor/bin/phpstan analyse --memory-limit=512M
+
code-style:
runs-on: ubuntu-latest
name: Code Style
diff --git a/README.md b/README.md
index 24edae6..da3abd7 100644
--- a/README.md
+++ b/README.md
@@ -293,14 +293,42 @@ foreach ($client->iterateBlogs() as $blog) {
}
```
+## Language Filtering
+
+By default, only the `default` language is returned. Use the `lang` parameter to request specific languages:
+
+```php
+use Pobo\Sdk\Enum\Language;
+
+// Get all languages
+$response = $client->getProducts(lang: [Language::ALL]);
+
+// Get specific languages
+$response = $client->getProducts(lang: [Language::DEFAULT, Language::CS, Language::SK]);
+
+// Iterate with language filter
+foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) {
+ echo $product->name->get(Language::CS);
+ echo $product->content?->getHtml(Language::CS);
+}
+
+// Same for categories and blogs
+$response = $client->getCategories(lang: [Language::DEFAULT, Language::CS]);
+$response = $client->getBlogs(lang: [Language::ALL]);
+```
+
+> **Note:** Without the `lang` parameter, only `default` is returned. Invalid language values are silently ignored.
+
## Content (HTML/Marketplace/Nested)
By default, only `content.html` is returned. Use the `include` parameter to request additional content:
-| Value | Description |
-|---------------|----------------------------------------------|
-| `marketplace` | HTML content for marketplace (no custom CSS) |
-| `nested` | Raw widget JSON from widget tables |
+| Value | Description | Available for |
+|----------------|---------------------------------------------------|--------------------------|
+| `marketplace` | HTML content for marketplace (no custom CSS) | product, category, blog |
+| `nested` | Raw widget JSON from widget tables | product, category, blog |
+| `site_link` | Anchor navigation on H2 headings | product, blog |
+| `rich_snippet` | JSON-LD structured data (FAQPage) | product, category, blog |
```php
use Pobo\Sdk\Enum\IncludeContent;
@@ -344,6 +372,55 @@ foreach ($client->iterateBlogs(include: [IncludeContent::MARKETPLACE]) as $blog)
}
```
+## Site Links
+
+Anchor navigation generated from H2 headings in content widgets. Available for products and blogs.
+
+```php
+use Pobo\Sdk\Enum\IncludeContent;
+use Pobo\Sdk\Enum\Language;
+
+foreach ($client->iterateProducts(include: [IncludeContent::SITE_LINK], lang: [Language::ALL]) as $product) {
+ if ($product->siteLink !== null) {
+ // Get rendered navigation HTML
+ $navHtml = $product->siteLink->getHtml(Language::DEFAULT);
+
+ // Get structured list of headings
+ $items = $product->siteLink->getList(Language::DEFAULT);
+ foreach ($items as $item) {
+ echo sprintf('%s', $item->slug, $item->heading);
+ }
+ }
+}
+```
+
+## Rich Snippets
+
+JSON-LD structured data (FAQPage schema) generated from FAQ widgets. Available for products, categories, and blogs.
+
+```php
+use Pobo\Sdk\Enum\IncludeContent;
+use Pobo\Sdk\Enum\Language;
+
+foreach ($client->iterateProducts(include: [IncludeContent::RICH_SNIPPET], lang: [Language::ALL]) as $product) {
+ if ($product->richSnippet !== null) {
+ // Get rendered JSON-LD script tag
+ $scriptHtml = $product->richSnippet->getHtml(Language::DEFAULT);
+
+ // Get parsed JSON-LD object
+ $jsonLd = $product->richSnippet->getJson(Language::DEFAULT);
+ echo $jsonLd['@type']; // 'FAQPage'
+ }
+}
+
+// Categories have rich snippets but no site links
+foreach ($client->iterateCategories(include: [IncludeContent::RICH_SNIPPET]) as $category) {
+ if ($category->richSnippet !== null) {
+ echo $category->richSnippet->getHtml(Language::DEFAULT);
+ }
+}
+```
+
## Webhook Handler
### Basic Usage
@@ -443,21 +520,21 @@ $name->toArray(); // ['default' => '...', 'cs' => '...', ...]
## API Methods
-| Method | Description |
-|---------------------------------------------------------------------------------------------------------|----------------------------------|
-| `importProducts(array $products)` | Bulk import products (max 100) |
-| `importCategories(array $categories)` | Bulk import categories (max 100) |
-| `importParameters(array $parameters)` | Bulk import parameters (max 100) |
-| `importBlogs(array $blogs)` | Bulk import blogs (max 100) |
-| `deleteProducts(array $ids)` | Bulk delete products (max 100) |
-| `deleteCategories(array $ids)` | Bulk delete categories (max 100) |
-| `deleteBlogs(array $ids)` | Bulk delete blogs (max 100) |
-| `getProducts(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Get products page |
-| `getCategories(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Get categories page |
-| `getBlogs(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Get blogs page |
-| `iterateProducts(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Iterate all products |
-| `iterateCategories(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Iterate all categories |
-| `iterateBlogs(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Iterate all blogs |
+| Method | Description |
+|-----------------------------------------------------------------------------------------------------------------------|----------------------------------|
+| `importProducts(array $products)` | Bulk import products (max 100) |
+| `importCategories(array $categories)` | Bulk import categories (max 100) |
+| `importParameters(array $parameters)` | Bulk import parameters (max 100) |
+| `importBlogs(array $blogs)` | Bulk import blogs (max 100) |
+| `deleteProducts(array $ids)` | Bulk delete products (max 100) |
+| `deleteCategories(array $ids)` | Bulk delete categories (max 100) |
+| `deleteBlogs(array $ids)` | Bulk delete blogs (max 100) |
+| `getProducts(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get products page |
+| `getCategories(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get categories page |
+| `getBlogs(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get blogs page |
+| `iterateProducts(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all products |
+| `iterateCategories(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all categories |
+| `iterateBlogs(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all blogs |
## Limits
@@ -470,7 +547,7 @@ $name->toArray(); // ['default' => '...', 'cs' => '...', ...]
| Name length | 250 chars |
| URL length | 255 chars |
| Image URL length | 650 chars |
-| Description length | 65,000 chars |
+| Description length | 500,000 chars |
| SEO description length | 500 chars |
## License
diff --git a/composer.json b/composer.json
index 2564fce..efa9e08 100644
--- a/composer.json
+++ b/composer.json
@@ -28,7 +28,8 @@
"ext-json": "*"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^10.0",
+ "phpstan/phpstan": "^2.0"
},
"autoload": {
"psr-4": {
@@ -41,7 +42,8 @@
}
},
"scripts": {
- "test": "phpunit"
+ "test": "phpunit",
+ "phpstan": "phpstan analyse"
},
"minimum-stability": "stable",
"prefer-stable": true
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..dba5a8a
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,5 @@
+parameters:
+ level: 9
+ paths:
+ - src
+ phpVersion: 80100
diff --git a/src/DTO/Blog.php b/src/DTO/Blog.php
index c475859..e8332f4 100644
--- a/src/DTO/Blog.php
+++ b/src/DTO/Blog.php
@@ -4,6 +4,25 @@
namespace Pobo\Sdk\DTO;
+/**
+ * @phpstan-type BlogData array{
+ * id: string,
+ * is_visible: bool,
+ * name: array,
+ * url: array,
+ * category?: string|null,
+ * description?: array,
+ * seo_title?: array,
+ * seo_description?: array,
+ * content?: array,
+ * site_link?: array,
+ * rich_snippet?: array,
+ * images?: array,
+ * is_loaded?: bool,
+ * created_at?: string,
+ * updated_at?: string,
+ * }
+ */
final class Blog
{
/**
@@ -19,6 +38,8 @@ public function __construct(
public readonly ?LocalizedString $seoTitle = null,
public readonly ?LocalizedString $seoDescription = null,
public readonly ?Content $content = null,
+ public readonly ?SiteLink $siteLink = null,
+ public readonly ?RichSnippet $richSnippet = null,
public readonly array $images = [],
public readonly ?bool $isLoaded = null,
public readonly ?\DateTimeInterface $createdAt = null,
@@ -66,6 +87,7 @@ public function toArray(): array
*/
public static function fromArray(array $data): self
{
+ /** @var BlogData $data */
return new self(
id: $data['id'],
isVisible: $data['is_visible'],
@@ -76,6 +98,8 @@ public static function fromArray(array $data): self
seoTitle: isset($data['seo_title']) ? LocalizedString::fromArray($data['seo_title']) : null,
seoDescription: isset($data['seo_description']) ? LocalizedString::fromArray($data['seo_description']) : null,
content: isset($data['content']) ? Content::fromArray($data['content']) : null,
+ siteLink: isset($data['site_link']) ? SiteLink::fromArray($data['site_link']) : null,
+ richSnippet: isset($data['rich_snippet']) ? RichSnippet::fromArray($data['rich_snippet']) : null,
images: $data['images'] ?? [],
isLoaded: $data['is_loaded'] ?? null,
createdAt: isset($data['created_at']) ? new \DateTimeImmutable($data['created_at']) : null,
diff --git a/src/DTO/Category.php b/src/DTO/Category.php
index 07b3395..097197b 100644
--- a/src/DTO/Category.php
+++ b/src/DTO/Category.php
@@ -4,6 +4,24 @@
namespace Pobo\Sdk\DTO;
+/**
+ * @phpstan-type CategoryData array{
+ * id: string,
+ * is_visible: bool,
+ * name: array,
+ * url: array,
+ * description?: array,
+ * seo_title?: array,
+ * seo_description?: array,
+ * content?: array,
+ * rich_snippet?: array,
+ * images?: array,
+ * guid?: string|null,
+ * is_loaded?: bool,
+ * created_at?: string,
+ * updated_at?: string,
+ * }
+ */
final class Category
{
/**
@@ -18,6 +36,7 @@ public function __construct(
public readonly ?LocalizedString $seoTitle = null,
public readonly ?LocalizedString $seoDescription = null,
public readonly ?Content $content = null,
+ public readonly ?RichSnippet $richSnippet = null,
public readonly array $images = [],
public readonly ?string $guid = null,
public readonly ?bool $isLoaded = null,
@@ -62,6 +81,7 @@ public function toArray(): array
*/
public static function fromArray(array $data): self
{
+ /** @var CategoryData $data */
return new self(
id: $data['id'],
isVisible: $data['is_visible'],
@@ -71,6 +91,7 @@ public static function fromArray(array $data): self
seoTitle: isset($data['seo_title']) ? LocalizedString::fromArray($data['seo_title']) : null,
seoDescription: isset($data['seo_description']) ? LocalizedString::fromArray($data['seo_description']) : null,
content: isset($data['content']) ? Content::fromArray($data['content']) : null,
+ richSnippet: isset($data['rich_snippet']) ? RichSnippet::fromArray($data['rich_snippet']) : null,
images: $data['images'] ?? [],
guid: $data['guid'] ?? null,
isLoaded: $data['is_loaded'] ?? null,
diff --git a/src/DTO/Content.php b/src/DTO/Content.php
index 4874e46..4fb80dc 100644
--- a/src/DTO/Content.php
+++ b/src/DTO/Content.php
@@ -70,6 +70,7 @@ public function toArray(): array
*/
public static function fromArray(array $data): self
{
+ /** @var array{html?: array, marketplace?: array, nested?: array>} $data */
return new self(
html: $data['html'] ?? [],
marketplace: $data['marketplace'] ?? [],
diff --git a/src/DTO/DeleteResult.php b/src/DTO/DeleteResult.php
index c1842b4..adca51a 100644
--- a/src/DTO/DeleteResult.php
+++ b/src/DTO/DeleteResult.php
@@ -4,6 +4,14 @@
namespace Pobo\Sdk\DTO;
+/**
+ * @phpstan-type DeleteResultData array{
+ * success?: bool,
+ * deleted?: int,
+ * skipped?: int,
+ * errors?: array}>,
+ * }
+ */
final class DeleteResult
{
/**
@@ -27,6 +35,7 @@ public function hasErrors(): bool
*/
public static function fromArray(array $data): self
{
+ /** @var DeleteResultData $data */
return new self(
success: $data['success'] ?? false,
deleted: $data['deleted'] ?? 0,
diff --git a/src/DTO/ImportResult.php b/src/DTO/ImportResult.php
index 4ed85da..f485dde 100644
--- a/src/DTO/ImportResult.php
+++ b/src/DTO/ImportResult.php
@@ -4,6 +4,17 @@
namespace Pobo\Sdk\DTO;
+/**
+ * @phpstan-type ImportResultData array{
+ * success?: bool,
+ * imported?: int,
+ * updated?: int,
+ * skipped?: int,
+ * errors?: array}>,
+ * values_imported?: int,
+ * values_updated?: int,
+ * }
+ */
final class ImportResult
{
/**
@@ -30,6 +41,7 @@ public function hasErrors(): bool
*/
public static function fromArray(array $data): self
{
+ /** @var ImportResultData $data */
return new self(
success: $data['success'] ?? false,
imported: $data['imported'] ?? 0,
diff --git a/src/DTO/LocalizedString.php b/src/DTO/LocalizedString.php
index 13ed2d5..ed5adc2 100644
--- a/src/DTO/LocalizedString.php
+++ b/src/DTO/LocalizedString.php
@@ -47,10 +47,11 @@ public function toArray(): array
}
/**
- * @param array $data
+ * @param array $data
*/
public static function fromArray(array $data): self
{
+ /** @var array $data */
return new self($data);
}
}
diff --git a/src/DTO/PaginatedResponse.php b/src/DTO/PaginatedResponse.php
index 56cfcf1..e693f49 100644
--- a/src/DTO/PaginatedResponse.php
+++ b/src/DTO/PaginatedResponse.php
@@ -4,10 +4,13 @@
namespace Pobo\Sdk\DTO;
+/**
+ * @template T of Product|Category|Blog
+ */
final class PaginatedResponse
{
/**
- * @param array $data
+ * @param array $data
*/
public function __construct(
public readonly array $data,
@@ -28,23 +31,32 @@ public function getTotalPages(): int
}
/**
+ * @template TEntity of Product|Category|Blog
* @param array $response
- * @param class-string $entityClass
+ * @param class-string $entityClass
+ * @return self
*/
public static function fromArray(array $response, string $entityClass): self
{
+ /** @var array> $responseData */
+ $responseData = $response['data'] ?? [];
+
$data = array_map(
fn(array $item) => $entityClass::fromArray($item),
- $response['data'] ?? []
+ $responseData,
);
+ /** @var array{current_page?: int, per_page?: int, total?: int} $meta */
$meta = $response['meta'] ?? [];
- return new self(
+ /** @var self $result */
+ $result = new self(
data: $data,
currentPage: $meta['current_page'] ?? 1,
perPage: $meta['per_page'] ?? 100,
total: $meta['total'] ?? count($data),
);
+
+ return $result;
}
}
diff --git a/src/DTO/Parameter.php b/src/DTO/Parameter.php
index 6ff0761..f2fa47c 100644
--- a/src/DTO/Parameter.php
+++ b/src/DTO/Parameter.php
@@ -33,6 +33,7 @@ public function toArray(): array
*/
public static function fromArray(array $data): self
{
+ /** @var array{id: int, name: string, values?: array>} $data */
return new self(
id: $data['id'],
name: $data['name'],
diff --git a/src/DTO/ParameterValue.php b/src/DTO/ParameterValue.php
index b45a94b..2c57540 100644
--- a/src/DTO/ParameterValue.php
+++ b/src/DTO/ParameterValue.php
@@ -28,6 +28,7 @@ public function toArray(): array
*/
public static function fromArray(array $data): self
{
+ /** @var array{id: int, value: string} $data */
return new self(
id: $data['id'],
value: $data['value'],
diff --git a/src/DTO/Product.php b/src/DTO/Product.php
index c3f3da3..fe35075 100644
--- a/src/DTO/Product.php
+++ b/src/DTO/Product.php
@@ -4,6 +4,29 @@
namespace Pobo\Sdk\DTO;
+/**
+ * @phpstan-type ProductData array{
+ * id: string,
+ * is_visible: bool,
+ * name: array,
+ * url: array,
+ * short_description?: array,
+ * description?: array,
+ * seo_title?: array,
+ * seo_description?: array,
+ * content?: array,
+ * site_link?: array,
+ * rich_snippet?: array,
+ * images?: array,
+ * categories_ids?: array,
+ * parameters_ids?: array,
+ * guid?: string|null,
+ * is_loaded?: bool,
+ * categories?: array}>,
+ * created_at?: string,
+ * updated_at?: string,
+ * }
+ */
final class Product
{
/**
@@ -22,6 +45,8 @@ public function __construct(
public readonly ?LocalizedString $seoTitle = null,
public readonly ?LocalizedString $seoDescription = null,
public readonly ?Content $content = null,
+ public readonly ?SiteLink $siteLink = null,
+ public readonly ?RichSnippet $richSnippet = null,
public readonly array $images = [],
public readonly array $categoriesIds = [],
public readonly array $parametersIds = [],
@@ -81,6 +106,7 @@ public function toArray(): array
*/
public static function fromArray(array $data): self
{
+ /** @var ProductData $data */
return new self(
id: $data['id'],
isVisible: $data['is_visible'],
@@ -91,6 +117,8 @@ public static function fromArray(array $data): self
seoTitle: isset($data['seo_title']) ? LocalizedString::fromArray($data['seo_title']) : null,
seoDescription: isset($data['seo_description']) ? LocalizedString::fromArray($data['seo_description']) : null,
content: isset($data['content']) ? Content::fromArray($data['content']) : null,
+ siteLink: isset($data['site_link']) ? SiteLink::fromArray($data['site_link']) : null,
+ richSnippet: isset($data['rich_snippet']) ? RichSnippet::fromArray($data['rich_snippet']) : null,
images: $data['images'] ?? [],
categoriesIds: $data['categories_ids'] ?? [],
parametersIds: $data['parameters_ids'] ?? [],
diff --git a/src/DTO/RichSnippet.php b/src/DTO/RichSnippet.php
new file mode 100644
index 0000000..01e9e20
--- /dev/null
+++ b/src/DTO/RichSnippet.php
@@ -0,0 +1,52 @@
+ $html
+ * @param array $json
+ */
+ public function __construct(
+ public readonly array $html = [],
+ public readonly array $json = [],
+ ) {
+ }
+
+ public function getHtml(Language $language): ?string
+ {
+ return $this->html[$language->value] ?? null;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getJson(Language $language): ?array
+ {
+ $value = $this->json[$language->value] ?? null;
+
+ if (is_array($value)) {
+ /** @var array $value */
+ return $value;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $data
+ */
+ public static function fromArray(array $data): self
+ {
+ /** @var array{html?: array, json?: array} $data */
+ return new self(
+ html: $data['html'] ?? [],
+ json: $data['json'] ?? [],
+ );
+ }
+}
diff --git a/src/DTO/SiteLink.php b/src/DTO/SiteLink.php
new file mode 100644
index 0000000..450e85a
--- /dev/null
+++ b/src/DTO/SiteLink.php
@@ -0,0 +1,58 @@
+ $html
+ * @param array> $list
+ */
+ public function __construct(
+ public readonly array $html = [],
+ public readonly array $list = [],
+ ) {
+ }
+
+ public function getHtml(Language $language): ?string
+ {
+ return $this->html[$language->value] ?? null;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getList(Language $language): ?array
+ {
+ return $this->list[$language->value] ?? null;
+ }
+
+ /**
+ * @param array $data
+ */
+ public static function fromArray(array $data): self
+ {
+ /** @var array $html */
+ $html = $data['html'] ?? [];
+
+ /** @var array> $rawList */
+ $rawList = $data['list'] ?? [];
+
+ $list = [];
+ foreach ($rawList as $lang => $items) {
+ $list[$lang] = array_map(
+ fn(array $item) => SiteLinkItem::fromArray($item),
+ $items,
+ );
+ }
+
+ return new self(
+ html: $html,
+ list: $list,
+ );
+ }
+}
diff --git a/src/DTO/SiteLinkItem.php b/src/DTO/SiteLinkItem.php
new file mode 100644
index 0000000..a70460d
--- /dev/null
+++ b/src/DTO/SiteLinkItem.php
@@ -0,0 +1,36 @@
+ $this->heading,
+ 'slug' => $this->slug,
+ ];
+ }
+
+ /**
+ * @param array{heading: string, slug: string} $data
+ */
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ heading: $data['heading'],
+ slug: $data['slug'],
+ );
+ }
+}
diff --git a/src/DTO/WebhookPayload.php b/src/DTO/WebhookPayload.php
index e8f3dc6..2fdbd6b 100644
--- a/src/DTO/WebhookPayload.php
+++ b/src/DTO/WebhookPayload.php
@@ -20,6 +20,7 @@ public function __construct(
*/
public static function fromArray(array $data, WebhookEvent $event): self
{
+ /** @var array{timestamp: string, eshop_id: int} $data */
return new self(
event: $event,
timestamp: new \DateTimeImmutable($data['timestamp']),
diff --git a/src/Enum/IncludeContent.php b/src/Enum/IncludeContent.php
index 2638716..00ae690 100644
--- a/src/Enum/IncludeContent.php
+++ b/src/Enum/IncludeContent.php
@@ -8,6 +8,8 @@ enum IncludeContent: string
{
case MARKETPLACE = 'marketplace';
case NESTED = 'nested';
+ case SITE_LINK = 'site_link';
+ case RICH_SNIPPET = 'rich_snippet';
/**
* @return array
diff --git a/src/Enum/Language.php b/src/Enum/Language.php
index 892e4ed..8e459fe 100644
--- a/src/Enum/Language.php
+++ b/src/Enum/Language.php
@@ -13,6 +13,7 @@ enum Language: string
case DE = 'de';
case PL = 'pl';
case HU = 'hu';
+ case ALL = 'all';
/**
* @return array
diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php
index 1381e2f..0966054 100644
--- a/src/Exception/ApiException.php
+++ b/src/Exception/ApiException.php
@@ -9,6 +9,7 @@ class ApiException extends PoboException
public function __construct(
string $message,
public readonly int $httpCode,
+ /** @var array|null */
public readonly ?array $responseBody = null,
) {
parent::__construct($message, $httpCode);
@@ -19,9 +20,12 @@ public static function unauthorized(): self
return new self('Authorization token required or invalid', 401);
}
+ /**
+ * @param array|null $body
+ */
public static function fromResponse(int $httpCode, ?array $body): self
{
- $message = $body['message'] ?? $body['error'] ?? sprintf('API request failed with HTTP %d', $httpCode);
+ $message = is_string($body['message'] ?? null) ? $body['message'] : (is_string($body['error'] ?? null) ? $body['error'] : sprintf('API request failed with HTTP %d', $httpCode));
return new self($message, $httpCode, $body);
}
}
diff --git a/src/PoboClient.php b/src/PoboClient.php
index 5206be6..f7c156f 100644
--- a/src/PoboClient.php
+++ b/src/PoboClient.php
@@ -12,6 +12,7 @@
use Pobo\Sdk\DTO\Parameter;
use Pobo\Sdk\DTO\Product;
use Pobo\Sdk\Enum\IncludeContent;
+use Pobo\Sdk\Enum\Language;
use Pobo\Sdk\Exception\ApiException;
use Pobo\Sdk\Exception\ValidationException;
@@ -101,7 +102,9 @@ public function importBlogs(array $blogs): ImportResult
}
/**
- * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED
+ * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED, IncludeContent::SITE_LINK, IncludeContent::RICH_SNIPPET
+ * @param array|null $lang Languages to include in response. null = only default, [Language::ALL] = all languages
+ * @return PaginatedResponse
* @throws ApiException
*/
public function getProducts(
@@ -110,14 +113,17 @@ public function getProducts(
?\DateTimeInterface $lastUpdateFrom = null,
?bool $isEdited = null,
?array $include = null,
+ ?array $lang = null,
): PaginatedResponse {
- $query = $this->buildQueryParams($page, $perPage, $lastUpdateFrom, $isEdited, $include);
+ $query = $this->buildQueryParams($page, $perPage, $lastUpdateFrom, $isEdited, $include, $lang);
$response = $this->request('GET', '/api/v2/rest/products' . $query);
return PaginatedResponse::fromArray($response, Product::class);
}
/**
- * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED
+ * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED, IncludeContent::RICH_SNIPPET
+ * @param array|null $lang Languages to include in response. null = only default, [Language::ALL] = all languages
+ * @return PaginatedResponse
* @throws ApiException
*/
public function getCategories(
@@ -126,14 +132,17 @@ public function getCategories(
?\DateTimeInterface $lastUpdateFrom = null,
?bool $isEdited = null,
?array $include = null,
+ ?array $lang = null,
): PaginatedResponse {
- $query = $this->buildQueryParams($page, $perPage, $lastUpdateFrom, $isEdited, $include);
+ $query = $this->buildQueryParams($page, $perPage, $lastUpdateFrom, $isEdited, $include, $lang);
$response = $this->request('GET', '/api/v2/rest/categories' . $query);
return PaginatedResponse::fromArray($response, Category::class);
}
/**
- * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED
+ * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED, IncludeContent::SITE_LINK, IncludeContent::RICH_SNIPPET
+ * @param array|null $lang Languages to include in response. null = only default, [Language::ALL] = all languages
+ * @return PaginatedResponse
* @throws ApiException
*/
public function getBlogs(
@@ -142,8 +151,9 @@ public function getBlogs(
?\DateTimeInterface $lastUpdateFrom = null,
?bool $isEdited = null,
?array $include = null,
+ ?array $lang = null,
): PaginatedResponse {
- $query = $this->buildQueryParams($page, $perPage, $lastUpdateFrom, $isEdited, $include);
+ $query = $this->buildQueryParams($page, $perPage, $lastUpdateFrom, $isEdited, $include, $lang);
$response = $this->request('GET', '/api/v2/rest/blogs' . $query);
return PaginatedResponse::fromArray($response, Blog::class);
}
@@ -194,7 +204,8 @@ public function deleteBlogs(array $ids): DeleteResult
}
/**
- * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED
+ * @param array|null $include Optional content to include
+ * @param array|null $lang Languages to include in response
* @return \Generator
* @throws ApiException
*/
@@ -202,11 +213,12 @@ public function iterateProducts(
?\DateTimeInterface $lastUpdateFrom = null,
?bool $isEdited = null,
?array $include = null,
+ ?array $lang = null,
): \Generator {
$page = 1;
do {
- $response = $this->getProducts($page, self::MAX_BULK_ITEMS, $lastUpdateFrom, $isEdited, $include);
+ $response = $this->getProducts($page, self::MAX_BULK_ITEMS, $lastUpdateFrom, $isEdited, $include, $lang);
foreach ($response->data as $product) {
yield $product;
@@ -217,7 +229,8 @@ public function iterateProducts(
}
/**
- * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED
+ * @param array|null $include Optional content to include
+ * @param array|null $lang Languages to include in response
* @return \Generator
* @throws ApiException
*/
@@ -225,11 +238,12 @@ public function iterateCategories(
?\DateTimeInterface $lastUpdateFrom = null,
?bool $isEdited = null,
?array $include = null,
+ ?array $lang = null,
): \Generator {
$page = 1;
do {
- $response = $this->getCategories($page, self::MAX_BULK_ITEMS, $lastUpdateFrom, $isEdited, $include);
+ $response = $this->getCategories($page, self::MAX_BULK_ITEMS, $lastUpdateFrom, $isEdited, $include, $lang);
foreach ($response->data as $category) {
yield $category;
@@ -240,7 +254,8 @@ public function iterateCategories(
}
/**
- * @param array|null $include Optional content to include: IncludeContent::MARKETPLACE, IncludeContent::NESTED
+ * @param array|null $include Optional content to include
+ * @param array|null $lang Languages to include in response
* @return \Generator
* @throws ApiException
*/
@@ -248,11 +263,12 @@ public function iterateBlogs(
?\DateTimeInterface $lastUpdateFrom = null,
?bool $isEdited = null,
?array $include = null,
+ ?array $lang = null,
): \Generator {
$page = 1;
do {
- $response = $this->getBlogs($page, self::MAX_BULK_ITEMS, $lastUpdateFrom, $isEdited, $include);
+ $response = $this->getBlogs($page, self::MAX_BULK_ITEMS, $lastUpdateFrom, $isEdited, $include, $lang);
foreach ($response->data as $blog) {
yield $blog;
@@ -263,6 +279,7 @@ public function iterateBlogs(
}
/**
+ * @param array $items
* @throws ValidationException
*/
private function validateBulkSize(array $items): void
@@ -279,6 +296,7 @@ private function validateBulkSize(array $items): void
/**
* @param array|null $include
+ * @param array|null $lang
*/
private function buildQueryParams(
?int $page,
@@ -286,6 +304,7 @@ private function buildQueryParams(
?\DateTimeInterface $lastUpdateFrom,
?bool $isEdited,
?array $include = null,
+ ?array $lang = null,
): string {
$params = [];
@@ -313,6 +332,14 @@ private function buildQueryParams(
$params['include'] = implode(',', $values);
}
+ if ($lang !== null && $lang !== []) {
+ $values = array_map(
+ fn(Language|string $item) => $item instanceof Language ? $item->value : $item,
+ $lang,
+ );
+ $params['lang'] = implode(',', $values);
+ }
+
return $params === [] ? '' : sprintf('?%s', http_build_query($params));
}
@@ -323,33 +350,30 @@ private function buildQueryParams(
*/
private function request(string $method, string $endpoint, ?array $data = null): array
{
- $url = sprintf('%s%s', $this->baseUrl, $endpoint);
+ $url = rtrim($this->baseUrl, '/') . $endpoint;
- $ch = curl_init();
+ $ch = curl_init($url);
$headers = [
- sprintf('Authorization: Bearer %s', $this->apiToken),
+ 'Authorization: Bearer ' . $this->apiToken,
'Content-Type: application/json',
'Accept: application/json',
];
- curl_setopt_array($ch, [
- CURLOPT_URL => $url,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_HTTPHEADER => $headers,
- CURLOPT_TIMEOUT => $this->timeout,
- CURLOPT_CONNECTTIMEOUT => 10,
- ]);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($data !== null) {
- curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_THROW_ON_ERROR));
}
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
if ($data !== null) {
- curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_THROW_ON_ERROR));
}
}
@@ -359,10 +383,11 @@ private function request(string $method, string $endpoint, ?array $data = null):
curl_close($ch);
- if ($response === false) {
+ if (!is_string($response)) {
throw new ApiException(sprintf('cURL error: %s', $error), 0);
}
+ /** @var array|null $body */
$body = json_decode($response, true);
if ($httpCode === 401) {
diff --git a/src/WebhookHandler.php b/src/WebhookHandler.php
index 22981a3..ff8e4cf 100644
--- a/src/WebhookHandler.php
+++ b/src/WebhookHandler.php
@@ -41,6 +41,7 @@ public function handle(string $payload, string $signature): WebhookPayload
public function handleFromGlobals(): WebhookPayload
{
$payload = file_get_contents('php://input');
+ /** @var string $signature */
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if ($payload === false) {
@@ -72,6 +73,7 @@ private function parsePayload(string $payload): WebhookPayload
throw WebhookException::invalidPayload();
}
+ /** @var string $eventString */
$eventString = $data['event'] ?? '';
$event = WebhookEvent::fromString($eventString);
diff --git a/tests/BlogTest.php b/tests/BlogTest.php
index b899c3a..1cb16f4 100644
--- a/tests/BlogTest.php
+++ b/tests/BlogTest.php
@@ -8,6 +8,8 @@
use Pobo\Sdk\DTO\Blog;
use Pobo\Sdk\DTO\Content;
use Pobo\Sdk\DTO\LocalizedString;
+use Pobo\Sdk\DTO\RichSnippet;
+use Pobo\Sdk\DTO\SiteLink;
use Pobo\Sdk\Enum\Language;
final class BlogTest extends TestCase
@@ -230,4 +232,46 @@ public function testBlogImagesArray(): void
$array = $blog->toArray();
$this->assertSame($images, $array['images']);
}
+
+ public function testBlogFromArrayWithSiteLinkAndRichSnippet(): void
+ {
+ $data = [
+ 'id' => 'BLOG-EXTRAS',
+ 'is_visible' => true,
+ 'name' => ['default' => 'Blog'],
+ 'url' => ['default' => 'https://example.com'],
+ 'site_link' => [
+ 'html' => ['default' => 'nav
'],
+ 'list' => [
+ 'default' => [['heading' => 'Nadpis', 'slug' => 'nadpis']],
+ ],
+ ],
+ 'rich_snippet' => [
+ 'html' => ['default' => ''],
+ 'json' => ['default' => ['@type' => 'FAQPage']],
+ ],
+ ];
+
+ $blog = Blog::fromArray($data);
+
+ $this->assertInstanceOf(SiteLink::class, $blog->siteLink);
+ $this->assertInstanceOf(RichSnippet::class, $blog->richSnippet);
+ $this->assertStringContainsString('pobo-site-link', $blog->siteLink->getHtml(Language::DEFAULT));
+ $this->assertSame('FAQPage', $blog->richSnippet->getJson(Language::DEFAULT)['@type']);
+ }
+
+ public function testBlogFromArrayWithoutSiteLinkAndRichSnippet(): void
+ {
+ $data = [
+ 'id' => 'BLOG-PLAIN',
+ 'is_visible' => true,
+ 'name' => ['default' => 'Blog'],
+ 'url' => ['default' => 'https://example.com'],
+ ];
+
+ $blog = Blog::fromArray($data);
+
+ $this->assertNull($blog->siteLink);
+ $this->assertNull($blog->richSnippet);
+ }
}
diff --git a/tests/CategoryTest.php b/tests/CategoryTest.php
index f6e5f5d..f146770 100644
--- a/tests/CategoryTest.php
+++ b/tests/CategoryTest.php
@@ -8,6 +8,7 @@
use Pobo\Sdk\DTO\Category;
use Pobo\Sdk\DTO\Content;
use Pobo\Sdk\DTO\LocalizedString;
+use Pobo\Sdk\DTO\RichSnippet;
use Pobo\Sdk\Enum\Language;
final class CategoryTest extends TestCase
@@ -214,4 +215,37 @@ public function testCategoryTimestamps(): void
$this->assertSame('2024-01-15', $category->createdAt->format('Y-m-d'));
$this->assertSame('2024-01-16', $category->updatedAt->format('Y-m-d'));
}
+
+ public function testCategoryFromArrayWithRichSnippet(): void
+ {
+ $data = [
+ 'id' => 'CAT-RICH',
+ 'is_visible' => true,
+ 'name' => ['default' => 'Category'],
+ 'url' => ['default' => 'https://example.com'],
+ 'rich_snippet' => [
+ 'html' => ['default' => ''],
+ 'json' => ['default' => ['@type' => 'FAQPage', 'mainEntity' => []]],
+ ],
+ ];
+
+ $category = Category::fromArray($data);
+
+ $this->assertInstanceOf(RichSnippet::class, $category->richSnippet);
+ $this->assertSame('FAQPage', $category->richSnippet->getJson(Language::DEFAULT)['@type']);
+ }
+
+ public function testCategoryFromArrayWithoutRichSnippet(): void
+ {
+ $data = [
+ 'id' => 'CAT-PLAIN',
+ 'is_visible' => true,
+ 'name' => ['default' => 'Category'],
+ 'url' => ['default' => 'https://example.com'],
+ ];
+
+ $category = Category::fromArray($data);
+
+ $this->assertNull($category->richSnippet);
+ }
}
diff --git a/tests/DTO/RichSnippetTest.php b/tests/DTO/RichSnippetTest.php
new file mode 100644
index 0000000..cd53185
--- /dev/null
+++ b/tests/DTO/RichSnippetTest.php
@@ -0,0 +1,69 @@
+ [
+ 'default' => '',
+ 'cs' => '',
+ ],
+ 'json' => [
+ 'default' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'FAQPage',
+ 'mainEntity' => [
+ [
+ '@type' => 'Question',
+ 'name' => 'Otázka?',
+ 'acceptedAnswer' => [
+ '@type' => 'Answer',
+ 'text' => 'Odpověď.',
+ ],
+ ],
+ ],
+ ],
+ 'cs' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'FAQPage',
+ 'mainEntity' => [],
+ ],
+ ],
+ ];
+
+ $richSnippet = RichSnippet::fromArray($data);
+
+ $this->assertStringContainsString('FAQPage', $richSnippet->getHtml(Language::DEFAULT));
+ $this->assertNull($richSnippet->getHtml(Language::SK));
+
+ $defaultJson = $richSnippet->getJson(Language::DEFAULT);
+ $this->assertNotNull($defaultJson);
+ $this->assertSame('FAQPage', $defaultJson['@type']);
+ $this->assertCount(1, $defaultJson['mainEntity']);
+
+ $csJson = $richSnippet->getJson(Language::CS);
+ $this->assertNotNull($csJson);
+ $this->assertSame([], $csJson['mainEntity']);
+
+ $this->assertNull($richSnippet->getJson(Language::SK));
+ }
+
+ public function testFromArrayEmpty(): void
+ {
+ $richSnippet = RichSnippet::fromArray([]);
+
+ $this->assertSame([], $richSnippet->html);
+ $this->assertSame([], $richSnippet->json);
+ $this->assertNull($richSnippet->getHtml(Language::DEFAULT));
+ $this->assertNull($richSnippet->getJson(Language::DEFAULT));
+ }
+}
diff --git a/tests/DTO/SiteLinkItemTest.php b/tests/DTO/SiteLinkItemTest.php
new file mode 100644
index 0000000..215cde1
--- /dev/null
+++ b/tests/DTO/SiteLinkItemTest.php
@@ -0,0 +1,43 @@
+ 'Introduction',
+ 'slug' => 'introduction',
+ ]);
+
+ $this->assertSame('Introduction', $item->heading);
+ $this->assertSame('introduction', $item->slug);
+ }
+
+ public function testToArray(): void
+ {
+ $item = new SiteLinkItem(
+ heading: 'Features',
+ slug: 'features',
+ );
+
+ $this->assertSame([
+ 'heading' => 'Features',
+ 'slug' => 'features',
+ ], $item->toArray());
+ }
+
+ public function testRoundtrip(): void
+ {
+ $data = ['heading' => 'FAQ', 'slug' => 'faq'];
+ $item = SiteLinkItem::fromArray($data);
+
+ $this->assertSame($data, $item->toArray());
+ }
+}
diff --git a/tests/DTO/SiteLinkTest.php b/tests/DTO/SiteLinkTest.php
new file mode 100644
index 0000000..8c7f922
--- /dev/null
+++ b/tests/DTO/SiteLinkTest.php
@@ -0,0 +1,81 @@
+ [
+ 'default' => '',
+ 'cs' => '',
+ ],
+ 'list' => [
+ 'default' => [
+ ['heading' => 'Nadpis', 'slug' => 'nadpis'],
+ ['heading' => 'Druhý nadpis', 'slug' => 'druhy-nadpis'],
+ ],
+ 'cs' => [
+ ['heading' => 'Nadpis', 'slug' => 'nadpis'],
+ ],
+ ],
+ ];
+
+ $siteLink = SiteLink::fromArray($data);
+
+ $this->assertSame($data['html']['default'], $siteLink->getHtml(Language::DEFAULT));
+ $this->assertSame($data['html']['cs'], $siteLink->getHtml(Language::CS));
+ $this->assertNull($siteLink->getHtml(Language::SK));
+
+ $defaultList = $siteLink->getList(Language::DEFAULT);
+ $this->assertNotNull($defaultList);
+ $this->assertCount(2, $defaultList);
+ $this->assertInstanceOf(SiteLinkItem::class, $defaultList[0]);
+ $this->assertSame('Nadpis', $defaultList[0]->heading);
+ $this->assertSame('nadpis', $defaultList[0]->slug);
+ $this->assertSame('Druhý nadpis', $defaultList[1]->heading);
+ $this->assertSame('druhy-nadpis', $defaultList[1]->slug);
+
+ $csList = $siteLink->getList(Language::CS);
+ $this->assertNotNull($csList);
+ $this->assertCount(1, $csList);
+
+ $this->assertNull($siteLink->getList(Language::SK));
+ }
+
+ public function testFromArrayEmpty(): void
+ {
+ $siteLink = SiteLink::fromArray([]);
+
+ $this->assertSame([], $siteLink->html);
+ $this->assertSame([], $siteLink->list);
+ $this->assertNull($siteLink->getHtml(Language::DEFAULT));
+ $this->assertNull($siteLink->getList(Language::DEFAULT));
+ }
+
+ public function testSiteLinkItemFromArray(): void
+ {
+ $item = SiteLinkItem::fromArray([
+ 'heading' => 'Test Heading',
+ 'slug' => 'test-heading',
+ ]);
+
+ $this->assertSame('Test Heading', $item->heading);
+ $this->assertSame('test-heading', $item->slug);
+ }
+
+ public function testSiteLinkItemToArray(): void
+ {
+ $item = new SiteLinkItem(heading: 'Test', slug: 'test');
+
+ $this->assertSame(['heading' => 'Test', 'slug' => 'test'], $item->toArray());
+ }
+}
diff --git a/tests/DTO/WebhookPayloadTest.php b/tests/DTO/WebhookPayloadTest.php
new file mode 100644
index 0000000..3d9d382
--- /dev/null
+++ b/tests/DTO/WebhookPayloadTest.php
@@ -0,0 +1,65 @@
+ '2024-01-15T10:30:00.000000Z',
+ 'eshop_id' => 42,
+ ];
+
+ $payload = WebhookPayload::fromArray($data, WebhookEvent::PRODUCTS_UPDATE);
+
+ $this->assertSame(WebhookEvent::PRODUCTS_UPDATE, $payload->event);
+ $this->assertSame(42, $payload->eshopId);
+ $this->assertInstanceOf(\DateTimeInterface::class, $payload->timestamp);
+ }
+
+ public function testFromArrayWithCategoriesUpdate(): void
+ {
+ $data = [
+ 'timestamp' => '2024-06-01T08:00:00Z',
+ 'eshop_id' => 1,
+ ];
+
+ $payload = WebhookPayload::fromArray($data, WebhookEvent::CATEGORIES_UPDATE);
+
+ $this->assertSame(WebhookEvent::CATEGORIES_UPDATE, $payload->event);
+ $this->assertSame(1, $payload->eshopId);
+ }
+
+ public function testFromArrayWithBlogsUpdate(): void
+ {
+ $data = [
+ 'timestamp' => '2024-12-25T00:00:00Z',
+ 'eshop_id' => 999,
+ ];
+
+ $payload = WebhookPayload::fromArray($data, WebhookEvent::BLOGS_UPDATE);
+
+ $this->assertSame(WebhookEvent::BLOGS_UPDATE, $payload->event);
+ $this->assertSame(999, $payload->eshopId);
+ }
+
+ public function testTimestampIsParsedCorrectly(): void
+ {
+ $data = [
+ 'timestamp' => '2024-03-15T14:30:00.000000Z',
+ 'eshop_id' => 5,
+ ];
+
+ $payload = WebhookPayload::fromArray($data, WebhookEvent::PRODUCTS_UPDATE);
+
+ $this->assertSame('2024-03-15', $payload->timestamp->format('Y-m-d'));
+ $this->assertSame('14:30:00', $payload->timestamp->format('H:i:s'));
+ }
+}
diff --git a/tests/Enum/IncludeContentTest.php b/tests/Enum/IncludeContentTest.php
index df7e5b7..2cdd73e 100644
--- a/tests/Enum/IncludeContentTest.php
+++ b/tests/Enum/IncludeContentTest.php
@@ -13,6 +13,8 @@ public function testAllCasesExist(): void
{
$this->assertSame('marketplace', IncludeContent::MARKETPLACE->value);
$this->assertSame('nested', IncludeContent::NESTED->value);
+ $this->assertSame('site_link', IncludeContent::SITE_LINK->value);
+ $this->assertSame('rich_snippet', IncludeContent::RICH_SNIPPET->value);
}
public function testValues(): void
@@ -21,13 +23,17 @@ public function testValues(): void
$this->assertContains('marketplace', $values);
$this->assertContains('nested', $values);
- $this->assertCount(2, $values);
+ $this->assertContains('site_link', $values);
+ $this->assertContains('rich_snippet', $values);
+ $this->assertCount(4, $values);
}
public function testIsValidReturnsTrue(): void
{
$this->assertTrue(IncludeContent::isValid('marketplace'));
$this->assertTrue(IncludeContent::isValid('nested'));
+ $this->assertTrue(IncludeContent::isValid('site_link'));
+ $this->assertTrue(IncludeContent::isValid('rich_snippet'));
}
public function testIsValidReturnsFalse(): void
@@ -41,10 +47,10 @@ public function testIsValidReturnsFalse(): void
public function testCanBeUsedInArray(): void
{
- $include = [IncludeContent::MARKETPLACE, IncludeContent::NESTED];
+ $include = [IncludeContent::MARKETPLACE, IncludeContent::NESTED, IncludeContent::SITE_LINK, IncludeContent::RICH_SNIPPET];
$values = array_map(fn(IncludeContent $item) => $item->value, $include);
- $this->assertSame(['marketplace', 'nested'], $values);
+ $this->assertSame(['marketplace', 'nested', 'site_link', 'rich_snippet'], $values);
}
}
diff --git a/tests/Enum/LanguageTest.php b/tests/Enum/LanguageTest.php
index 3b5d7ae..39532a0 100644
--- a/tests/Enum/LanguageTest.php
+++ b/tests/Enum/LanguageTest.php
@@ -20,6 +20,11 @@ public function testAllCasesExist(): void
$this->assertSame('hu', Language::HU->value);
}
+ public function testAllCase(): void
+ {
+ $this->assertSame('all', Language::ALL->value);
+ }
+
public function testValues(): void
{
$values = Language::values();
@@ -31,7 +36,8 @@ public function testValues(): void
$this->assertContains('de', $values);
$this->assertContains('pl', $values);
$this->assertContains('hu', $values);
- $this->assertCount(7, $values);
+ $this->assertContains('all', $values);
+ $this->assertCount(8, $values);
}
public function testIsValidReturnsTrue(): void
@@ -40,6 +46,7 @@ public function testIsValidReturnsTrue(): void
$this->assertTrue(Language::isValid('cs'));
$this->assertTrue(Language::isValid('sk'));
$this->assertTrue(Language::isValid('en'));
+ $this->assertTrue(Language::isValid('all'));
}
public function testIsValidReturnsFalse(): void
diff --git a/tests/Exception/ApiExceptionTest.php b/tests/Exception/ApiExceptionTest.php
new file mode 100644
index 0000000..8097c39
--- /dev/null
+++ b/tests/Exception/ApiExceptionTest.php
@@ -0,0 +1,75 @@
+assertSame('Authorization token required or invalid', $exception->getMessage());
+ $this->assertSame(401, $exception->httpCode);
+ $this->assertNull($exception->responseBody);
+ }
+
+ public function testFromResponseWithMessage(): void
+ {
+ $body = ['message' => 'Not Found'];
+ $exception = ApiException::fromResponse(404, $body);
+
+ $this->assertSame('Not Found', $exception->getMessage());
+ $this->assertSame(404, $exception->httpCode);
+ $this->assertSame($body, $exception->responseBody);
+ }
+
+ public function testFromResponseWithError(): void
+ {
+ $body = ['error' => 'Server Error'];
+ $exception = ApiException::fromResponse(500, $body);
+
+ $this->assertSame('Server Error', $exception->getMessage());
+ $this->assertSame(500, $exception->httpCode);
+ }
+
+ public function testFromResponseWithNullBody(): void
+ {
+ $exception = ApiException::fromResponse(502, null);
+
+ $this->assertSame('API request failed with HTTP 502', $exception->getMessage());
+ $this->assertSame(502, $exception->httpCode);
+ $this->assertNull($exception->responseBody);
+ }
+
+ public function testFromResponseWithEmptyBody(): void
+ {
+ $exception = ApiException::fromResponse(500, []);
+
+ $this->assertSame('API request failed with HTTP 500', $exception->getMessage());
+ $this->assertSame(500, $exception->httpCode);
+ }
+
+ public function testFromResponsePrefersMessageOverError(): void
+ {
+ $body = ['message' => 'Detailed message', 'error' => 'Generic error'];
+ $exception = ApiException::fromResponse(422, $body);
+
+ $this->assertSame('Detailed message', $exception->getMessage());
+ }
+
+ public function testConstructorStoresAllProperties(): void
+ {
+ $body = ['detail' => 'some info'];
+ $exception = new ApiException('Custom error', 418, $body);
+
+ $this->assertSame('Custom error', $exception->getMessage());
+ $this->assertSame(418, $exception->httpCode);
+ $this->assertSame(418, $exception->getCode());
+ $this->assertSame($body, $exception->responseBody);
+ }
+}
diff --git a/tests/Exception/ValidationExceptionTest.php b/tests/Exception/ValidationExceptionTest.php
new file mode 100644
index 0000000..b958230
--- /dev/null
+++ b/tests/Exception/ValidationExceptionTest.php
@@ -0,0 +1,45 @@
+assertSame('Payload cannot be empty', $exception->getMessage());
+ $this->assertArrayHasKey('bulk', $exception->errors);
+ $this->assertContains('At least one item is required', $exception->errors['bulk']);
+ }
+
+ public function testTooManyItems(): void
+ {
+ $exception = ValidationException::tooManyItems(150, 100);
+
+ $this->assertSame('Too many items: 150 provided, maximum is 100', $exception->getMessage());
+ $this->assertArrayHasKey('bulk', $exception->errors);
+ $this->assertStringContainsString('Maximum 100 items', $exception->errors['bulk'][0]);
+ }
+
+ public function testCustomErrors(): void
+ {
+ $errors = ['field' => ['Error 1', 'Error 2']];
+ $exception = new ValidationException('Validation failed', $errors);
+
+ $this->assertSame('Validation failed', $exception->getMessage());
+ $this->assertSame($errors, $exception->errors);
+ }
+
+ public function testDefaultEmptyErrors(): void
+ {
+ $exception = new ValidationException('Some error');
+
+ $this->assertSame([], $exception->errors);
+ }
+}
diff --git a/tests/Exception/WebhookExceptionTest.php b/tests/Exception/WebhookExceptionTest.php
new file mode 100644
index 0000000..1b89a1a
--- /dev/null
+++ b/tests/Exception/WebhookExceptionTest.php
@@ -0,0 +1,39 @@
+assertSame('Invalid webhook signature', $exception->getMessage());
+ }
+
+ public function testInvalidPayload(): void
+ {
+ $exception = WebhookException::invalidPayload();
+
+ $this->assertSame('Invalid webhook payload - could not parse JSON', $exception->getMessage());
+ }
+
+ public function testMissingSignature(): void
+ {
+ $exception = WebhookException::missingSignature();
+
+ $this->assertSame('Missing webhook signature header', $exception->getMessage());
+ }
+
+ public function testUnknownEvent(): void
+ {
+ $exception = WebhookException::unknownEvent('Unknown.event');
+
+ $this->assertSame('Unknown webhook event: Unknown.event', $exception->getMessage());
+ }
+}
diff --git a/tests/PoboClientTest.php b/tests/PoboClientTest.php
index efb0f76..8eb1296 100644
--- a/tests/PoboClientTest.php
+++ b/tests/PoboClientTest.php
@@ -13,6 +13,7 @@
use Pobo\Sdk\DTO\ParameterValue;
use Pobo\Sdk\DTO\Product;
use Pobo\Sdk\Enum\IncludeContent;
+use Pobo\Sdk\Enum\Language;
use Pobo\Sdk\Exception\ValidationException;
use Pobo\Sdk\PoboClient;
@@ -443,4 +444,108 @@ public function testBuildQueryParamsWithMixedEnumAndString(): void
$this->assertSame('?include=marketplace%2Cnested', $query);
}
+
+ public function testBuildQueryParamsWithLangAll(): void
+ {
+ $method = new \ReflectionMethod(PoboClient::class, 'buildQueryParams');
+
+ $query = $method->invoke(
+ $this->client,
+ null,
+ null,
+ null,
+ null,
+ null,
+ [Language::ALL],
+ );
+
+ $this->assertSame('?lang=all', $query);
+ }
+
+ public function testBuildQueryParamsWithLangSpecific(): void
+ {
+ $method = new \ReflectionMethod(PoboClient::class, 'buildQueryParams');
+
+ $query = $method->invoke(
+ $this->client,
+ null,
+ null,
+ null,
+ null,
+ null,
+ [Language::DEFAULT, Language::CS, Language::SK],
+ );
+
+ $this->assertSame('?lang=default%2Ccs%2Csk', $query);
+ }
+
+ public function testBuildQueryParamsWithLangString(): void
+ {
+ $method = new \ReflectionMethod(PoboClient::class, 'buildQueryParams');
+
+ $query = $method->invoke(
+ $this->client,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ['default', 'cs'],
+ );
+
+ $this->assertSame('?lang=default%2Ccs', $query);
+ }
+
+ public function testBuildQueryParamsWithLangNull(): void
+ {
+ $method = new \ReflectionMethod(PoboClient::class, 'buildQueryParams');
+
+ $query = $method->invoke(
+ $this->client,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ );
+
+ $this->assertSame('', $query);
+ }
+
+ public function testBuildQueryParamsWithLangAndInclude(): void
+ {
+ $method = new \ReflectionMethod(PoboClient::class, 'buildQueryParams');
+
+ $query = $method->invoke(
+ $this->client,
+ 1,
+ 50,
+ null,
+ null,
+ [IncludeContent::RICH_SNIPPET, IncludeContent::SITE_LINK],
+ [Language::ALL],
+ );
+
+ $this->assertStringContainsString('page=1', $query);
+ $this->assertStringContainsString('per_page=50', $query);
+ $this->assertStringContainsString('include=rich_snippet%2Csite_link', $query);
+ $this->assertStringContainsString('lang=all', $query);
+ }
+
+ public function testBuildQueryParamsWithIncludeSiteLinkAndRichSnippet(): void
+ {
+ $method = new \ReflectionMethod(PoboClient::class, 'buildQueryParams');
+
+ $query = $method->invoke(
+ $this->client,
+ null,
+ null,
+ null,
+ null,
+ [IncludeContent::SITE_LINK, IncludeContent::RICH_SNIPPET],
+ );
+
+ $this->assertSame('?include=site_link%2Crich_snippet', $query);
+ }
}
diff --git a/tests/ProductTest.php b/tests/ProductTest.php
index cd1dd7f..5170f22 100644
--- a/tests/ProductTest.php
+++ b/tests/ProductTest.php
@@ -8,6 +8,8 @@
use Pobo\Sdk\DTO\Content;
use Pobo\Sdk\DTO\LocalizedString;
use Pobo\Sdk\DTO\Product;
+use Pobo\Sdk\DTO\RichSnippet;
+use Pobo\Sdk\DTO\SiteLink;
use Pobo\Sdk\Enum\Language;
final class ProductTest extends TestCase
@@ -223,4 +225,65 @@ public function testProductWithGuidAndIsLoaded(): void
$this->assertSame('550e8400-e29b-41d4-a716-446655440000', $product->guid);
$this->assertTrue($product->isLoaded);
}
+
+ public function testProductFromArrayWithSiteLink(): void
+ {
+ $data = [
+ 'id' => 'PROD-SITE',
+ 'is_visible' => true,
+ 'name' => ['default' => 'Product'],
+ 'url' => ['default' => 'https://example.com'],
+ 'site_link' => [
+ 'html' => ['default' => ''],
+ 'list' => [
+ 'default' => [
+ ['heading' => 'Nadpis', 'slug' => 'nadpis'],
+ ],
+ ],
+ ],
+ ];
+
+ $product = Product::fromArray($data);
+
+ $this->assertInstanceOf(SiteLink::class, $product->siteLink);
+ $this->assertStringContainsString('pobo-site-link', $product->siteLink->getHtml(Language::DEFAULT));
+ $this->assertSame('nadpis', $product->siteLink->getList(Language::DEFAULT)[0]->slug);
+ }
+
+ public function testProductFromArrayWithRichSnippet(): void
+ {
+ $data = [
+ 'id' => 'PROD-RICH',
+ 'is_visible' => true,
+ 'name' => ['default' => 'Product'],
+ 'url' => ['default' => 'https://example.com'],
+ 'rich_snippet' => [
+ 'html' => ['default' => ''],
+ 'json' => [
+ 'default' => ['@type' => 'FAQPage', 'mainEntity' => []],
+ ],
+ ],
+ ];
+
+ $product = Product::fromArray($data);
+
+ $this->assertInstanceOf(RichSnippet::class, $product->richSnippet);
+ $this->assertStringContainsString('FAQPage', $product->richSnippet->getHtml(Language::DEFAULT));
+ $this->assertSame('FAQPage', $product->richSnippet->getJson(Language::DEFAULT)['@type']);
+ }
+
+ public function testProductFromArrayWithoutSiteLinkAndRichSnippet(): void
+ {
+ $data = [
+ 'id' => 'PROD-PLAIN',
+ 'is_visible' => true,
+ 'name' => ['default' => 'Product'],
+ 'url' => ['default' => 'https://example.com'],
+ ];
+
+ $product = Product::fromArray($data);
+
+ $this->assertNull($product->siteLink);
+ $this->assertNull($product->richSnippet);
+ }
}