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