diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0ad534a..a76021b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -5,6 +5,7 @@ on:
branches: [main, master]
pull_request:
branches: [main, master]
+ workflow_dispatch:
jobs:
test:
@@ -34,8 +35,34 @@ jobs:
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- - name: Run tests
- run: vendor/bin/phpunit --colors=always
+ - name: Run unit tests
+ run: vendor/bin/phpunit --colors=always --testsuite=Unit
+
+ 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
@@ -57,3 +84,39 @@ jobs:
- name: Check PHP syntax
run: find src tests -name "*.php" -exec php -l {} \;
+
+ integration:
+ runs-on: ubuntu-latest
+ name: Integration (real API)
+
+ # Skip on PRs from forks (they have no access to repository secrets).
+ # Runs on push to master/main, on PRs from the same repository, and on manual dispatch.
+ if: |
+ github.event_name == 'workflow_dispatch'
+ || github.event_name == 'push'
+ || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
+
+ # Serialize integration runs so concurrent jobs do not race on the same test e-shop.
+ concurrency:
+ group: integration-${{ github.ref }}
+ cancel-in-progress: false
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: curl, json
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Run integration tests
+ env:
+ POBO_API_TOKEN: ${{ secrets.POBO_API_TOKEN }}
+ POBO_BASE_URL: https://api.pobo.space
+ run: vendor/bin/phpunit --colors=always --testsuite=Integration --display-skipped
diff --git a/README.md b/README.md
index 24edae6..767a214 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,12 @@ composer require pobo-builder/php-sdk
## Quick Start
+### Authentication
+
+The SDK uses bearer token authentication. You can obtain your **API token** and **webhook secret** in your Pobo administration under the e-shop settings (REST API section).
+
+The token is sent as `Authorization: Bearer {token}` on every request and is bound to a single e-shop.
+
### API Client
```php
@@ -43,6 +49,18 @@ $client = new PoboClient(
4. Blogs (no dependencies)
```
+### Multilang Validation Rules
+
+The API enforces a "language consistency" rule across multilang fields:
+
+- `name.default` and `url.default` are **always required**.
+- If any language key (e.g. `sk`) appears in any multilang field of an item, then `name.{lang}` and `url.{lang}` become required for that item.
+- Other multilang fields (`short_description`, `description`, `seo_title`, `seo_description`) may have `null` values, but the language key must be present in the field if used elsewhere.
+- All `url.*` values must start with `https://`.
+- Records that fail validation are **skipped**, not aborted — the response reports them in `errors[]` with `index`, `id`, and a list of messages. The rest of the batch is imported.
+
+> **Tip:** The `LocalizedString::withTranslation()` helper sets the value for a given language; use it consistently across `name`, `url` and other fields when adding a new locale to an item.
+
### Import Parameters
```php
@@ -234,6 +252,8 @@ echo sprintf('Deleted: %d', $result->deleted);
## Export
+> **Important:** When `isEdited` is not provided, the server applies a default of `true` and returns **only products/categories/blogs that have been edited in Pobo** (`is_loaded = true`). To export everything, pass `isEdited: false` explicitly.
+
### Export Products
```php
@@ -250,13 +270,16 @@ foreach ($client->iterateProducts() as $product) {
echo sprintf("%s: %s\n", $product->id, $product->name->getDefault());
}
-// Filter by last update time
+// Filter by last update time (sent to API as Y-m-d H:i:s in server timezone)
$since = new DateTime('2024-01-01 00:00:00');
$response = $client->getProducts(lastUpdateFrom: $since);
-// Filter only edited products
+// Filter only edited products (= is_loaded on server side)
$response = $client->getProducts(isEdited: true);
+// Get ALL products including ones never edited in Pobo
+$response = $client->getProducts(isEdited: false);
+
// Include optional content (marketplace HTML, raw widget JSON)
use Pobo\Sdk\Enum\IncludeContent;
@@ -293,14 +316,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,8 +395,79 @@ foreach ($client->iterateBlogs(include: [IncludeContent::MARKETPLACE]) as $blog)
}
```
+## Site Links
+
+Anchor navigation generated from H2 headings in content widgets. Available for products and blogs.
+
+> **E-shop config required:** Site links are returned only if `enable_site_link` is enabled on the e-shop in Pobo administration. Without that flag the `site_link` field will be empty even when `IncludeContent::SITE_LINK` is requested.
+
+```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.
+
+> **E-shop config required:** Rich snippets are returned only if `enable_rich_snippet` is enabled on the e-shop in Pobo administration. Without that flag the `rich_snippet` field will be empty even when `IncludeContent::RICH_SNIPPET` is requested.
+
+```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
+Pobo can notify your application when content is changed in the administration. You register a webhook URL in the Pobo e-shop settings and receive `POST` requests signed with HMAC-SHA256.
+
+### Events
+
+| Event | Constant | Fired when |
+|----------------------|-----------------------------------|----------------------------------------------------|
+| `Products.update` | `WebhookEvent::PRODUCTS_UPDATE` | Any product content was edited and saved in Pobo |
+| `Categories.update` | `WebhookEvent::CATEGORIES_UPDATE` | Any category content was edited and saved in Pobo |
+| `Blogs.update` | `WebhookEvent::BLOGS_UPDATE` | Any blog content was edited and saved in Pobo |
+
+Each request carries:
+
+- Header `X-Webhook-Signature` — HMAC-SHA256 of the raw body using your webhook secret.
+- Header `X-Webhook-Event` — event name (informational; the SDK reads the event from the body).
+- JSON body — `{ "event": "...", "timestamp": "ISO-8601", "eshop_id": 123 }`.
+
+The webhook does **not** carry the changed entities themselves; it is a notification to trigger a sync via the export endpoints (`getProducts` / `iterateProducts` etc., typically combined with `lastUpdateFrom`).
+
### Basic Usage
```php
@@ -408,6 +530,16 @@ try {
}
```
+### Exception Types
+
+| Exception | Thrown when |
+|------------------------|---------------------------------------------------------------------------------------------------------------------------|
+| `ValidationException` | Local pre-flight checks fail — empty payload or **more than 100 items** in a bulk import/delete. The HTTP request is not sent. |
+| `ApiException` | The HTTP request fails (network/cURL error) or the API returns `>= 400`. `httpCode` and parsed `responseBody` are exposed. |
+| `WebhookException` | The webhook signature is missing/invalid, the body cannot be parsed, or the event name is unknown. |
+
+> **Note:** Per-item validation failures during bulk import (e.g. invalid URL, missing language) do **not** raise `ApiException`. They appear in `$result->errors[]` while the rest of the batch is processed. Always inspect `$result->hasErrors()` after a successful call.
+
## Localized Strings
```php
@@ -429,6 +561,8 @@ $name->get(Language::CS); // 'Czech Name'
$name->toArray(); // ['default' => '...', 'cs' => '...', ...]
```
+> **About `default`:** `default` is a separate, virtual locale — **not an alias for `cs` or any other language**. It is the fallback content used by Pobo when a specific language variant is missing. `name.default` and `url.default` are required on import; per-language values (`cs`, `sk`, …) are optional and follow the multilang validation rule above.
+
### Supported Languages
| Code | Language |
@@ -443,21 +577,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,9 +604,37 @@ $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 |
+## Testing
+
+### Unit Tests
+
+```bash
+vendor/bin/phpunit --testsuite=Unit
+```
+
+Unit tests are pure (no network) and run on every CI build across PHP 8.1–8.5.
+
+### Integration Tests
+
+Integration tests hit the real `api.pobo.space` API and verify that the SDK's wire format is in sync with the server. They require a dedicated test e-shop and a valid REST API token.
+
+```bash
+POBO_API_TOKEN="your-test-eshop-token" vendor/bin/phpunit --testsuite=Integration
+```
+
+If `POBO_API_TOKEN` is not set, all tests in the suite are marked **skipped** (not failed).
+
+The CI workflow runs integration tests automatically on:
+
+- pushes to `master`,
+- PRs **from the same repository** (PRs from forks are skipped — secrets are not exposed there),
+- manual dispatch (`workflow_dispatch`).
+
+Each run uses a unique ID prefix combining `GITHUB_RUN_ID` and a random suffix, and `tearDown()` removes anything the test created. Two PRs running simultaneously cannot collide.
+
## License
MIT License
diff --git a/composer.json b/composer.json
index 2564fce..867e722 100644
--- a/composer.json
+++ b/composer.json
@@ -13,9 +13,9 @@
"homepage": "https://github.com/pobo-builder/php-sdk",
"authors": [
{
- "name": "Pobo",
+ "name": "Tomas Smetka",
"email": "tomas@pobo.cz",
- "homepage": "https://pobo.cz"
+ "homepage": "https://pobo.space"
}
],
"support": {
@@ -28,7 +28,9 @@
"ext-json": "*"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^10.5",
+ "phpstan/phpstan": "^2.1",
+ "fakerphp/faker": "^1.23"
},
"autoload": {
"psr-4": {
@@ -41,7 +43,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/phpunit.xml b/phpunit.xml
index 6f244f5..5dd9b71 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -7,6 +7,10 @@
tests
+ tests/Integration
+
+
+ tests/Integration
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/Integration/BulkImportTest.php b/tests/Integration/BulkImportTest.php
new file mode 100644
index 0000000..2ee20d0
--- /dev/null
+++ b/tests/Integration/BulkImportTest.php
@@ -0,0 +1,96 @@
+makeProduct();
+ }
+ $expectedIds = array_map(fn($p) => $p->id, $products);
+
+ $importResult = $this->client->importProducts($products);
+
+ self::assertTrue($importResult->success);
+ self::assertSame(0, $importResult->skipped, sprintf('Unexpected errors: %s', json_encode($importResult->errors)));
+ self::assertFalse($importResult->hasErrors());
+ self::assertSame(self::BULK_SIZE, $importResult->imported + $importResult->updated);
+
+ $foundIds = [];
+ foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $product) {
+ if (in_array($product->id, $expectedIds, true)) {
+ $foundIds[] = $product->id;
+ }
+ if (count($foundIds) === self::BULK_SIZE) {
+ break;
+ }
+ }
+
+ self::assertCount(self::BULK_SIZE, $foundIds, 'All imported products should be retrievable.');
+ }
+
+ public function testBulkImportCategories(): void
+ {
+ $categories = [];
+ for ($i = 0; $i < self::BULK_SIZE; $i++) {
+ $categories[] = $this->makeCategory();
+ }
+ $expectedIds = array_map(fn($c) => $c->id, $categories);
+
+ $importResult = $this->client->importCategories($categories);
+
+ self::assertTrue($importResult->success);
+ self::assertSame(0, $importResult->skipped, sprintf('Unexpected errors: %s', json_encode($importResult->errors)));
+ self::assertFalse($importResult->hasErrors());
+ self::assertSame(self::BULK_SIZE, $importResult->imported + $importResult->updated);
+
+ $foundIds = [];
+ foreach ($this->client->iterateCategories(isEdited: false, lang: [Language::ALL]) as $category) {
+ if (in_array($category->id, $expectedIds, true)) {
+ $foundIds[] = $category->id;
+ }
+ if (count($foundIds) === self::BULK_SIZE) {
+ break;
+ }
+ }
+
+ self::assertCount(self::BULK_SIZE, $foundIds, 'All imported categories should be retrievable.');
+ }
+
+ public function testBulkImportBlogs(): void
+ {
+ $blogs = [];
+ for ($i = 0; $i < self::BULK_SIZE; $i++) {
+ $blogs[] = $this->makeBlog();
+ }
+ $expectedIds = array_map(fn($b) => $b->id, $blogs);
+
+ $importResult = $this->client->importBlogs($blogs);
+
+ self::assertTrue($importResult->success);
+ self::assertSame(0, $importResult->skipped, sprintf('Unexpected errors: %s', json_encode($importResult->errors)));
+ self::assertFalse($importResult->hasErrors());
+ self::assertSame(self::BULK_SIZE, $importResult->imported + $importResult->updated);
+
+ $foundIds = [];
+ foreach ($this->client->iterateBlogs(isEdited: false, lang: [Language::ALL]) as $blog) {
+ if (in_array($blog->id, $expectedIds, true)) {
+ $foundIds[] = $blog->id;
+ }
+ if (count($foundIds) === self::BULK_SIZE) {
+ break;
+ }
+ }
+
+ self::assertCount(self::BULK_SIZE, $foundIds, 'All imported blogs should be retrievable.');
+ }
+}
diff --git a/tests/Integration/BulkUpdateTest.php b/tests/Integration/BulkUpdateTest.php
new file mode 100644
index 0000000..c66ca53
--- /dev/null
+++ b/tests/Integration/BulkUpdateTest.php
@@ -0,0 +1,144 @@
+makeProduct();
+ }
+
+ $firstResult = $this->client->importProducts($originals);
+
+ self::assertSame(self::BULK_SIZE, $firstResult->imported, 'First pass should be a fresh import.');
+ self::assertSame(0, $firstResult->updated);
+ self::assertFalse($firstResult->hasErrors());
+
+ // Re-build the same items with the same IDs but fresh faker content.
+ $updates = array_map(fn($product) => $this->makeProduct($product->id), $originals);
+
+ $secondResult = $this->client->importProducts($updates);
+
+ self::assertSame(0, $secondResult->imported, 'Re-import must not create new records.');
+ self::assertSame(self::BULK_SIZE, $secondResult->updated);
+ self::assertFalse($secondResult->hasErrors());
+
+ // Verify the new content actually propagated to the server.
+ $expectedNamesById = [];
+ foreach ($updates as $product) {
+ $expectedNamesById[$product->id] = $product->name->getDefault();
+ }
+
+ $verified = 0;
+ foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $product) {
+ if (isset($expectedNamesById[$product->id])) {
+ self::assertSame(
+ $expectedNamesById[$product->id],
+ $product->name->getDefault(),
+ sprintf('Product %s should reflect the updated name.', $product->id),
+ );
+ $verified++;
+ }
+ if ($verified === self::BULK_SIZE) {
+ break;
+ }
+ }
+
+ self::assertSame(self::BULK_SIZE, $verified, 'All updated products should be readable.');
+ }
+
+ public function testReimportCategoriesTriggersUpdate(): void
+ {
+ $originals = [];
+ for ($i = 0; $i < self::BULK_SIZE; $i++) {
+ $originals[] = $this->makeCategory();
+ }
+
+ $firstResult = $this->client->importCategories($originals);
+
+ self::assertSame(self::BULK_SIZE, $firstResult->imported);
+ self::assertSame(0, $firstResult->updated);
+ self::assertFalse($firstResult->hasErrors());
+
+ $updates = array_map(fn($category) => $this->makeCategory($category->id), $originals);
+
+ $secondResult = $this->client->importCategories($updates);
+
+ self::assertSame(0, $secondResult->imported);
+ self::assertSame(self::BULK_SIZE, $secondResult->updated);
+ self::assertFalse($secondResult->hasErrors());
+
+ $expectedNamesById = [];
+ foreach ($updates as $category) {
+ $expectedNamesById[$category->id] = $category->name->getDefault();
+ }
+
+ $verified = 0;
+ foreach ($this->client->iterateCategories(isEdited: false, lang: [Language::ALL]) as $category) {
+ if (isset($expectedNamesById[$category->id])) {
+ self::assertSame(
+ $expectedNamesById[$category->id],
+ $category->name->getDefault(),
+ );
+ $verified++;
+ }
+ if ($verified === self::BULK_SIZE) {
+ break;
+ }
+ }
+
+ self::assertSame(self::BULK_SIZE, $verified);
+ }
+
+ public function testReimportBlogsTriggersUpdate(): void
+ {
+ $originals = [];
+ for ($i = 0; $i < self::BULK_SIZE; $i++) {
+ $originals[] = $this->makeBlog();
+ }
+
+ $firstResult = $this->client->importBlogs($originals);
+
+ self::assertSame(self::BULK_SIZE, $firstResult->imported);
+ self::assertSame(0, $firstResult->updated);
+ self::assertFalse($firstResult->hasErrors());
+
+ $updates = array_map(fn($blog) => $this->makeBlog($blog->id), $originals);
+
+ $secondResult = $this->client->importBlogs($updates);
+
+ self::assertSame(0, $secondResult->imported);
+ self::assertSame(self::BULK_SIZE, $secondResult->updated);
+ self::assertFalse($secondResult->hasErrors());
+
+ $expectedNamesById = [];
+ foreach ($updates as $blog) {
+ $expectedNamesById[$blog->id] = $blog->name->getDefault();
+ }
+
+ $verified = 0;
+ foreach ($this->client->iterateBlogs(isEdited: false, lang: [Language::ALL]) as $blog) {
+ if (isset($expectedNamesById[$blog->id])) {
+ self::assertSame(
+ $expectedNamesById[$blog->id],
+ $blog->name->getDefault(),
+ );
+ $verified++;
+ }
+ if ($verified === self::BULK_SIZE) {
+ break;
+ }
+ }
+
+ self::assertSame(self::BULK_SIZE, $verified);
+ }
+}
diff --git a/tests/Integration/CategoryLifecycleTest.php b/tests/Integration/CategoryLifecycleTest.php
new file mode 100644
index 0000000..4a4da99
--- /dev/null
+++ b/tests/Integration/CategoryLifecycleTest.php
@@ -0,0 +1,55 @@
+uniqueId('cat');
+ $this->trackCategory($categoryId);
+
+ $category = new Category(
+ id: $categoryId,
+ isVisible: true,
+ name: LocalizedString::create('SDK Integration Test Category')
+ ->withTranslation(Language::CS, 'SDK Integration Test Kategorie'),
+ url: LocalizedString::create(sprintf('https://example.com/%s', $categoryId))
+ ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $categoryId)),
+ description: LocalizedString::create('Created by SDK CI
')
+ ->withTranslation(Language::CS, 'Vytvořeno SDK CI
'),
+ );
+
+ $importResult = $this->client->importCategories([$category]);
+
+ self::assertTrue($importResult->success);
+ self::assertSame(0, $importResult->skipped, sprintf('Unexpected skipped items: %s', json_encode($importResult->errors)));
+ self::assertFalse($importResult->hasErrors());
+ self::assertSame(1, $importResult->imported + $importResult->updated);
+
+ $found = null;
+ foreach ($this->client->iterateCategories(isEdited: false, lang: [Language::ALL]) as $candidate) {
+ if ($candidate->id === $categoryId) {
+ $found = $candidate;
+ break;
+ }
+ }
+
+ self::assertNotNull($found, sprintf('Imported category %s was not returned by iterateCategories().', $categoryId));
+ self::assertSame('SDK Integration Test Category', $found->name->getDefault());
+
+ $deleteResult = $this->client->deleteCategories([$categoryId]);
+
+ self::assertTrue($deleteResult->success);
+ self::assertSame(1, $deleteResult->deleted);
+ self::assertFalse($deleteResult->hasErrors());
+
+ $this->untrackCategory($categoryId);
+ }
+}
diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php
new file mode 100644
index 0000000..8f43f08
--- /dev/null
+++ b/tests/Integration/IntegrationTestCase.php
@@ -0,0 +1,215 @@
+ */
+ private array $productIdsToCleanup = [];
+
+ /** @var array */
+ private array $categoryIdsToCleanup = [];
+
+ /** @var array */
+ private array $blogIdsToCleanup = [];
+
+ protected function setUp(): void
+ {
+ $token = getenv('POBO_API_TOKEN');
+
+ if ($token === false || $token === '') {
+ self::markTestSkipped('Integration tests require POBO_API_TOKEN environment variable.');
+ }
+
+ $baseUrl = getenv('POBO_BASE_URL');
+ if ($baseUrl === false || $baseUrl === '') {
+ $baseUrl = 'https://api.pobo.space';
+ }
+
+ $this->client = new PoboClient(
+ apiToken: $token,
+ baseUrl: $baseUrl,
+ timeout: 60,
+ );
+
+ $runId = getenv('GITHUB_RUN_ID');
+ if ($runId === false || $runId === '') {
+ $runId = (string) getmypid();
+ }
+ $this->idPrefix = sprintf('CI-%s-%s', $runId, bin2hex(random_bytes(3)));
+
+ $this->faker = FakerFactory::create('en_US');
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->productIdsToCleanup !== []) {
+ try {
+ $this->client->deleteProducts($this->productIdsToCleanup);
+ } catch (\Throwable) {
+ // Cleanup is best-effort; don't mask the actual test failure.
+ }
+ }
+
+ if ($this->categoryIdsToCleanup !== []) {
+ try {
+ $this->client->deleteCategories($this->categoryIdsToCleanup);
+ } catch (\Throwable) {
+ }
+ }
+
+ if ($this->blogIdsToCleanup !== []) {
+ try {
+ $this->client->deleteBlogs($this->blogIdsToCleanup);
+ } catch (\Throwable) {
+ }
+ }
+ }
+
+ protected function trackProduct(string $id): void
+ {
+ $this->productIdsToCleanup[] = $id;
+ }
+
+ protected function untrackProduct(string $id): void
+ {
+ $this->productIdsToCleanup = array_values(array_filter(
+ $this->productIdsToCleanup,
+ fn(string $existing) => $existing !== $id,
+ ));
+ }
+
+ protected function trackCategory(string $id): void
+ {
+ $this->categoryIdsToCleanup[] = $id;
+ }
+
+ protected function untrackCategory(string $id): void
+ {
+ $this->categoryIdsToCleanup = array_values(array_filter(
+ $this->categoryIdsToCleanup,
+ fn(string $existing) => $existing !== $id,
+ ));
+ }
+
+ protected function trackBlog(string $id): void
+ {
+ $this->blogIdsToCleanup[] = $id;
+ }
+
+ protected function untrackBlog(string $id): void
+ {
+ $this->blogIdsToCleanup = array_values(array_filter(
+ $this->blogIdsToCleanup,
+ fn(string $existing) => $existing !== $id,
+ ));
+ }
+
+ protected function uniqueId(string $kind): string
+ {
+ return sprintf('%s-%s-%s', $this->idPrefix, $kind, bin2hex(random_bytes(2)));
+ }
+
+ /**
+ * Build a Product with realistic faker content. If $id is null, a new unique
+ * ID is generated and tracked for cleanup. If $id is provided, the caller is
+ * responsible for tracking it (typical for update tests reusing existing IDs).
+ */
+ protected function makeProduct(?string $id = null): Product
+ {
+ if ($id === null) {
+ $id = $this->uniqueId('prod');
+ $this->trackProduct($id);
+ }
+
+ $nameEn = $this->faker->unique()->words(3, true);
+ $nameCs = sprintf('Produkt %s', $this->faker->unique()->word());
+ $slug = $this->faker->slug(2);
+
+ return new Product(
+ id: $id,
+ isVisible: true,
+ name: LocalizedString::create($nameEn)->withTranslation(Language::CS, $nameCs),
+ url: LocalizedString::create(sprintf('https://example.com/%s', $slug))
+ ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $slug)),
+ shortDescription: LocalizedString::create($this->faker->sentence(8))
+ ->withTranslation(Language::CS, $this->faker->sentence(8)),
+ description: LocalizedString::create(sprintf('%s
', $this->faker->paragraph(3)))
+ ->withTranslation(Language::CS, sprintf('%s
', $this->faker->paragraph(3))),
+ );
+ }
+
+ /**
+ * Build a Category with realistic faker content. See {@see makeProduct()} for ID behavior.
+ */
+ protected function makeCategory(?string $id = null): Category
+ {
+ if ($id === null) {
+ $id = $this->uniqueId('cat');
+ $this->trackCategory($id);
+ }
+
+ $nameEn = $this->faker->unique()->words(2, true);
+ $nameCs = sprintf('Kategorie %s', $this->faker->unique()->word());
+ $slug = $this->faker->slug(2);
+
+ return new Category(
+ id: $id,
+ isVisible: true,
+ name: LocalizedString::create($nameEn)->withTranslation(Language::CS, $nameCs),
+ url: LocalizedString::create(sprintf('https://example.com/%s', $slug))
+ ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $slug)),
+ description: LocalizedString::create(sprintf('%s
', $this->faker->paragraph(2)))
+ ->withTranslation(Language::CS, sprintf('%s
', $this->faker->paragraph(2))),
+ );
+ }
+
+ /**
+ * Build a Blog with realistic faker content. See {@see makeProduct()} for ID behavior.
+ */
+ protected function makeBlog(?string $id = null): Blog
+ {
+ if ($id === null) {
+ $id = $this->uniqueId('blog');
+ $this->trackBlog($id);
+ }
+
+ $titleEn = $this->faker->unique()->sentence(4);
+ $titleCs = sprintf('Článek %s', $this->faker->unique()->word());
+ $slug = $this->faker->slug(3);
+
+ return new Blog(
+ id: $id,
+ isVisible: true,
+ name: LocalizedString::create($titleEn)->withTranslation(Language::CS, $titleCs),
+ url: LocalizedString::create(sprintf('https://example.com/blog/%s', $slug))
+ ->withTranslation(Language::CS, sprintf('https://example.com/cs/blog/%s', $slug)),
+ category: $this->faker->randomElement(['news', 'tips', 'reviews', 'guides']),
+ description: LocalizedString::create(sprintf('%s
', $this->faker->paragraph(4)))
+ ->withTranslation(Language::CS, sprintf('%s
', $this->faker->paragraph(4))),
+ );
+ }
+}
diff --git a/tests/Integration/ProductLifecycleTest.php b/tests/Integration/ProductLifecycleTest.php
new file mode 100644
index 0000000..400821c
--- /dev/null
+++ b/tests/Integration/ProductLifecycleTest.php
@@ -0,0 +1,57 @@
+uniqueId('prod');
+ $this->trackProduct($productId);
+
+ $product = new Product(
+ id: $productId,
+ isVisible: true,
+ name: LocalizedString::create('SDK Integration Test Product')
+ ->withTranslation(Language::CS, 'SDK Integration Test Produkt'),
+ url: LocalizedString::create(sprintf('https://example.com/%s', $productId))
+ ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $productId)),
+ shortDescription: LocalizedString::create('Created by SDK CI')
+ ->withTranslation(Language::CS, 'Vytvořeno SDK CI'),
+ description: LocalizedString::create('Created by SDK CI
')
+ ->withTranslation(Language::CS, 'Vytvořeno SDK CI
'),
+ );
+
+ $importResult = $this->client->importProducts([$product]);
+
+ self::assertTrue($importResult->success, 'Import should report success.');
+ self::assertSame(0, $importResult->skipped, sprintf('Unexpected skipped items: %s', json_encode($importResult->errors)));
+ self::assertFalse($importResult->hasErrors(), 'Import should not produce per-item errors.');
+ self::assertSame(1, $importResult->imported + $importResult->updated, 'Exactly one item should be imported or updated.');
+
+ $found = null;
+ foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $candidate) {
+ if ($candidate->id === $productId) {
+ $found = $candidate;
+ break;
+ }
+ }
+
+ self::assertNotNull($found, sprintf('Imported product %s was not returned by iterateProducts().', $productId));
+ self::assertSame('SDK Integration Test Product', $found->name->getDefault());
+
+ $deleteResult = $this->client->deleteProducts([$productId]);
+
+ self::assertTrue($deleteResult->success);
+ self::assertSame(1, $deleteResult->deleted);
+ self::assertFalse($deleteResult->hasErrors());
+
+ $this->untrackProduct($productId);
+ }
+}
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);
+ }
}