diff --git a/README.md b/README.md index 767a214..55054f2 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,12 @@ [![Latest Stable Version](https://poser.pugx.org/pobo-builder/php-sdk/v/stable)](https://packagist.org/packages/pobo-builder/php-sdk) [![License](https://poser.pugx.org/pobo-builder/php-sdk/license)](https://packagist.org/packages/pobo-builder/php-sdk) -Official PHP SDK for [Pobo API V2](https://api.pobo.space) - product content management and webhooks. +Official PHP SDK for [Pobo API V2](https://api.pobo.space) — product content management and webhooks. ## Requirements - PHP 8.1+ -- ext-curl -- ext-json +- ext-curl, ext-json ## Installation @@ -20,455 +19,323 @@ 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 use Pobo\Sdk\PoboClient; -$client = new PoboClient( - apiToken: 'your-api-token', - baseUrl: 'https://api.pobo.space', // optional - timeout: 30 // optional, in seconds -); -``` - -## Import - -### Import Order - -``` -1. Parameters (no dependencies) -2. Categories (no dependencies) -3. Products (depends on categories and parameters) -4. Blogs (no dependencies) +$client = new PoboClient(apiToken: 'your-api-token'); ``` -### Multilang Validation Rules +The token authenticates via `Authorization: Bearer ...` and is bound to a single e-shop. Get the API token and webhook secret in the Pobo administration under e-shop settings → REST API. -The API enforces a "language consistency" rule across multilang fields: +The examples below use only the **default** language. For multi-language imports and exports see [Multilang](#multilang) further down. -- `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. +## Import -> **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. +Order: parameters → categories → brands → products → blogs. -### Import Parameters +### Parameters ```php use Pobo\Sdk\DTO\Parameter; use Pobo\Sdk\DTO\ParameterValue; -$parameters = [ - new Parameter( - id: 1, - name: 'Color', - values: [ - new ParameterValue(id: 1, value: 'Red'), - new ParameterValue(id: 2, value: 'Blue'), - ], - ), - new Parameter( - id: 2, - name: 'Size', - values: [ - new ParameterValue(id: 3, value: 'S'), - new ParameterValue(id: 4, value: 'M'), - ], - ), -]; - -$result = $client->importParameters($parameters); -echo sprintf('Imported: %d, Values: %d', $result->imported, $result->valuesImported); +$result = $client->importParameters([ + new Parameter(id: 1, name: 'Color', values: [ + new ParameterValue(id: 1, value: 'Red'), + new ParameterValue(id: 2, value: 'Blue'), + ]), +]); ``` -### Import Categories +### Categories ```php use Pobo\Sdk\DTO\Category; use Pobo\Sdk\DTO\LocalizedString; -use Pobo\Sdk\Enum\Language; -$categories = [ +$result = $client->importCategories([ new Category( id: 'CAT-001', isVisible: true, - name: LocalizedString::create('Electronics') - ->withTranslation(Language::CS, 'Elektronika') - ->withTranslation(Language::SK, 'Elektronika'), - url: LocalizedString::create('https://example.com/electronics') - ->withTranslation(Language::CS, 'https://example.com/cs/elektronika') - ->withTranslation(Language::SK, 'https://example.com/sk/elektronika'), - description: LocalizedString::create('

All electronics

') - ->withTranslation(Language::CS, '

Veškerá elektronika

') - ->withTranslation(Language::SK, '

Všetka elektronika

'), - images: ['https://example.com/images/electronics.jpg'], + name: LocalizedString::create('Electronics'), + url: LocalizedString::create('https://example.com/electronics'), ), - new Category( - id: 'CAT-002', +]); +``` + +### Brands + +```php +use Pobo\Sdk\DTO\Brand; +use Pobo\Sdk\DTO\LocalizedString; + +$result = $client->importBrands([ + new Brand( + id: 'BRAND-001', isVisible: true, - name: LocalizedString::create('Phones') - ->withTranslation(Language::CS, 'Telefony') - ->withTranslation(Language::SK, 'Telefóny'), - url: LocalizedString::create('https://example.com/phones') - ->withTranslation(Language::CS, 'https://example.com/cs/telefony') - ->withTranslation(Language::SK, 'https://example.com/sk/telefony'), + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + imagePreview: 'https://example.com/brands/apple-logo.png', ), -]; - -$result = $client->importCategories($categories); -echo sprintf('Imported: %d, Updated: %d', $result->imported, $result->updated); +]); ``` -### Import Products +`imagePreview` follows the same three-state semantics as `Product::brandId`: + +| Value of `imagePreview` | Behavior | +|-------------------------------------|-------------------------------------------------------------------| +| omitted (default) | `image_preview` is not sent — the server keeps the existing logo | +| `null` | `image_preview` is **cleared** on the brand | +| `'https://.../logo.png'` (string) | `image_preview` is set to this value | + +### Products ```php use Pobo\Sdk\DTO\Product; use Pobo\Sdk\DTO\LocalizedString; -use Pobo\Sdk\Enum\Language; -$products = [ +$result = $client->importProducts([ new Product( id: 'PROD-001', isVisible: true, - name: LocalizedString::create('iPhone 15') - ->withTranslation(Language::CS, 'iPhone 15') - ->withTranslation(Language::SK, 'iPhone 15'), - url: LocalizedString::create('https://example.com/iphone-15') - ->withTranslation(Language::CS, 'https://example.com/cs/iphone-15') - ->withTranslation(Language::SK, 'https://example.com/sk/iphone-15'), - shortDescription: LocalizedString::create('Latest iPhone model') - ->withTranslation(Language::CS, 'Nejnovější model iPhone') - ->withTranslation(Language::SK, 'Najnovší model iPhone'), - description: LocalizedString::create('

The best iPhone ever.

') - ->withTranslation(Language::CS, '

Nejlepší iPhone vůbec.

') - ->withTranslation(Language::SK, '

Najlepší iPhone vôbec.

'), - images: ['https://example.com/images/iphone-1.jpg'], - categoriesIds: ['CAT-001', 'CAT-002'], - parametersIds: [1, 2], - ), - new Product( - id: 'PROD-002', - isVisible: true, - name: LocalizedString::create('Samsung Galaxy S24') - ->withTranslation(Language::CS, 'Samsung Galaxy S24') - ->withTranslation(Language::SK, 'Samsung Galaxy S24'), - url: LocalizedString::create('https://example.com/samsung-s24') - ->withTranslation(Language::CS, 'https://example.com/cs/samsung-s24') - ->withTranslation(Language::SK, 'https://example.com/sk/samsung-s24'), + name: LocalizedString::create('iPhone 15'), + url: LocalizedString::create('https://example.com/iphone-15'), categoriesIds: ['CAT-001'], - parametersIds: [1, 3], + parametersIds: [1, 2], + brandId: 'BRAND-001', ), -]; - -$result = $client->importProducts($products); +]); -if ($result->hasErrors() === true) { +if ($result->hasErrors()) { foreach ($result->errors as $error) { - echo sprintf('Error: %s', implode(', ', $error['errors'])); + echo implode(', ', $error['errors']); } } ``` -### Import Blogs +#### Pairing a product with a brand + +`brandId` has three-state semantics: + +| Value of `brandId` | Behavior | +|------------------------|-------------------------------------------------------------------| +| omitted (default) | `brand_id` is not sent — the server keeps the existing assignment | +| `null` | brand is **cleared** on the product | +| `'BRAND-001'` (string) | paired with `brand.remote_id` in Pobo | + +The brand referenced by `brandId` must already exist in Pobo (registered via `importBrands`); otherwise the product is skipped with `"Invalid brand id: ..."`. + +> **Empty string `brandId: ''`:** the API treats `""` as a lookup, not a clear — the product is skipped with `"Invalid brand id: "`. To clear an existing brand pairing, pass an explicit `null`. The same applies to entries inside `categoriesIds` / `parametersIds` — the SDK defensively filters empty strings and `null` from those arrays before sending. + +> **Known V2 limitation — clearing categories or parameters:** sending `categoriesIds: []` (or `parametersIds: []`) **does not** detach existing pivot rows; the server treats an empty/absent array as "no change". V2 currently has no clear-all endpoint for these relations — clearing has to happen via the Pobo admin UI. The 3-state semantic only applies to `brandId`. + +### Blogs ```php use Pobo\Sdk\DTO\Blog; use Pobo\Sdk\DTO\LocalizedString; -use Pobo\Sdk\Enum\Language; -$blogs = [ +$result = $client->importBlogs([ new Blog( id: 'BLOG-001', isVisible: true, - name: LocalizedString::create('New Product Launch') - ->withTranslation(Language::CS, 'Uvedení nového produktu') - ->withTranslation(Language::SK, 'Uvedenie nového produktu'), - url: LocalizedString::create('https://example.com/blog/new-product') - ->withTranslation(Language::CS, 'https://example.com/cs/blog/novy-produkt') - ->withTranslation(Language::SK, 'https://example.com/sk/blog/novy-produkt'), + name: LocalizedString::create('New Product Launch'), + url: LocalizedString::create('https://example.com/blog/new-product'), category: 'news', - description: LocalizedString::create('

We are excited to announce...

') - ->withTranslation(Language::CS, '

S radostí oznamujeme...

') - ->withTranslation(Language::SK, '

S radosťou oznamujeme...

'), - images: ['https://example.com/images/blog-1.jpg'], ), - new Blog( - id: 'BLOG-002', - isVisible: true, - name: LocalizedString::create('How to Choose') - ->withTranslation(Language::CS, 'Jak vybrat') - ->withTranslation(Language::SK, 'Ako vybrať'), - url: LocalizedString::create('https://example.com/blog/how-to-choose') - ->withTranslation(Language::CS, 'https://example.com/cs/blog/jak-vybrat') - ->withTranslation(Language::SK, 'https://example.com/sk/blog/ako-vybrat'), - category: 'tips', - ), -]; - -$result = $client->importBlogs($blogs); -echo sprintf('Imported: %d, Updated: %d', $result->imported, $result->updated); -``` - -## Delete - -### Delete Products - -```php -$result = $client->deleteProducts(['PROD-001', 'PROD-002', 'PROD-003']); - -echo sprintf('Deleted: %d, Skipped: %d', $result->deleted, $result->skipped); - -if ($result->hasErrors() === true) { - foreach ($result->errors as $error) { - echo sprintf('ID %s: %s', $error['id'], implode(', ', $error['errors'])); - } -} +]); ``` -### Delete Categories +> **Per-item errors:** validation failures during bulk import (invalid URL, missing language, …) **don't** raise an exception. They appear in `$result->errors[]` while the rest of the batch is processed. Always inspect `$result->hasErrors()`. -```php -$result = $client->deleteCategories(['CAT-001', 'CAT-002']); -echo sprintf('Deleted: %d', $result->deleted); -``` +## Delete -### Delete Blogs +Soft-delete by external ID (max 100 per request): ```php -$result = $client->deleteBlogs(['BLOG-001', 'BLOG-002']); -echo sprintf('Deleted: %d', $result->deleted); +$client->deleteProducts(['PROD-001', 'PROD-002']); +$client->deleteCategories(['CAT-001']); +$client->deleteBrands(['BRAND-001']); +$client->deleteBlogs(['BLOG-001']); ``` -> **Note:** Delete performs a soft-delete. +> Soft-deleting a brand does **not** clear `brand_id` on products that reference it. Re-import the products with `brandId: null` if you also want to drop the assignment. ## 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 $response = $client->getProducts(page: 1, perPage: 50); foreach ($response->data as $product) { - echo sprintf("%s: %s\n", $product->id, $product->name->getDefault()); + echo $product->id, ': ', $product->name->getDefault(), "\n"; } -echo sprintf('Page %d of %d', $response->currentPage, $response->getTotalPages()); - -// Iterate through all products (handles pagination automatically) +// Or iterate everything (handles pagination automatically): foreach ($client->iterateProducts() as $product) { - echo sprintf("%s: %s\n", $product->id, $product->name->getDefault()); + // ... } +``` -// 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 (= is_loaded on server side) -$response = $client->getProducts(isEdited: true); +The same shape works for `getCategories` / `getBrands` / `getBlogs` (and their `iterate*` variants). -// Get ALL products including ones never edited in Pobo -$response = $client->getProducts(isEdited: false); +> **`isEdited`:** when not provided, the server defaults to `true` and returns only items that have been edited in Pobo (`is_loaded = true`). To export everything, pass `isEdited: false` explicitly. -// Include optional content (marketplace HTML, raw widget JSON) -use Pobo\Sdk\Enum\IncludeContent; +### Filter by last update -$response = $client->getProducts(include: [IncludeContent::MARKETPLACE, IncludeContent::NESTED]); +```php +$since = new DateTime('2024-01-01 00:00:00'); +$response = $client->getProducts(lastUpdateFrom: $since); ``` -### Export Categories +### Generated content (HTML, marketplace, …) -```php -$response = $client->getCategories(); +By default the export returns only `content.html`. Use `include` to request additional content: -foreach ($response->data as $category) { - echo sprintf("%s: %s\n", $category->id, $category->name->getDefault()); -} +```php +use Pobo\Sdk\Enum\IncludeContent; -// Iterate through all categories -foreach ($client->iterateCategories() as $category) { - processCategory($category); +foreach ($client->iterateProducts(include: [IncludeContent::MARKETPLACE]) as $product) { + $html = $product->content?->getHtmlDefault(); + $marketplaceHtml = $product->content?->getMarketplaceDefault(); } ``` -### Export Blogs +| Value | Description | Available for | +|----------------|----------------------------------------------|--------------------------------| +| `marketplace` | HTML for marketplace (no custom CSS) | product, category, brand, blog | +| `nested` | Raw widget JSON | product, category, brand, blog | +| `site_link` | Anchor navigation on H2 headings | product, brand, blog | +| `rich_snippet` | JSON-LD structured data (FAQPage) | product, category, brand, blog | -```php -$response = $client->getBlogs(); +`site_link` requires `enable_site_link` and `rich_snippet` requires `enable_rich_snippet` to be enabled on the e-shop in Pobo administration. -foreach ($response->data as $blog) { - echo sprintf("%s: %s\n", $blog->id, $blog->name->getDefault()); -} +## Multilang -// Iterate through all blogs -foreach ($client->iterateBlogs() as $blog) { - processBlog($blog); -} -``` +Pobo supports 7 languages: `default`, `cs`, `sk`, `en`, `de`, `pl`, `hu`. `default` is a separate virtual locale used as fallback content — it is **not** an alias for `cs` or any other language. -## Language Filtering +| Code | Language | +|-----------|--------------------| +| `default` | Default (required) | +| `cs` | Czech | +| `sk` | Slovak | +| `en` | English | +| `de` | German | +| `pl` | Polish | +| `hu` | Hungarian | + +### Importing translations -By default, only the `default` language is returned. Use the `lang` parameter to request specific languages: +Use `LocalizedString::withTranslation()` to chain languages on any localized field: ```php +use Pobo\Sdk\DTO\LocalizedString; 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); -} +$name = LocalizedString::create('iPhone 15') // default + ->withTranslation(Language::CS, 'iPhone 15 CZ') + ->withTranslation(Language::SK, 'iPhone 15 SK') + ->withTranslation(Language::EN, 'iPhone 15'); -// Same for categories and blogs -$response = $client->getCategories(lang: [Language::DEFAULT, Language::CS]); -$response = $client->getBlogs(lang: [Language::ALL]); +$name->getDefault(); // 'iPhone 15' +$name->get(Language::CS); // 'iPhone 15 CZ' +$name->toArray(); // ['default' => '...', 'cs' => '...', 'sk' => '...', 'en' => '...'] ``` -> **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 | 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 | +Pass localized strings to any DTO that accepts them — products, categories, brands, blogs: ```php -use Pobo\Sdk\Enum\IncludeContent; +use Pobo\Sdk\DTO\Product; +use Pobo\Sdk\DTO\LocalizedString; use Pobo\Sdk\Enum\Language; -// Include marketplace and nested content -foreach ($client->iterateProducts(include: [IncludeContent::MARKETPLACE, IncludeContent::NESTED]) as $product) { - if ($product->content !== null) { - // Get HTML content for web (always included) - $htmlCs = $product->content->getHtml(Language::CS); - $htmlSk = $product->content->getHtml(Language::SK); - $htmlEn = $product->content->getHtml(Language::EN); +new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('iPhone 15') + ->withTranslation(Language::CS, 'iPhone 15 CZ') + ->withTranslation(Language::SK, 'iPhone 15 SK'), + url: LocalizedString::create('https://example.com/iphone-15') + ->withTranslation(Language::CS, 'https://example.com/cs/iphone-15') + ->withTranslation(Language::SK, 'https://example.com/sk/iphone-15'), + shortDescription: LocalizedString::create('Latest iPhone model') + ->withTranslation(Language::CS, 'Nejnovější model iPhone') + ->withTranslation(Language::SK, 'Najnovší model iPhone'), + description: LocalizedString::create('

The best iPhone ever.

') + ->withTranslation(Language::CS, '

Nejlepší iPhone vůbec.

') + ->withTranslation(Language::SK, '

Najlepší iPhone vôbec.

'), +); +``` - // Get content for marketplace (requires include: ['marketplace']) - $marketplaceCs = $product->content->getMarketplace(Language::CS); - $marketplaceSk = $product->content->getMarketplace(Language::SK); +### Validation rules - // Get raw widget JSON (requires include: ['nested']) - $nested = $product->content->getNested(); +The API enforces a "language consistency" rule across multilang fields: - // Get default content - $htmlDefault = $product->content->getHtmlDefault(); - $marketplaceDefault = $product->content->getMarketplaceDefault(); - } -} +- `name.default` and `url.default` are **always required**. +- If any field on an item carries a translation key (e.g. `sk`), then `name.{lang}` and `url.{lang}` become required for that item. +- Other fields (`short_description`, `description`, `seo_title`, `seo_description`) may have `null` values, but the language key must be present if the language is used elsewhere on the item. +- All `url.*` values must start with `https://`. +- Records that fail validation are **skipped**, not aborted — the response reports them in `errors[]`. The rest of the batch is imported. +- These rules apply identically to products, categories, brands and blogs. -// Same for categories -foreach ($client->iterateCategories(include: [IncludeContent::NESTED]) as $category) { - if ($category->content !== null) { - echo $category->content->getHtml(Language::CS); - $nested = $category->content->getNested(); - } -} +### Update behavior -// Same for blogs -foreach ($client->iterateBlogs(include: [IncludeContent::MARKETPLACE]) as $blog) { - if ($blog->content !== null) { - echo $blog->content->getHtml(Language::CS); - echo $blog->content->getMarketplace(Language::CS); - } -} -``` +When you re-import an item: + +- Languages **present** in the request stay active (`*_meta.is_active = true`). +- Languages **missing** from the request are soft-deleted (`*_meta.is_active = false`). -## Site Links +Always re-send every language you want to keep visible. -Anchor navigation generated from H2 headings in content widgets. Available for products and blogs. +### Filtering languages on export -> **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. +By default, only `default` is returned. Use the `lang` parameter to request specific languages: ```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); +// All 7 languages +$response = $client->getProducts(lang: [Language::ALL]); - // Get structured list of headings - $items = $product->siteLink->getList(Language::DEFAULT); - foreach ($items as $item) { - echo sprintf('%s', $item->slug, $item->heading); - } - } +// Specific languages +$response = $client->getProducts(lang: [Language::DEFAULT, Language::CS, Language::SK]); + +foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) { + echo $product->name->get(Language::CS); + echo $product->content?->getHtml(Language::CS); } ``` -## Rich Snippets - -JSON-LD structured data (FAQPage schema) generated from FAQ widgets. Available for products, categories, and blogs. +The `lang` parameter filters every multilang field in the response — `name`, `description`, `url`, …, plus `content.html`, `content.marketplace`, `site_link.*`, `rich_snippet.*`. Invalid language values are silently ignored. -> **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. +### Reading translations ```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); - } +foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) { + $product->name->getDefault(); // default value + $product->name->get(Language::CS); // Czech variant (or null) + $product->name->toArray(); // ['default' => '...', 'cs' => '...', ...] + + // Same accessors on content / site_link / rich_snippet: + $product->content?->getHtml(Language::CS); + $product->content?->getMarketplace(Language::CS); + $product->siteLink?->getList(Language::CS); + $product->richSnippet?->getJson(Language::CS); } ``` ## 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 | +Pobo can notify your application when content changes in the administration. Register a webhook URL in the Pobo e-shop settings; Pobo POSTs to it with HMAC-SHA256 signed requests. -Each request carries: +| Event | Constant | Fired when | +|----------------------|-----------------------------------|----------------------------------------| +| `Products.update` | `WebhookEvent::PRODUCTS_UPDATE` | a product was edited and saved in Pobo | +| `Categories.update` | `WebhookEvent::CATEGORIES_UPDATE` | a category was edited | +| `Blogs.update` | `WebhookEvent::BLOGS_UPDATE` | a blog was edited | -- 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 +The webhook is a notification only — it does not carry the changed entities. Use it as a trigger to re-sync via the export endpoints (typically combined with `lastUpdateFrom`). ```php use Pobo\Sdk\WebhookHandler; @@ -481,135 +348,83 @@ try { $payload = $handler->handleFromGlobals(); match ($payload->event) { - WebhookEvent::PRODUCTS_UPDATE => syncProducts($client), + WebhookEvent::PRODUCTS_UPDATE => syncProducts($client), WebhookEvent::CATEGORIES_UPDATE => syncCategories($client), - WebhookEvent::BLOGS_UPDATE => syncBlogs($client), + WebhookEvent::BLOGS_UPDATE => syncBlogs($client), }; http_response_code(200); - echo json_encode(['status' => 'ok']); - } catch (WebhookException $e) { http_response_code(401); echo json_encode(['error' => $e->getMessage()]); } ``` -### Manual Handling - -```php -$payload = $handler->handle( - payload: file_get_contents('php://input'), - signature: $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '' -); -``` - -### Webhook Payload - -```php -$payload->event; // WebhookEvent enum -$payload->timestamp; // DateTimeInterface -$payload->eshopId; // int -``` +`$payload` exposes `event` (`WebhookEvent` enum), `timestamp` (`DateTimeInterface`), and `eshopId` (int). ## Error Handling ```php use Pobo\Sdk\Exception\ApiException; use Pobo\Sdk\Exception\ValidationException; -use Pobo\Sdk\Exception\WebhookException; try { $result = $client->importProducts($products); } catch (ValidationException $e) { - echo sprintf('Validation error: %s', $e->getMessage()); print_r($e->errors); } catch (ApiException $e) { - echo sprintf('API error (%d): %s', $e->httpCode, $e->getMessage()); + echo $e->httpCode, ': ', $e->getMessage(); print_r($e->responseBody); } ``` -### 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. +| Exception | Thrown when | +|------------------------|--------------------------------------------------------------------------------------------------------------------------| +| `ValidationException` | Local pre-flight check fails — empty payload or **more than 100 items** in a bulk request. The HTTP request is not sent. | +| `ApiException` | The HTTP request fails or the API returns `>= 400`. Exposes `httpCode` and parsed `responseBody`. | +| `WebhookException` | The webhook signature is missing/invalid, the body cannot be parsed, or the event name is unknown. | -## Localized Strings - -```php -use Pobo\Sdk\DTO\LocalizedString; -use Pobo\Sdk\Enum\Language; - -// Create with default value -$name = LocalizedString::create('Default Name'); - -// Add translations using fluent interface -$name = $name - ->withTranslation(Language::CS, 'Czech Name') - ->withTranslation(Language::SK, 'Slovak Name') - ->withTranslation(Language::EN, 'English Name'); - -// Get values -$name->getDefault(); // 'Default Name' -$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 | -|-----------|--------------------| -| `default` | Default (required) | -| `cs` | Czech | -| `sk` | Slovak | -| `en` | English | -| `de` | German | -| `pl` | Polish | -| `hu` | Hungarian | +> Per-item validation failures during bulk import (invalid URL, missing language, …) **don't** raise `ApiException` — they appear in `$result->errors[]` while the rest of the batch is processed. ## 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, ?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 | +| Method | Description | +|---------------------------------------------------------------------------------|----------------------------------| +| `importProducts(array $products)` | Bulk import products (max 100) | +| `importCategories(array $categories)` | Bulk import categories (max 100) | +| `importBrands(array $brands)` | Bulk import brands (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) | +| `deleteBrands(array $ids)` | Bulk delete brands (max 100) | +| `deleteBlogs(array $ids)` | Bulk delete blogs (max 100) | +| `getProducts(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Get products page | +| `getCategories(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Get categories page | +| `getBrands(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Get brands page | +| `getBlogs(?page, ?perPage, ?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Get blogs page | +| `iterateProducts(?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Iterate all products | +| `iterateCategories(?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Iterate all categories | +| `iterateBrands(?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Iterate all brands | +| `iterateBlogs(?lastUpdateFrom, ?isEdited, ?include, ?lang)` | Iterate all blogs | ## Limits -| Limit | Value | -|------------------------------|--------------| -| Max items per import request | 100 | -| Max items per delete request | 100 | -| Max items per export page | 100 | -| Product/Category ID length | 255 chars | -| Name length | 250 chars | -| URL length | 255 chars | -| Image URL length | 650 chars | +| Limit | Value | +|------------------------------|---------------| +| Max items per import request | 100 | +| Max items per delete request | 100 | +| Max items per export page | 100 | +| Product/Category ID length | 255 chars | +| Name length | 250 chars | +| URL length | 255 chars | +| Image URL length | 650 chars | | Description length | 500,000 chars | -| SEO description length | 500 chars | +| SEO description length | 500 chars | ## Testing -### Unit Tests +### Unit tests ```bash vendor/bin/phpunit --testsuite=Unit @@ -617,7 +432,7 @@ 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 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. @@ -625,15 +440,7 @@ Integration tests hit the real `api.pobo.space` API and verify that the SDK's wi 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. +If `POBO_API_TOKEN` is not set, all integration tests are marked **skipped** (not failed). The CI workflow runs integration tests on pushes to `master`, PRs from the same repository (forks are skipped — secrets are not exposed there), and manual dispatch. Each run uses a unique ID prefix and `tearDown()` removes anything the test created. ## License diff --git a/src/DTO/Brand.php b/src/DTO/Brand.php new file mode 100644 index 0000000..4d0d71c --- /dev/null +++ b/src/DTO/Brand.php @@ -0,0 +1,109 @@ +, + * url: array, + * image_preview?: string|null, + * description?: array, + * seo_title?: array, + * seo_description?: array, + * content?: array, + * site_link?: array, + * rich_snippet?: array, + * guid?: string|null, + * is_loaded?: bool, + * created_at?: string, + * updated_at?: string, + * } + */ +final class Brand +{ + /** + * Sentinel meaning "do not send image_preview in the import payload" — the + * server will leave brand.image_preview unchanged. Distinct from passing + * null, which explicitly clears the logo in the DB. + */ + public const IMAGE_PREVIEW_UNSET = "\x00POBO_IMAGE_PREVIEW_UNSET\x00"; + + public function __construct( + public readonly string $id, + public readonly bool $isVisible, + public readonly LocalizedString $name, + public readonly LocalizedString $url, + public readonly ?string $imagePreview = self::IMAGE_PREVIEW_UNSET, + public readonly ?LocalizedString $description = null, + 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 ?string $guid = null, + public readonly ?bool $isLoaded = null, + public readonly ?\DateTimeInterface $createdAt = null, + public readonly ?\DateTimeInterface $updatedAt = null, + ) { + } + + /** + * @return array + */ + public function toArray(): array + { + $data = [ + 'id' => $this->id, + 'is_visible' => $this->isVisible, + 'name' => $this->name->toArray(), + 'url' => $this->url->toArray(), + ]; + + if ($this->imagePreview !== self::IMAGE_PREVIEW_UNSET) { + $data['image_preview'] = $this->imagePreview; + } + + if ($this->description !== null) { + $data['description'] = $this->description->toArray(); + } + + if ($this->seoTitle !== null) { + $data['seo_title'] = $this->seoTitle->toArray(); + } + + if ($this->seoDescription !== null) { + $data['seo_description'] = $this->seoDescription->toArray(); + } + + return $data; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + /** @var BrandData $data */ + return new self( + id: $data['id'], + isVisible: $data['is_visible'], + name: LocalizedString::fromArray($data['name']), + url: LocalizedString::fromArray($data['url']), + imagePreview: array_key_exists('image_preview', $data) ? $data['image_preview'] : self::IMAGE_PREVIEW_UNSET, + description: isset($data['description']) ? LocalizedString::fromArray($data['description']) : null, + 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, + guid: $data['guid'] ?? null, + isLoaded: $data['is_loaded'] ?? null, + createdAt: isset($data['created_at']) ? new \DateTimeImmutable($data['created_at']) : null, + updatedAt: isset($data['updated_at']) ? new \DateTimeImmutable($data['updated_at']) : null, + ); + } +} diff --git a/src/DTO/PaginatedResponse.php b/src/DTO/PaginatedResponse.php index e693f49..a0dac39 100644 --- a/src/DTO/PaginatedResponse.php +++ b/src/DTO/PaginatedResponse.php @@ -5,7 +5,7 @@ namespace Pobo\Sdk\DTO; /** - * @template T of Product|Category|Blog + * @template T of Product|Category|Blog|Brand */ final class PaginatedResponse { @@ -31,7 +31,7 @@ public function getTotalPages(): int } /** - * @template TEntity of Product|Category|Blog + * @template TEntity of Product|Category|Blog|Brand * @param array $response * @param class-string $entityClass * @return self diff --git a/src/DTO/Product.php b/src/DTO/Product.php index fe35075..ab68a0a 100644 --- a/src/DTO/Product.php +++ b/src/DTO/Product.php @@ -20,6 +20,7 @@ * images?: array, * categories_ids?: array, * parameters_ids?: array, + * brand_id?: string|null, * guid?: string|null, * is_loaded?: bool, * categories?: array}>, @@ -29,10 +30,23 @@ */ final class Product { + /** + * Sentinel meaning "do not send brand_id in the import payload" — the server + * will leave product.brand_id unchanged. Distinct from passing null, which + * explicitly clears the brand assignment in the DB. + */ + public const BRAND_ID_UNSET = "\x00POBO_BRAND_ID_UNSET\x00"; + + /** @var array */ + public readonly array $categoriesIds; + + /** @var array */ + public readonly array $parametersIds; + /** * @param array $images - * @param array $categoriesIds - * @param array $parametersIds + * @param array $categoriesIds Empty strings and nulls are filtered defensively + * @param array $parametersIds Empty strings and nulls are filtered defensively * @param array}> $categories */ public function __construct( @@ -48,14 +62,29 @@ public function __construct( public readonly ?SiteLink $siteLink = null, public readonly ?RichSnippet $richSnippet = null, public readonly array $images = [], - public readonly array $categoriesIds = [], - public readonly array $parametersIds = [], + array $categoriesIds = [], + array $parametersIds = [], + public readonly ?string $brandId = self::BRAND_ID_UNSET, public readonly ?string $guid = null, public readonly ?bool $isLoaded = null, public readonly array $categories = [], public readonly ?\DateTimeInterface $createdAt = null, public readonly ?\DateTimeInterface $updatedAt = null, ) { + $this->categoriesIds = array_values(array_map( + static fn(mixed $value): string => (string) $value, + array_filter( + $categoriesIds, + static fn(mixed $value): bool => $value !== '' && $value !== null, + ), + )); + $this->parametersIds = array_values(array_map( + static fn(mixed $value): int => (int) $value, + array_filter( + $parametersIds, + static fn(mixed $value): bool => $value !== '' && $value !== null, + ), + )); } /** @@ -98,6 +127,10 @@ public function toArray(): array $data['parameters_ids'] = $this->parametersIds; } + if ($this->brandId !== self::BRAND_ID_UNSET) { + $data['brand_id'] = $this->brandId; + } + return $data; } @@ -122,6 +155,7 @@ public static function fromArray(array $data): self images: $data['images'] ?? [], categoriesIds: $data['categories_ids'] ?? [], parametersIds: $data['parameters_ids'] ?? [], + brandId: array_key_exists('brand_id', $data) ? $data['brand_id'] : self::BRAND_ID_UNSET, guid: $data['guid'] ?? null, isLoaded: $data['is_loaded'] ?? null, categories: $data['categories'] ?? [], diff --git a/src/PoboClient.php b/src/PoboClient.php index f7c156f..cba1397 100644 --- a/src/PoboClient.php +++ b/src/PoboClient.php @@ -5,6 +5,7 @@ namespace Pobo\Sdk; use Pobo\Sdk\DTO\Blog; +use Pobo\Sdk\DTO\Brand; use Pobo\Sdk\DTO\Category; use Pobo\Sdk\DTO\DeleteResult; use Pobo\Sdk\DTO\ImportResult; @@ -83,6 +84,24 @@ public function importParameters(array $parameters): ImportResult return ImportResult::fromArray($response); } + /** + * @param array> $brands + * @throws ValidationException + * @throws ApiException + */ + public function importBrands(array $brands): ImportResult + { + $this->validateBulkSize($brands); + + $payload = array_map( + fn($brand) => $brand instanceof Brand ? $brand->toArray() : $brand, + $brands + ); + + $response = $this->request('POST', '/api/v2/rest/brands', $payload); + return ImportResult::fromArray($response); + } + /** * @param array> $blogs * @throws ValidationException @@ -139,6 +158,25 @@ public function getCategories( return PaginatedResponse::fromArray($response, Category::class); } + /** + * @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 getBrands( + ?int $page = null, + ?int $perPage = null, + ?\DateTimeInterface $lastUpdateFrom = null, + ?bool $isEdited = null, + ?array $include = null, + ?array $lang = null, + ): PaginatedResponse { + $query = $this->buildQueryParams($page, $perPage, $lastUpdateFrom, $isEdited, $include, $lang); + $response = $this->request('GET', '/api/v2/rest/brands' . $query); + return PaginatedResponse::fromArray($response, Brand::class); + } + /** * @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 @@ -188,6 +226,21 @@ public function deleteCategories(array $ids): DeleteResult return DeleteResult::fromArray($response); } + /** + * @param array $ids + * @throws ValidationException + * @throws ApiException + */ + public function deleteBrands(array $ids): DeleteResult + { + $this->validateBulkSize($ids); + + $payload = array_map(fn(string $id) => ['id' => $id], $ids); + + $response = $this->request('DELETE', '/api/v2/rest/brands', $payload); + return DeleteResult::fromArray($response); + } + /** * @param array $ids * @throws ValidationException @@ -253,6 +306,31 @@ public function iterateCategories( } while ($response->hasMorePages()); } + /** + * @param array|null $include Optional content to include + * @param array|null $lang Languages to include in response + * @return \Generator + * @throws ApiException + */ + public function iterateBrands( + ?\DateTimeInterface $lastUpdateFrom = null, + ?bool $isEdited = null, + ?array $include = null, + ?array $lang = null, + ): \Generator { + $page = 1; + + do { + $response = $this->getBrands($page, self::MAX_BULK_ITEMS, $lastUpdateFrom, $isEdited, $include, $lang); + + foreach ($response->data as $brand) { + yield $brand; + } + + $page++; + } while ($response->hasMorePages()); + } + /** * @param array|null $include Optional content to include * @param array|null $lang Languages to include in response diff --git a/tests/BrandTest.php b/tests/BrandTest.php new file mode 100644 index 0000000..1596205 --- /dev/null +++ b/tests/BrandTest.php @@ -0,0 +1,363 @@ + 'BRAND-001', + 'is_visible' => true, + 'image_preview' => 'https://example.com/brands/apple-logo.png', + 'name' => ['default' => 'Apple', 'cs' => 'Apple CZ'], + 'url' => ['default' => 'https://example.com/znacky/apple'], + 'description' => ['default' => '

Brand description

'], + 'content' => [ + 'html' => [ + 'cs' => '
Czech HTML
', + 'sk' => '
Slovak HTML
', + ], + 'marketplace' => [ + 'cs' => '
Czech Marketplace
', + ], + ], + 'guid' => '550e8400-e29b-41d4-a716-446655440099', + 'is_loaded' => false, + 'created_at' => '2024-01-15T10:30:00.000000Z', + 'updated_at' => '2024-01-16T14:20:00.000000Z', + ]; + + $brand = Brand::fromArray($data); + + $this->assertSame('BRAND-001', $brand->id); + $this->assertTrue($brand->isVisible); + $this->assertSame('https://example.com/brands/apple-logo.png', $brand->imagePreview); + $this->assertSame('Apple', $brand->name->getDefault()); + $this->assertSame('Apple CZ', $brand->name->get(Language::CS)); + + $this->assertInstanceOf(Content::class, $brand->content); + $this->assertSame('
Czech HTML
', $brand->content->getHtml(Language::CS)); + $this->assertSame('
Czech Marketplace
', $brand->content->getMarketplace(Language::CS)); + + $this->assertSame('550e8400-e29b-41d4-a716-446655440099', $brand->guid); + $this->assertFalse($brand->isLoaded); + } + + public function testBrandFromArrayWithNullImagePreview(): void + { + $data = [ + 'id' => 'BRAND-002', + 'is_visible' => true, + 'image_preview' => null, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com/brand'], + ]; + + $brand = Brand::fromArray($data); + + $this->assertNull($brand->imagePreview); + } + + public function testBrandFromArrayWithoutImagePreviewKeyKeepsSentinel(): void + { + $data = [ + 'id' => 'BRAND-003', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com/brand'], + ]; + + $brand = Brand::fromArray($data); + + $this->assertSame(Brand::IMAGE_PREVIEW_UNSET, $brand->imagePreview); + $this->assertArrayNotHasKey('image_preview', $brand->toArray()); + } + + public function testBrandToArrayIncludesImagePreviewNullToClear(): void + { + $brand = new Brand( + id: 'BRAND-CLEAR', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + imagePreview: null, + ); + + $array = $brand->toArray(); + + $this->assertArrayHasKey('image_preview', $array); + $this->assertNull($array['image_preview']); + } + + public function testBrandToArrayOmitsImagePreviewWithExplicitSentinel(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + imagePreview: Brand::IMAGE_PREVIEW_UNSET, + ); + + $array = $brand->toArray(); + + $this->assertArrayNotHasKey('image_preview', $array); + } + + public function testBrandFromArrayThenToArrayRoundtripsImagePreview(): void + { + $data = [ + 'id' => 'BRAND-RT', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + 'image_preview' => 'https://example.com/logo.png', + ]; + + $array = Brand::fromArray($data)->toArray(); + + $this->assertSame('https://example.com/logo.png', $array['image_preview']); + } + + public function testBrandFromArrayThenToArrayRoundtripsNullImagePreview(): void + { + $data = [ + 'id' => 'BRAND-NO-LOGO', + 'is_visible' => true, + 'name' => ['default' => 'Brand without logo'], + 'url' => ['default' => 'https://example.com'], + 'image_preview' => null, + ]; + + $array = Brand::fromArray($data)->toArray(); + + // Server returned null (no logo) → SDK round-trip preserves it explicitly + // (sending null again is a no-op since server state is already null) + $this->assertArrayHasKey('image_preview', $array); + $this->assertNull($array['image_preview']); + } + + public function testBrandFromArrayWithoutContent(): void + { + $data = [ + 'id' => 'BRAND-004', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + ]; + + $brand = Brand::fromArray($data); + + $this->assertNull($brand->content); + } + + public function testBrandToArrayWithRequiredFieldsOnly(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + ); + + $expected = [ + 'id' => 'BRAND-001', + 'is_visible' => true, + 'name' => ['default' => 'Apple'], + 'url' => ['default' => 'https://example.com/znacky/apple'], + ]; + + $this->assertSame($expected, $brand->toArray()); + } + + public function testBrandToArrayIncludesImagePreview(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + imagePreview: 'https://example.com/brands/apple-logo.png', + ); + + $array = $brand->toArray(); + + $this->assertSame('https://example.com/brands/apple-logo.png', $array['image_preview']); + } + + public function testBrandToArrayOmitsImagePreviewWhenNotProvided(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + ); + + $array = $brand->toArray(); + + $this->assertArrayNotHasKey('image_preview', $array); + } + + public function testBrandToArrayDoesNotIncludeContent(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Brand'), + url: LocalizedString::create('https://example.com'), + content: new Content( + html: ['cs' => '
Test
'], + marketplace: [], + ), + ); + + $array = $brand->toArray(); + + $this->assertArrayNotHasKey('content', $array); + } + + public function testBrandWithAllLocalizedFields(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple') + ->withTranslation(Language::CS, 'Apple CZ') + ->withTranslation(Language::SK, 'Apple SK'), + url: LocalizedString::create('https://example.com/znacky/apple') + ->withTranslation(Language::CS, 'https://example.com/cs/znacky/apple') + ->withTranslation(Language::SK, 'https://example.com/sk/znacky/apple'), + description: LocalizedString::create('

Description

') + ->withTranslation(Language::CS, '

Popis

'), + seoTitle: LocalizedString::create('SEO Title') + ->withTranslation(Language::CS, 'SEO Titulek'), + seoDescription: LocalizedString::create('SEO Description') + ->withTranslation(Language::CS, 'SEO Popis'), + ); + + $array = $brand->toArray(); + + $this->assertArrayHasKey('description', $array); + $this->assertArrayHasKey('seo_title', $array); + $this->assertArrayHasKey('seo_description', $array); + } + + public function testBrandToArrayExcludesNullFields(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Brand'), + url: LocalizedString::create('https://example.com'), + ); + + $array = $brand->toArray(); + + $this->assertArrayNotHasKey('description', $array); + $this->assertArrayNotHasKey('seo_title', $array); + $this->assertArrayNotHasKey('seo_description', $array); + $this->assertArrayNotHasKey('image_preview', $array); + } + + public function testBrandTimestamps(): void + { + $data = [ + 'id' => 'BRAND-001', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + 'created_at' => '2024-01-15T10:30:00.000000Z', + 'updated_at' => '2024-01-16T14:20:00.000000Z', + ]; + + $brand = Brand::fromArray($data); + + $this->assertInstanceOf(\DateTimeInterface::class, $brand->createdAt); + $this->assertInstanceOf(\DateTimeInterface::class, $brand->updatedAt); + $this->assertSame('2024-01-15', $brand->createdAt->format('Y-m-d')); + $this->assertSame('2024-01-16', $brand->updatedAt->format('Y-m-d')); + } + + public function testBrandFromArrayWithRichSnippet(): void + { + $data = [ + 'id' => 'BRAND-RICH', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + 'rich_snippet' => [ + 'html' => ['default' => ''], + 'json' => ['default' => ['@type' => 'FAQPage', 'mainEntity' => []]], + ], + ]; + + $brand = Brand::fromArray($data); + + $this->assertInstanceOf(RichSnippet::class, $brand->richSnippet); + $this->assertSame('FAQPage', $brand->richSnippet->getJson(Language::DEFAULT)['@type']); + } + + public function testBrandFromArrayWithoutRichSnippet(): void + { + $data = [ + 'id' => 'BRAND-PLAIN', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + ]; + + $brand = Brand::fromArray($data); + + $this->assertNull($brand->richSnippet); + } + + public function testBrandFromArrayWithSiteLink(): void + { + $data = [ + 'id' => 'BRAND-SITE', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + 'site_link' => [ + 'html' => ['default' => ''], + 'list' => [ + 'default' => [ + ['heading' => 'Nadpis', 'slug' => 'nadpis'], + ], + ], + ], + ]; + + $brand = Brand::fromArray($data); + + $this->assertInstanceOf(SiteLink::class, $brand->siteLink); + $this->assertStringContainsString('pobo-site-link', $brand->siteLink->getHtml(Language::DEFAULT)); + $this->assertSame('nadpis', $brand->siteLink->getList(Language::DEFAULT)[0]->slug); + } + + public function testBrandFromArrayWithoutSiteLink(): void + { + $data = [ + 'id' => 'BRAND-PLAIN', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + ]; + + $brand = Brand::fromArray($data); + + $this->assertNull($brand->siteLink); + } +} diff --git a/tests/DTO/BrandTest.php b/tests/DTO/BrandTest.php new file mode 100644 index 0000000..fc06838 --- /dev/null +++ b/tests/DTO/BrandTest.php @@ -0,0 +1,140 @@ + 'BRAND-001', + 'is_visible' => true, + 'name' => ['default' => 'Apple'], + 'url' => ['default' => 'https://example.com/znacky/apple'], + ]; + + $this->assertSame($expected, $brand->toArray()); + } + + public function testToArrayWithAllFields(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + imagePreview: 'https://example.com/brands/apple-logo.png', + description: LocalizedString::create('

Apple Inc.

'), + seoTitle: LocalizedString::create('Apple | Shop'), + seoDescription: LocalizedString::create('Best Apple products'), + ); + + $array = $brand->toArray(); + + $this->assertSame('https://example.com/brands/apple-logo.png', $array['image_preview']); + $this->assertSame(['default' => '

Apple Inc.

'], $array['description']); + $this->assertSame(['default' => 'Apple | Shop'], $array['seo_title']); + $this->assertSame(['default' => 'Best Apple products'], $array['seo_description']); + } + + public function testToArrayIncludesImagePreviewNullToClearLogo(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + imagePreview: null, + ); + + $array = $brand->toArray(); + + $this->assertArrayHasKey('image_preview', $array); + $this->assertNull($array['image_preview']); + } + + public function testToArrayOmitsImagePreviewWhenNotProvided(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + ); + + $array = $brand->toArray(); + + $this->assertArrayNotHasKey('image_preview', $array); + } + + public function testFromArray(): void + { + $data = [ + 'id' => 'BRAND-001', + 'is_visible' => true, + 'name' => ['default' => 'Apple', 'cs' => 'Apple CZ'], + 'url' => ['default' => 'https://example.com/znacky/apple'], + 'image_preview' => 'https://example.com/brands/apple-logo.png', + 'description' => ['default' => 'Description'], + 'guid' => '550e8400-e29b-41d4-a716-446655440099', + 'created_at' => '2024-01-15T10:30:00.000000Z', + 'updated_at' => '2024-01-16T14:20:00.000000Z', + ]; + + $brand = Brand::fromArray($data); + + $this->assertSame('BRAND-001', $brand->id); + $this->assertTrue($brand->isVisible); + $this->assertSame('Apple', $brand->name->getDefault()); + $this->assertSame('https://example.com/brands/apple-logo.png', $brand->imagePreview); + $this->assertSame('Description', $brand->description?->getDefault()); + $this->assertSame('550e8400-e29b-41d4-a716-446655440099', $brand->guid); + $this->assertInstanceOf(\DateTimeInterface::class, $brand->createdAt); + } + + public function testFromArrayWithMinimalData(): void + { + $data = [ + 'id' => 'BRAND-001', + 'is_visible' => false, + 'name' => ['default' => 'Test'], + 'url' => ['default' => 'https://example.com'], + ]; + + $brand = Brand::fromArray($data); + + $this->assertSame('BRAND-001', $brand->id); + $this->assertFalse($brand->isVisible); + $this->assertSame(Brand::IMAGE_PREVIEW_UNSET, $brand->imagePreview); + $this->assertNull($brand->description); + $this->assertNull($brand->guid); + } + + public function testFromArrayWithExplicitNullImagePreview(): void + { + $data = [ + 'id' => 'BRAND-001', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com'], + 'image_preview' => null, + ]; + + $brand = Brand::fromArray($data); + + $this->assertNull($brand->imagePreview); + } +} diff --git a/tests/DTO/PaginatedResponseTest.php b/tests/DTO/PaginatedResponseTest.php index 0ded838..3be1bb0 100644 --- a/tests/DTO/PaginatedResponseTest.php +++ b/tests/DTO/PaginatedResponseTest.php @@ -5,8 +5,9 @@ namespace Pobo\Sdk\Tests\DTO; use PHPUnit\Framework\TestCase; -use Pobo\Sdk\DTO\Category; use Pobo\Sdk\DTO\Blog; +use Pobo\Sdk\DTO\Brand; +use Pobo\Sdk\DTO\Category; use Pobo\Sdk\DTO\PaginatedResponse; use Pobo\Sdk\DTO\Product; @@ -197,6 +198,48 @@ public function testFromArrayWithCategoryEntity(): void $this->assertCount(1, $response->data[0]->content->nested); } + public function testFromArrayWithBrandEntity(): void + { + $data = [ + 'data' => [ + [ + 'id' => 'BRAND-001', + 'is_visible' => true, + 'name' => ['default' => 'Apple'], + 'url' => ['default' => 'https://example.com/znacky/apple'], + 'image_preview' => 'https://example.com/brands/apple-logo.png', + 'content' => [ + 'html' => ['default' => '
Brand HTML
'], + ], + ], + [ + 'id' => 'BRAND-002', + 'is_visible' => false, + 'name' => ['default' => 'No-logo brand'], + 'url' => ['default' => 'https://example.com/znacky/no-logo'], + 'image_preview' => null, + ], + ], + 'meta' => [ + 'current_page' => 2, + 'per_page' => 50, + 'total' => 12, + ], + ]; + + $response = PaginatedResponse::fromArray($data, Brand::class); + + $this->assertCount(2, $response->data); + $this->assertInstanceOf(Brand::class, $response->data[0]); + $this->assertInstanceOf(Brand::class, $response->data[1]); + $this->assertSame('BRAND-001', $response->data[0]->id); + $this->assertSame('https://example.com/brands/apple-logo.png', $response->data[0]->imagePreview); + $this->assertNull($response->data[1]->imagePreview); + $this->assertSame(2, $response->currentPage); + $this->assertSame(50, $response->perPage); + $this->assertSame(12, $response->total); + } + public function testFromArrayWithBlogEntity(): void { $data = [ diff --git a/tests/Integration/BrandLifecycleTest.php b/tests/Integration/BrandLifecycleTest.php new file mode 100644 index 0000000..ce5dd29 --- /dev/null +++ b/tests/Integration/BrandLifecycleTest.php @@ -0,0 +1,222 @@ +uniqueId('brand'); + $this->trackBrand($brandId); + + $brand = new Brand( + id: $brandId, + isVisible: true, + name: LocalizedString::create('SDK Integration Test Brand') + ->withTranslation(Language::CS, 'SDK Integration Test Značka'), + url: LocalizedString::create(sprintf('https://example.com/znacky/%s', $brandId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/znacky/%s', $brandId)), + imagePreview: 'https://example.com/brands/sdk-test-logo.png', + description: LocalizedString::create('

Created by SDK CI

') + ->withTranslation(Language::CS, '

Vytvořeno SDK CI

'), + ); + + $importResult = $this->client->importBrands([$brand]); + + 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->iterateBrands(isEdited: false, lang: [Language::ALL]) as $candidate) { + if ($candidate->id === $brandId) { + $found = $candidate; + break; + } + } + + self::assertNotNull($found, sprintf('Imported brand %s was not returned by iterateBrands().', $brandId)); + self::assertSame('SDK Integration Test Brand', $found->name->getDefault()); + self::assertSame('https://example.com/brands/sdk-test-logo.png', $found->imagePreview); + + $deleteResult = $this->client->deleteBrands([$brandId]); + + self::assertTrue($deleteResult->success); + self::assertSame(1, $deleteResult->deleted); + self::assertFalse($deleteResult->hasErrors()); + + $this->untrackBrand($brandId); + } + + public function testImportProductWithBrandPairing(): void + { + $brandId = $this->uniqueId('brand'); + $this->trackBrand($brandId); + + $brand = new Brand( + id: $brandId, + isVisible: true, + name: LocalizedString::create('SDK Brand Pair Test') + ->withTranslation(Language::CS, 'SDK Brand Pair Test CZ'), + url: LocalizedString::create(sprintf('https://example.com/znacky/%s', $brandId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/znacky/%s', $brandId)), + ); + + $brandImport = $this->client->importBrands([$brand]); + self::assertFalse($brandImport->hasErrors(), sprintf('Brand import failed: %s', json_encode($brandImport->errors))); + + $productId = $this->uniqueId('prod'); + $this->trackProduct($productId); + + $product = new Product( + id: $productId, + isVisible: true, + name: LocalizedString::create('SDK Brand-paired Product') + ->withTranslation(Language::CS, 'SDK Produkt s brandem'), + url: LocalizedString::create(sprintf('https://example.com/%s', $productId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $productId)), + brandId: $brandId, + ); + + $productImport = $this->client->importProducts([$product]); + + self::assertTrue($productImport->success); + self::assertSame(0, $productImport->skipped, sprintf('Unexpected skipped items: %s', json_encode($productImport->errors))); + self::assertFalse($productImport->hasErrors()); + + $foundProduct = null; + foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $candidate) { + if ($candidate->id === $productId) { + $foundProduct = $candidate; + break; + } + } + + self::assertNotNull($foundProduct, sprintf('Imported product %s was not returned.', $productId)); + self::assertSame($brandId, $foundProduct->brandId, 'Product should report the assigned brand_id.'); + + $unsetProduct = new Product( + id: $productId, + isVisible: true, + name: LocalizedString::create('SDK Brand-paired Product') + ->withTranslation(Language::CS, 'SDK Produkt s brandem'), + url: LocalizedString::create(sprintf('https://example.com/%s', $productId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $productId)), + brandId: null, + ); + + $unsetResult = $this->client->importProducts([$unsetProduct]); + self::assertFalse($unsetResult->hasErrors(), sprintf('Brand-unset re-import failed: %s', json_encode($unsetResult->errors))); + + $reread = null; + foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $candidate) { + if ($candidate->id === $productId) { + $reread = $candidate; + break; + } + } + + self::assertNotNull($reread); + self::assertNull($reread->brandId, 'Sending brand_id: null should clear the product brand assignment.'); + } + + public function testReimportBrandWithoutImagePreviewKeepsExistingLogo(): void + { + $brandId = $this->uniqueId('brand'); + $this->trackBrand($brandId); + + $logoUrl = 'https://example.com/brands/sdk-omit-test-logo.png'; + + $original = new Brand( + id: $brandId, + isVisible: true, + name: LocalizedString::create('SDK Omit Test') + ->withTranslation(Language::CS, 'SDK Omit Test CZ'), + url: LocalizedString::create(sprintf('https://example.com/znacky/%s', $brandId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/znacky/%s', $brandId)), + imagePreview: $logoUrl, + ); + + $first = $this->client->importBrands([$original]); + self::assertFalse($first->hasErrors(), sprintf('Brand import failed: %s', json_encode($first->errors))); + + // Re-import without imagePreview (default sentinel) → server must keep the logo. + $reimport = new Brand( + id: $brandId, + isVisible: true, + name: LocalizedString::create('SDK Omit Test renamed') + ->withTranslation(Language::CS, 'SDK Omit Test CZ renamed'), + url: LocalizedString::create(sprintf('https://example.com/znacky/%s', $brandId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/znacky/%s', $brandId)), + ); + + $second = $this->client->importBrands([$reimport]); + self::assertFalse($second->hasErrors(), sprintf('Brand re-import failed: %s', json_encode($second->errors))); + + $found = null; + foreach ($this->client->iterateBrands(isEdited: false, lang: [Language::ALL]) as $candidate) { + if ($candidate->id === $brandId) { + $found = $candidate; + break; + } + } + + self::assertNotNull($found); + self::assertSame($logoUrl, $found->imagePreview, 'Omitting imagePreview must preserve the existing logo, not clear it.'); + self::assertSame('SDK Omit Test renamed', $found->name->getDefault(), 'Other fields should still update.'); + + // Now explicitly clear the logo. + $clear = new Brand( + id: $brandId, + isVisible: true, + name: LocalizedString::create('SDK Omit Test renamed') + ->withTranslation(Language::CS, 'SDK Omit Test CZ renamed'), + url: LocalizedString::create(sprintf('https://example.com/znacky/%s', $brandId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/znacky/%s', $brandId)), + imagePreview: null, + ); + + $third = $this->client->importBrands([$clear]); + self::assertFalse($third->hasErrors(), sprintf('Brand clear-logo import failed: %s', json_encode($third->errors))); + + $reread = null; + foreach ($this->client->iterateBrands(isEdited: false, lang: [Language::ALL]) as $candidate) { + if ($candidate->id === $brandId) { + $reread = $candidate; + break; + } + } + + self::assertNotNull($reread); + self::assertNull($reread->imagePreview, 'Sending imagePreview: null should clear the logo.'); + } + + public function testImportProductWithInvalidBrandIdReportsError(): void + { + $productId = $this->uniqueId('prod'); + $this->trackProduct($productId); + + $product = new Product( + id: $productId, + isVisible: true, + name: LocalizedString::create('SDK Invalid Brand Product') + ->withTranslation(Language::CS, 'SDK Produkt'), + url: LocalizedString::create(sprintf('https://example.com/%s', $productId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $productId)), + brandId: $this->uniqueId('nonexistent-brand'), + ); + + $result = $this->client->importProducts([$product]); + + self::assertSame(1, $result->skipped, 'Product with invalid brand_id should be skipped.'); + self::assertTrue($result->hasErrors(), 'Invalid brand_id should produce a per-item error.'); + } +} diff --git a/tests/Integration/BulkImportTest.php b/tests/Integration/BulkImportTest.php index 2ee20d0..9f46efb 100644 --- a/tests/Integration/BulkImportTest.php +++ b/tests/Integration/BulkImportTest.php @@ -93,4 +93,32 @@ public function testBulkImportBlogs(): void self::assertCount(self::BULK_SIZE, $foundIds, 'All imported blogs should be retrievable.'); } + + public function testBulkImportBrands(): void + { + $brands = []; + for ($i = 0; $i < self::BULK_SIZE; $i++) { + $brands[] = $this->makeBrand(); + } + $expectedIds = array_map(fn($b) => $b->id, $brands); + + $importResult = $this->client->importBrands($brands); + + 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->iterateBrands(isEdited: false, lang: [Language::ALL]) as $brand) { + if (in_array($brand->id, $expectedIds, true)) { + $foundIds[] = $brand->id; + } + if (count($foundIds) === self::BULK_SIZE) { + break; + } + } + + self::assertCount(self::BULK_SIZE, $foundIds, 'All imported brands should be retrievable.'); + } } diff --git a/tests/Integration/BulkUpdateTest.php b/tests/Integration/BulkUpdateTest.php index c66ca53..cdd6e69 100644 --- a/tests/Integration/BulkUpdateTest.php +++ b/tests/Integration/BulkUpdateTest.php @@ -99,6 +99,49 @@ public function testReimportCategoriesTriggersUpdate(): void self::assertSame(self::BULK_SIZE, $verified); } + public function testReimportBrandsTriggersUpdate(): void + { + $originals = []; + for ($i = 0; $i < self::BULK_SIZE; $i++) { + $originals[] = $this->makeBrand(); + } + + $firstResult = $this->client->importBrands($originals); + + self::assertSame(self::BULK_SIZE, $firstResult->imported); + self::assertSame(0, $firstResult->updated); + self::assertFalse($firstResult->hasErrors()); + + $updates = array_map(fn($brand) => $this->makeBrand($brand->id), $originals); + + $secondResult = $this->client->importBrands($updates); + + self::assertSame(0, $secondResult->imported); + self::assertSame(self::BULK_SIZE, $secondResult->updated); + self::assertFalse($secondResult->hasErrors()); + + $expectedNamesById = []; + foreach ($updates as $brand) { + $expectedNamesById[$brand->id] = $brand->name->getDefault(); + } + + $verified = 0; + foreach ($this->client->iterateBrands(isEdited: false, lang: [Language::ALL]) as $brand) { + if (isset($expectedNamesById[$brand->id])) { + self::assertSame( + $expectedNamesById[$brand->id], + $brand->name->getDefault(), + ); + $verified++; + } + if ($verified === self::BULK_SIZE) { + break; + } + } + + self::assertSame(self::BULK_SIZE, $verified); + } + public function testReimportBlogsTriggersUpdate(): void { $originals = []; diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 8f43f08..fa77637 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -8,6 +8,7 @@ use Faker\Generator as FakerGenerator; use PHPUnit\Framework\TestCase; use Pobo\Sdk\DTO\Blog; +use Pobo\Sdk\DTO\Brand; use Pobo\Sdk\DTO\Category; use Pobo\Sdk\DTO\LocalizedString; use Pobo\Sdk\DTO\Product; @@ -36,6 +37,9 @@ abstract class IntegrationTestCase extends TestCase /** @var array */ private array $blogIdsToCleanup = []; + /** @var array */ + private array $brandIdsToCleanup = []; + protected function setUp(): void { $token = getenv('POBO_API_TOKEN'); @@ -87,6 +91,13 @@ protected function tearDown(): void } catch (\Throwable) { } } + + if ($this->brandIdsToCleanup !== []) { + try { + $this->client->deleteBrands($this->brandIdsToCleanup); + } catch (\Throwable) { + } + } } protected function trackProduct(string $id): void @@ -128,6 +139,19 @@ protected function untrackBlog(string $id): void )); } + protected function trackBrand(string $id): void + { + $this->brandIdsToCleanup[] = $id; + } + + protected function untrackBrand(string $id): void + { + $this->brandIdsToCleanup = array_values(array_filter( + $this->brandIdsToCleanup, + fn(string $existing) => $existing !== $id, + )); + } + protected function uniqueId(string $kind): string { return sprintf('%s-%s-%s', $this->idPrefix, $kind, bin2hex(random_bytes(2))); @@ -212,4 +236,29 @@ protected function makeBlog(?string $id = null): Blog ->withTranslation(Language::CS, sprintf('

%s

', $this->faker->paragraph(4))), ); } + + /** + * Build a Brand with realistic faker content. See {@see makeProduct()} for ID behavior. + */ + protected function makeBrand(?string $id = null): Brand + { + if ($id === null) { + $id = $this->uniqueId('brand'); + $this->trackBrand($id); + } + + $nameEn = $this->faker->unique()->company(); + $nameCs = sprintf('Značka %s', $this->faker->unique()->word()); + $slug = $this->faker->slug(2); + + return new Brand( + id: $id, + isVisible: true, + name: LocalizedString::create($nameEn)->withTranslation(Language::CS, $nameCs), + url: LocalizedString::create(sprintf('https://example.com/znacky/%s', $slug)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/znacky/%s', $slug)), + description: LocalizedString::create(sprintf('

%s

', $this->faker->paragraph(2))) + ->withTranslation(Language::CS, sprintf('

%s

', $this->faker->paragraph(2))), + ); + } } diff --git a/tests/PoboClientTest.php b/tests/PoboClientTest.php index 8eb1296..196cdd4 100644 --- a/tests/PoboClientTest.php +++ b/tests/PoboClientTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Pobo\Sdk\DTO\Blog; +use Pobo\Sdk\DTO\Brand; use Pobo\Sdk\DTO\Category; use Pobo\Sdk\DTO\DeleteResult; use Pobo\Sdk\DTO\LocalizedString; @@ -313,6 +314,70 @@ public function testDeleteBlogsThrowsExceptionForTooManyItems(): void $this->client->deleteBlogs($ids); } + public function testImportBrandsThrowsExceptionForEmptyArray(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Payload cannot be empty'); + + $this->client->importBrands([]); + } + + public function testImportBrandsThrowsExceptionForTooManyItems(): void + { + $brands = []; + for ($i = 0; $i < 101; $i++) { + $brands[] = [ + 'id' => sprintf('BRAND-%03d', $i), + 'is_visible' => true, + 'name' => ['default' => sprintf('Brand %d', $i)], + 'url' => ['default' => sprintf('https://example.com/brand/%d', $i)], + ]; + } + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Too many items: 101 provided, maximum is 100'); + + $this->client->importBrands($brands); + } + + public function testBrandDtoIsConvertedToArray(): void + { + $brand = new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple'), + url: LocalizedString::create('https://example.com/znacky/apple'), + imagePreview: 'https://example.com/brands/apple-logo.png', + ); + + $array = $brand->toArray(); + + $this->assertSame('BRAND-001', $array['id']); + $this->assertSame('https://example.com/brands/apple-logo.png', $array['image_preview']); + $this->assertTrue($array['is_visible']); + } + + public function testDeleteBrandsThrowsExceptionForEmptyArray(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Payload cannot be empty'); + + $this->client->deleteBrands([]); + } + + public function testDeleteBrandsThrowsExceptionForTooManyItems(): void + { + $ids = []; + for ($i = 0; $i < 101; $i++) { + $ids[] = sprintf('BRAND-%03d', $i); + } + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Too many items: 101 provided, maximum is 100'); + + $this->client->deleteBrands($ids); + } + public function testDeleteResultFromArray(): void { $result = DeleteResult::fromArray([ diff --git a/tests/ProductTest.php b/tests/ProductTest.php index 5170f22..5539c73 100644 --- a/tests/ProductTest.php +++ b/tests/ProductTest.php @@ -286,4 +286,169 @@ public function testProductFromArrayWithoutSiteLinkAndRichSnippet(): void $this->assertNull($product->siteLink); $this->assertNull($product->richSnippet); } + + public function testProductToArrayOmitsBrandIdByDefault(): void + { + $product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('Product'), + url: LocalizedString::create('https://example.com'), + ); + + $array = $product->toArray(); + + $this->assertArrayNotHasKey('brand_id', $array); + } + + public function testProductToArrayIncludesBrandIdString(): void + { + $product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('Product'), + url: LocalizedString::create('https://example.com'), + brandId: 'BRAND-001', + ); + + $array = $product->toArray(); + + $this->assertSame('BRAND-001', $array['brand_id']); + } + + public function testProductToArrayIncludesBrandIdNullToUnset(): void + { + $product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('Product'), + url: LocalizedString::create('https://example.com'), + brandId: null, + ); + + $array = $product->toArray(); + + $this->assertArrayHasKey('brand_id', $array); + $this->assertNull($array['brand_id']); + } + + public function testProductToArrayWithExplicitBrandIdUnsetSentinel(): void + { + $product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('Product'), + url: LocalizedString::create('https://example.com'), + brandId: Product::BRAND_ID_UNSET, + ); + + $array = $product->toArray(); + + $this->assertArrayNotHasKey('brand_id', $array); + } + + public function testProductFromArrayWithBrandIdString(): void + { + $data = [ + 'id' => 'PROD-BRAND', + 'is_visible' => true, + 'name' => ['default' => 'Product'], + 'url' => ['default' => 'https://example.com'], + 'brand_id' => 'BRAND-001', + ]; + + $product = Product::fromArray($data); + + $this->assertSame('BRAND-001', $product->brandId); + } + + public function testProductFromArrayWithBrandIdNull(): void + { + $data = [ + 'id' => 'PROD-NOBRAND', + 'is_visible' => true, + 'name' => ['default' => 'Product'], + 'url' => ['default' => 'https://example.com'], + 'brand_id' => null, + ]; + + $product = Product::fromArray($data); + + $this->assertNull($product->brandId); + } + + public function testProductFromArrayWithoutBrandIdKeyKeepsSentinel(): void + { + $data = [ + 'id' => 'PROD-LEGACY', + 'is_visible' => true, + 'name' => ['default' => 'Product'], + 'url' => ['default' => 'https://example.com'], + ]; + + $product = Product::fromArray($data); + + $this->assertSame(Product::BRAND_ID_UNSET, $product->brandId); + $this->assertArrayNotHasKey('brand_id', $product->toArray()); + } + + public function testProductFromArrayThenToArrayRoundtripsBrandId(): void + { + $data = [ + 'id' => 'PROD-RT', + 'is_visible' => true, + 'name' => ['default' => 'Product'], + 'url' => ['default' => 'https://example.com'], + 'brand_id' => 'BRAND-RT', + ]; + + $array = Product::fromArray($data)->toArray(); + + $this->assertSame('BRAND-RT', $array['brand_id']); + } + + public function testProductCategoriesIdsFiltersEmptyStringsAndNulls(): void + { + $product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('Product'), + url: LocalizedString::create('https://example.com'), + categoriesIds: ['CAT-001', '', 'CAT-002', null, 'CAT-003'], + ); + + $this->assertSame(['CAT-001', 'CAT-002', 'CAT-003'], $product->categoriesIds); + $this->assertSame(['CAT-001', 'CAT-002', 'CAT-003'], $product->toArray()['categories_ids']); + } + + public function testProductParametersIdsFiltersEmptyStringsAndNulls(): void + { + $product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('Product'), + url: LocalizedString::create('https://example.com'), + parametersIds: [1, '', 2, null, 3], + ); + + $this->assertSame([1, 2, 3], $product->parametersIds); + $this->assertSame([1, 2, 3], $product->toArray()['parameters_ids']); + } + + public function testProductCategoriesIdsAllEmptyDoesNotEmitKey(): void + { + $product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('Product'), + url: LocalizedString::create('https://example.com'), + categoriesIds: ['', null, ''], + parametersIds: ['', null], + ); + + $array = $product->toArray(); + + $this->assertArrayNotHasKey('categories_ids', $array); + $this->assertArrayNotHasKey('parameters_ids', $array); + } }