From 971f7546ca01ecd4566eee92c32f050b5daf7ea9 Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Tue, 5 May 2026 02:19:06 +0200 Subject: [PATCH 1/3] Add brand entity to REST API --- README.md | 139 +++++++++- src/DTO/Brand.php | 102 ++++++++ src/DTO/PaginatedResponse.php | 4 +- src/DTO/Product.php | 14 + src/PoboClient.php | 78 ++++++ tests/BrandTest.php | 298 ++++++++++++++++++++++ tests/DTO/BrandTest.php | 125 +++++++++ tests/Integration/BrandLifecycleTest.php | 151 +++++++++++ tests/Integration/BulkImportTest.php | 28 ++ tests/Integration/BulkUpdateTest.php | 43 ++++ tests/Integration/IntegrationTestCase.php | 49 ++++ tests/PoboClientTest.php | 65 +++++ tests/ProductTest.php | 120 +++++++++ 13 files changed, 1203 insertions(+), 13 deletions(-) create mode 100644 src/DTO/Brand.php create mode 100644 tests/BrandTest.php create mode 100644 tests/DTO/BrandTest.php create mode 100644 tests/Integration/BrandLifecycleTest.php diff --git a/README.md b/README.md index 767a214..d306901 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ $client = new PoboClient( ``` 1. Parameters (no dependencies) 2. Categories (no dependencies) -3. Products (depends on categories and parameters) -4. Blogs (no dependencies) +3. Brands (no dependencies) +4. Products (depends on categories, parameters, and brands) +5. Blogs (no dependencies) ``` ### Multilang Validation Rules @@ -128,6 +129,35 @@ $result = $client->importCategories($categories); echo sprintf('Imported: %d, Updated: %d', $result->imported, $result->updated); ``` +### Import Brands + +```php +use Pobo\Sdk\DTO\Brand; +use Pobo\Sdk\DTO\LocalizedString; +use Pobo\Sdk\Enum\Language; + +$brands = [ + new Brand( + id: 'BRAND-001', + isVisible: true, + name: LocalizedString::create('Apple') + ->withTranslation(Language::CS, 'Apple') + ->withTranslation(Language::SK, 'Apple'), + url: LocalizedString::create('https://example.com/brands/apple') + ->withTranslation(Language::CS, 'https://example.com/cs/znacky/apple') + ->withTranslation(Language::SK, 'https://example.com/sk/znacky/apple'), + imagePreview: 'https://example.com/brands/apple-logo.png', + description: LocalizedString::create('

Apple Inc.

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

Apple Inc.

'), + ), +]; + +$result = $client->importBrands($brands); +echo sprintf('Imported: %d, Updated: %d', $result->imported, $result->updated); +``` + +> **Brand → Product pairing:** brands stand on their own. To pair a product with a brand, set the `brandId` argument on the `Product` DTO (see "Import Products" below). Brands must be imported **before** the products that reference them. + ### Import Products ```php @@ -178,6 +208,46 @@ if ($result->hasErrors() === true) { } ``` +#### Pairing a product with a brand + +The `brandId` argument on `Product` follows three-state semantics matching the API: + +| Value of `brandId` | Behavior | +|--------------------------------|---------------------------------------------------------------------------| +| **omitted** (default) | `brand_id` is **not sent** — the server keeps the existing assignment. | +| `null` | `brand_id: null` is sent — the brand is **cleared** on the product. | +| `'BRAND-001'` (string) | `brand_id: "BRAND-001"` is sent — paired with `brand.remote_id` in Pobo. | + +```php +// Set a brand +$product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('iPhone 15'), + url: LocalizedString::create('https://example.com/iphone-15'), + brandId: 'BRAND-001', +); + +// Clear an existing brand assignment +$product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('iPhone 15'), + url: LocalizedString::create('https://example.com/iphone-15'), + brandId: null, +); + +// Leave the brand untouched (the default — do not pass brandId) +$product = new Product( + id: 'PROD-001', + isVisible: true, + name: LocalizedString::create('iPhone 15'), + url: LocalizedString::create('https://example.com/iphone-15'), +); +``` + +> **Note:** the brand referenced by `brandId` must already exist in Pobo (registered via `importBrands`). Otherwise the product is skipped with `"Invalid brand id: ..."`. + ### Import Blogs ```php @@ -241,6 +311,15 @@ $result = $client->deleteCategories(['CAT-001', 'CAT-002']); echo sprintf('Deleted: %d', $result->deleted); ``` +### Delete Brands + +```php +$result = $client->deleteBrands(['BRAND-001', 'BRAND-002']); +echo sprintf('Deleted: %d', $result->deleted); +``` + +> **Note:** soft-deleting a brand does **not** clear `brand_id` on the products that reference it. Re-import the products with `brandId: null` if you also want to drop the assignment. + ### Delete Blogs ```php @@ -301,6 +380,21 @@ foreach ($client->iterateCategories() as $category) { } ``` +### Export Brands + +```php +$response = $client->getBrands(); + +foreach ($response->data as $brand) { + echo sprintf("%s: %s (logo: %s)\n", $brand->id, $brand->name->getDefault(), $brand->imagePreview ?? 'none'); +} + +// Iterate through all brands +foreach ($client->iterateBrands() as $brand) { + processBrand($brand); +} +``` + ### Export Blogs ```php @@ -335,8 +429,9 @@ foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) { echo $product->content?->getHtml(Language::CS); } -// Same for categories and blogs +// Same for categories, brands and blogs $response = $client->getCategories(lang: [Language::DEFAULT, Language::CS]); +$response = $client->getBrands(lang: [Language::ALL]); $response = $client->getBlogs(lang: [Language::ALL]); ``` @@ -346,12 +441,12 @@ $response = $client->getBlogs(lang: [Language::ALL]); 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 | +| Value | Description | Available for | +|----------------|---------------------------------------------------|---------------------------------| +| `marketplace` | HTML content for marketplace (no custom CSS) | product, category, brand, blog | +| `nested` | Raw widget JSON from widget tables | 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 use Pobo\Sdk\Enum\IncludeContent; @@ -386,6 +481,14 @@ foreach ($client->iterateCategories(include: [IncludeContent::NESTED]) as $categ } } +// Same for brands +foreach ($client->iterateBrands(include: [IncludeContent::MARKETPLACE]) as $brand) { + if ($brand->content !== null) { + echo $brand->content->getHtml(Language::CS); + echo $brand->content->getMarketplace(Language::CS); + } +} + // Same for blogs foreach ($client->iterateBlogs(include: [IncludeContent::MARKETPLACE]) as $blog) { if ($blog->content !== null) { @@ -397,7 +500,7 @@ foreach ($client->iterateBlogs(include: [IncludeContent::MARKETPLACE]) as $blog) ## Site Links -Anchor navigation generated from H2 headings in content widgets. Available for products and blogs. +Anchor navigation generated from H2 headings in content widgets. Available for products, brands, 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. @@ -421,7 +524,7 @@ foreach ($client->iterateProducts(include: [IncludeContent::SITE_LINK], lang: [L ## Rich Snippets -JSON-LD structured data (FAQPage schema) generated from FAQ widgets. Available for products, categories, and blogs. +JSON-LD structured data (FAQPage schema) generated from FAQ widgets. Available for products, categories, brands, 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. @@ -446,6 +549,16 @@ foreach ($client->iterateCategories(include: [IncludeContent::RICH_SNIPPET]) as echo $category->richSnippet->getHtml(Language::DEFAULT); } } + +// Brands support both site_link and rich_snippet +foreach ($client->iterateBrands(include: [IncludeContent::RICH_SNIPPET, IncludeContent::SITE_LINK]) as $brand) { + if ($brand->richSnippet !== null) { + echo $brand->richSnippet->getHtml(Language::DEFAULT); + } + if ($brand->siteLink !== null) { + echo $brand->siteLink->getHtml(Language::DEFAULT); + } +} ``` ## Webhook Handler @@ -581,16 +694,20 @@ $name->toArray(); // ['default' => '...', 'cs' => '...', ...] |-----------------------------------------------------------------------------------------------------------------------|----------------------------------| | `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(?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 | +| `getBrands(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get brands 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 | +| `iterateBrands(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all brands | | `iterateBlogs(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all blogs | ## Limits diff --git a/src/DTO/Brand.php b/src/DTO/Brand.php new file mode 100644 index 0000000..5e6b303 --- /dev/null +++ b/src/DTO/Brand.php @@ -0,0 +1,102 @@ +, + * 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 +{ + public function __construct( + public readonly string $id, + public readonly bool $isVisible, + public readonly LocalizedString $name, + public readonly LocalizedString $url, + public readonly ?string $imagePreview = null, + 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 !== null) { + $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: $data['image_preview'] ?? null, + 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..b2fba9d 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,6 +30,13 @@ */ 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"; + /** * @param array $images * @param array $categoriesIds @@ -50,6 +58,7 @@ public function __construct( public readonly array $images = [], public readonly array $categoriesIds = [], public readonly array $parametersIds = [], + public readonly ?string $brandId = self::BRAND_ID_UNSET, public readonly ?string $guid = null, public readonly ?bool $isLoaded = null, public readonly array $categories = [], @@ -98,6 +107,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 +135,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..1a924e0 --- /dev/null +++ b/tests/BrandTest.php @@ -0,0 +1,298 @@ + '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 testBrandFromArrayWithoutImagePreviewKey(): void + { + $data = [ + 'id' => 'BRAND-003', + 'is_visible' => true, + 'name' => ['default' => 'Brand'], + 'url' => ['default' => 'https://example.com/brand'], + ]; + + $brand = Brand::fromArray($data); + + $this->assertNull($brand->imagePreview); + } + + 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 testBrandToArrayOmitsImagePreviewWhenNull(): 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..d6d5e16 --- /dev/null +++ b/tests/DTO/BrandTest.php @@ -0,0 +1,125 @@ + '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 testToArrayOmitsImagePreviewWhenNull(): 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->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->assertNull($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/Integration/BrandLifecycleTest.php b/tests/Integration/BrandLifecycleTest.php new file mode 100644 index 0000000..909831c --- /dev/null +++ b/tests/Integration/BrandLifecycleTest.php @@ -0,0 +1,151 @@ +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 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..5121175 100644 --- a/tests/ProductTest.php +++ b/tests/ProductTest.php @@ -286,4 +286,124 @@ 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']); + } } From 79c319127cd4efc8b6d94774704f205589c4af68 Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Tue, 5 May 2026 12:32:41 +0200 Subject: [PATCH 2/3] Edit README.md --- README.md | 726 +++++++++++++++--------------------------------------- 1 file changed, 202 insertions(+), 524 deletions(-) diff --git a/README.md b/README.md index d306901..93f8108 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,568 +19,311 @@ 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 -); +$client = new PoboClient(apiToken: 'your-api-token'); ``` -## Import +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. -### Import Order +The examples below use only the **default** language. For multi-language imports and exports see [Multilang](#multilang) further down. -``` -1. Parameters (no dependencies) -2. Categories (no dependencies) -3. Brands (no dependencies) -4. Products (depends on categories, parameters, and brands) -5. 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. +## 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', - 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'), - ), -]; - -$result = $client->importCategories($categories); -echo sprintf('Imported: %d, Updated: %d', $result->imported, $result->updated); +]); ``` -### Import Brands +### Brands ```php use Pobo\Sdk\DTO\Brand; use Pobo\Sdk\DTO\LocalizedString; -use Pobo\Sdk\Enum\Language; -$brands = [ +$result = $client->importBrands([ new Brand( id: 'BRAND-001', isVisible: true, - name: LocalizedString::create('Apple') - ->withTranslation(Language::CS, 'Apple') - ->withTranslation(Language::SK, 'Apple'), - url: LocalizedString::create('https://example.com/brands/apple') - ->withTranslation(Language::CS, 'https://example.com/cs/znacky/apple') - ->withTranslation(Language::SK, 'https://example.com/sk/znacky/apple'), + 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.

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

Apple Inc.

'), ), -]; - -$result = $client->importBrands($brands); -echo sprintf('Imported: %d, Updated: %d', $result->imported, $result->updated); +]); ``` -> **Brand → Product pairing:** brands stand on their own. To pair a product with a brand, set the `brandId` argument on the `Product` DTO (see "Import Products" below). Brands must be imported **before** the products that reference them. - -### Import Products +### 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']); } } ``` #### Pairing a product with a brand -The `brandId` argument on `Product` follows three-state semantics matching the API: - -| Value of `brandId` | Behavior | -|--------------------------------|---------------------------------------------------------------------------| -| **omitted** (default) | `brand_id` is **not sent** — the server keeps the existing assignment. | -| `null` | `brand_id: null` is sent — the brand is **cleared** on the product. | -| `'BRAND-001'` (string) | `brand_id: "BRAND-001"` is sent — paired with `brand.remote_id` in Pobo. | - -```php -// Set a brand -$product = new Product( - id: 'PROD-001', - isVisible: true, - name: LocalizedString::create('iPhone 15'), - url: LocalizedString::create('https://example.com/iphone-15'), - brandId: 'BRAND-001', -); - -// Clear an existing brand assignment -$product = new Product( - id: 'PROD-001', - isVisible: true, - name: LocalizedString::create('iPhone 15'), - url: LocalizedString::create('https://example.com/iphone-15'), - brandId: null, -); +`brandId` has three-state semantics: -// Leave the brand untouched (the default — do not pass brandId) -$product = new Product( - id: 'PROD-001', - isVisible: true, - name: LocalizedString::create('iPhone 15'), - url: LocalizedString::create('https://example.com/iphone-15'), -); -``` +| 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 | -> **Note:** the brand referenced by `brandId` must already exist in Pobo (registered via `importBrands`). Otherwise the product is skipped with `"Invalid brand id: ..."`. +The brand referenced by `brandId` must already exist in Pobo (registered via `importBrands`); otherwise the product is skipped with `"Invalid brand id: ..."`. -### Import Blogs +### 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'])); - } -} -``` +> **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()`. -### Delete Categories - -```php -$result = $client->deleteCategories(['CAT-001', 'CAT-002']); -echo sprintf('Deleted: %d', $result->deleted); -``` - -### Delete Brands - -```php -$result = $client->deleteBrands(['BRAND-001', 'BRAND-002']); -echo sprintf('Deleted: %d', $result->deleted); -``` - -> **Note:** soft-deleting a brand does **not** clear `brand_id` on the products that reference it. Re-import the products with `brandId: null` if you also want to drop the assignment. +## 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); - -// 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; - -$response = $client->getProducts(include: [IncludeContent::MARKETPLACE, IncludeContent::NESTED]); ``` -### Export Categories +The same shape works for `getCategories` / `getBrands` / `getBlogs` (and their `iterate*` variants). -```php -$response = $client->getCategories(); +> **`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. -foreach ($response->data as $category) { - echo sprintf("%s: %s\n", $category->id, $category->name->getDefault()); -} +### Filter by last update -// Iterate through all categories -foreach ($client->iterateCategories() as $category) { - processCategory($category); -} +```php +$since = new DateTime('2024-01-01 00:00:00'); +$response = $client->getProducts(lastUpdateFrom: $since); ``` -### Export Brands +### Generated content (HTML, marketplace, …) -```php -$response = $client->getBrands(); +By default the export returns only `content.html`. Use `include` to request additional content: -foreach ($response->data as $brand) { - echo sprintf("%s: %s (logo: %s)\n", $brand->id, $brand->name->getDefault(), $brand->imagePreview ?? 'none'); -} +```php +use Pobo\Sdk\Enum\IncludeContent; -// Iterate through all brands -foreach ($client->iterateBrands() as $brand) { - processBrand($brand); +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. + +| Code | Language | +|-----------|--------------------| +| `default` | Default (required) | +| `cs` | Czech | +| `sk` | Slovak | +| `en` | English | +| `de` | German | +| `pl` | Polish | +| `hu` | Hungarian | -## Language Filtering +### 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]); +$name = LocalizedString::create('iPhone 15') // default + ->withTranslation(Language::CS, 'iPhone 15 CZ') + ->withTranslation(Language::SK, 'iPhone 15 SK') + ->withTranslation(Language::EN, 'iPhone 15'); -// 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, brands and blogs -$response = $client->getCategories(lang: [Language::DEFAULT, Language::CS]); -$response = $client->getBrands(lang: [Language::ALL]); -$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, brand, blog | -| `nested` | Raw widget JSON from widget tables | 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 | +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 brands -foreach ($client->iterateBrands(include: [IncludeContent::MARKETPLACE]) as $brand) { - if ($brand->content !== null) { - echo $brand->content->getHtml(Language::CS); - echo $brand->content->getMarketplace(Language::CS); - } -} +When you re-import an item: -// 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); - } -} -``` +- 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, brands, 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, brands, 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); - } -} - -// Brands support both site_link and rich_snippet -foreach ($client->iterateBrands(include: [IncludeContent::RICH_SNIPPET, IncludeContent::SITE_LINK]) as $brand) { - if ($brand->richSnippet !== null) { - echo $brand->richSnippet->getHtml(Language::DEFAULT); - } - if ($brand->siteLink !== null) { - echo $brand->siteLink->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 | - -Each request carries: +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. -- 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 }`. +| 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 | -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; @@ -594,139 +336,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. | +| 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. | -> **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 -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) | -| `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(?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 | -| `getBrands(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get brands 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 | -| `iterateBrands(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all brands | -| `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 @@ -734,7 +420,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. @@ -742,15 +428,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 From edf123b50a6ded38d8c37a8ac05a8fc30995c0d3 Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Tue, 5 May 2026 13:58:01 +0200 Subject: [PATCH 3/3] Finish test --- README.md | 12 ++++ src/DTO/Brand.php | 13 ++++- src/DTO/Product.php | 28 ++++++++-- tests/BrandTest.php | 71 +++++++++++++++++++++++- tests/DTO/BrandTest.php | 19 ++++++- tests/DTO/PaginatedResponseTest.php | 45 ++++++++++++++- tests/Integration/BrandLifecycleTest.php | 71 ++++++++++++++++++++++++ tests/ProductTest.php | 45 +++++++++++++++ 8 files changed, 291 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 93f8108..55054f2 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,14 @@ $result = $client->importBrands([ ]); ``` +`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 @@ -117,6 +125,10 @@ if ($result->hasErrors()) { 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 diff --git a/src/DTO/Brand.php b/src/DTO/Brand.php index 5e6b303..4d0d71c 100644 --- a/src/DTO/Brand.php +++ b/src/DTO/Brand.php @@ -25,12 +25,19 @@ */ 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 = null, + public readonly ?string $imagePreview = self::IMAGE_PREVIEW_UNSET, public readonly ?LocalizedString $description = null, public readonly ?LocalizedString $seoTitle = null, public readonly ?LocalizedString $seoDescription = null, @@ -56,7 +63,7 @@ public function toArray(): array 'url' => $this->url->toArray(), ]; - if ($this->imagePreview !== null) { + if ($this->imagePreview !== self::IMAGE_PREVIEW_UNSET) { $data['image_preview'] = $this->imagePreview; } @@ -86,7 +93,7 @@ public static function fromArray(array $data): self isVisible: $data['is_visible'], name: LocalizedString::fromArray($data['name']), url: LocalizedString::fromArray($data['url']), - imagePreview: $data['image_preview'] ?? null, + 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, diff --git a/src/DTO/Product.php b/src/DTO/Product.php index b2fba9d..ab68a0a 100644 --- a/src/DTO/Product.php +++ b/src/DTO/Product.php @@ -37,10 +37,16 @@ final class Product */ 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( @@ -56,8 +62,8 @@ 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, @@ -65,6 +71,20 @@ public function __construct( 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, + ), + )); } /** diff --git a/tests/BrandTest.php b/tests/BrandTest.php index 1a924e0..1596205 100644 --- a/tests/BrandTest.php +++ b/tests/BrandTest.php @@ -69,7 +69,7 @@ public function testBrandFromArrayWithNullImagePreview(): void $this->assertNull($brand->imagePreview); } - public function testBrandFromArrayWithoutImagePreviewKey(): void + public function testBrandFromArrayWithoutImagePreviewKeyKeepsSentinel(): void { $data = [ 'id' => 'BRAND-003', @@ -80,7 +80,72 @@ public function testBrandFromArrayWithoutImagePreviewKey(): void $brand = Brand::fromArray($data); - $this->assertNull($brand->imagePreview); + $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 @@ -131,7 +196,7 @@ public function testBrandToArrayIncludesImagePreview(): void $this->assertSame('https://example.com/brands/apple-logo.png', $array['image_preview']); } - public function testBrandToArrayOmitsImagePreviewWhenNull(): void + public function testBrandToArrayOmitsImagePreviewWhenNotProvided(): void { $brand = new Brand( id: 'BRAND-001', diff --git a/tests/DTO/BrandTest.php b/tests/DTO/BrandTest.php index d6d5e16..fc06838 100644 --- a/tests/DTO/BrandTest.php +++ b/tests/DTO/BrandTest.php @@ -50,7 +50,7 @@ public function testToArrayWithAllFields(): void $this->assertSame(['default' => 'Best Apple products'], $array['seo_description']); } - public function testToArrayOmitsImagePreviewWhenNull(): void + public function testToArrayIncludesImagePreviewNullToClearLogo(): void { $brand = new Brand( id: 'BRAND-001', @@ -62,6 +62,21 @@ public function testToArrayOmitsImagePreviewWhenNull(): void $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); } @@ -103,7 +118,7 @@ public function testFromArrayWithMinimalData(): void $this->assertSame('BRAND-001', $brand->id); $this->assertFalse($brand->isVisible); - $this->assertNull($brand->imagePreview); + $this->assertSame(Brand::IMAGE_PREVIEW_UNSET, $brand->imagePreview); $this->assertNull($brand->description); $this->assertNull($brand->guid); } 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 index 909831c..ce5dd29 100644 --- a/tests/Integration/BrandLifecycleTest.php +++ b/tests/Integration/BrandLifecycleTest.php @@ -128,6 +128,77 @@ public function testImportProductWithBrandPairing(): void 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'); diff --git a/tests/ProductTest.php b/tests/ProductTest.php index 5121175..5539c73 100644 --- a/tests/ProductTest.php +++ b/tests/ProductTest.php @@ -406,4 +406,49 @@ public function testProductFromArrayThenToArrayRoundtripsBrandId(): void $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); + } }