From 6dbf55fefc618e0fbc19f987b355b1bae40705ce Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Thu, 26 Mar 2026 22:09:06 +0100 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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 From 428aed0dd8413dfe1f6f24132dba6f92693fa3ed Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Tue, 28 Apr 2026 16:57:16 +0200 Subject: [PATCH 5/7] Add integration tests --- .github/workflows/tests.yml | 41 ++++++- README.md | 89 +++++++++++++- phpunit.xml | 4 + tests/Integration/CategoryLifecycleTest.php | 54 +++++++++ tests/Integration/IntegrationTestCase.php | 124 ++++++++++++++++++++ tests/Integration/ProductLifecycleTest.php | 54 +++++++++ 6 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/CategoryLifecycleTest.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/ProductLifecycleTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cadb3ab..a76021b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: branches: [main, master] pull_request: branches: [main, master] + workflow_dispatch: jobs: test: @@ -34,8 +35,8 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Run tests - run: vendor/bin/phpunit --colors=always + - name: Run unit tests + run: vendor/bin/phpunit --colors=always --testsuite=Unit phpstan: runs-on: ubuntu-latest @@ -83,3 +84,39 @@ jobs: - name: Check PHP syntax run: find src tests -name "*.php" -exec php -l {} \; + + integration: + runs-on: ubuntu-latest + name: Integration (real API) + + # Skip on PRs from forks (they have no access to repository secrets). + # Runs on push to master/main, on PRs from the same repository, and on manual dispatch. + if: | + github.event_name == 'workflow_dispatch' + || github.event_name == 'push' + || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + + # Serialize integration runs so concurrent jobs do not race on the same test e-shop. + concurrency: + group: integration-${{ github.ref }} + cancel-in-progress: false + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: curl, json + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run integration tests + env: + POBO_API_TOKEN: ${{ secrets.POBO_API_TOKEN }} + POBO_BASE_URL: https://api.pobo.space + run: vendor/bin/phpunit --colors=always --testsuite=Integration --display-skipped diff --git a/README.md b/README.md index da3abd7..767a214 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ composer require pobo-builder/php-sdk ## Quick Start +### Authentication + +The SDK uses bearer token authentication. You can obtain your **API token** and **webhook secret** in your Pobo administration under the e-shop settings (REST API section). + +The token is sent as `Authorization: Bearer {token}` on every request and is bound to a single e-shop. + ### API Client ```php @@ -43,6 +49,18 @@ $client = new PoboClient( 4. Blogs (no dependencies) ``` +### Multilang Validation Rules + +The API enforces a "language consistency" rule across multilang fields: + +- `name.default` and `url.default` are **always required**. +- If any language key (e.g. `sk`) appears in any multilang field of an item, then `name.{lang}` and `url.{lang}` become required for that item. +- Other multilang fields (`short_description`, `description`, `seo_title`, `seo_description`) may have `null` values, but the language key must be present in the field if used elsewhere. +- All `url.*` values must start with `https://`. +- Records that fail validation are **skipped**, not aborted — the response reports them in `errors[]` with `index`, `id`, and a list of messages. The rest of the batch is imported. + +> **Tip:** The `LocalizedString::withTranslation()` helper sets the value for a given language; use it consistently across `name`, `url` and other fields when adding a new locale to an item. + ### Import Parameters ```php @@ -234,6 +252,8 @@ echo sprintf('Deleted: %d', $result->deleted); ## Export +> **Important:** When `isEdited` is not provided, the server applies a default of `true` and returns **only products/categories/blogs that have been edited in Pobo** (`is_loaded = true`). To export everything, pass `isEdited: false` explicitly. + ### Export Products ```php @@ -250,13 +270,16 @@ foreach ($client->iterateProducts() as $product) { echo sprintf("%s: %s\n", $product->id, $product->name->getDefault()); } -// Filter by last update time +// Filter by last update time (sent to API as Y-m-d H:i:s in server timezone) $since = new DateTime('2024-01-01 00:00:00'); $response = $client->getProducts(lastUpdateFrom: $since); -// Filter only edited products +// Filter only edited products (= is_loaded on server side) $response = $client->getProducts(isEdited: true); +// Get ALL products including ones never edited in Pobo +$response = $client->getProducts(isEdited: false); + // Include optional content (marketplace HTML, raw widget JSON) use Pobo\Sdk\Enum\IncludeContent; @@ -376,6 +399,8 @@ foreach ($client->iterateBlogs(include: [IncludeContent::MARKETPLACE]) as $blog) Anchor navigation generated from H2 headings in content widgets. Available for products and blogs. +> **E-shop config required:** Site links are returned only if `enable_site_link` is enabled on the e-shop in Pobo administration. Without that flag the `site_link` field will be empty even when `IncludeContent::SITE_LINK` is requested. + ```php use Pobo\Sdk\Enum\IncludeContent; use Pobo\Sdk\Enum\Language; @@ -398,6 +423,8 @@ foreach ($client->iterateProducts(include: [IncludeContent::SITE_LINK], lang: [L JSON-LD structured data (FAQPage schema) generated from FAQ widgets. Available for products, categories, and blogs. +> **E-shop config required:** Rich snippets are returned only if `enable_rich_snippet` is enabled on the e-shop in Pobo administration. Without that flag the `rich_snippet` field will be empty even when `IncludeContent::RICH_SNIPPET` is requested. + ```php use Pobo\Sdk\Enum\IncludeContent; use Pobo\Sdk\Enum\Language; @@ -423,6 +450,24 @@ foreach ($client->iterateCategories(include: [IncludeContent::RICH_SNIPPET]) as ## Webhook Handler +Pobo can notify your application when content is changed in the administration. You register a webhook URL in the Pobo e-shop settings and receive `POST` requests signed with HMAC-SHA256. + +### Events + +| Event | Constant | Fired when | +|----------------------|-----------------------------------|----------------------------------------------------| +| `Products.update` | `WebhookEvent::PRODUCTS_UPDATE` | Any product content was edited and saved in Pobo | +| `Categories.update` | `WebhookEvent::CATEGORIES_UPDATE` | Any category content was edited and saved in Pobo | +| `Blogs.update` | `WebhookEvent::BLOGS_UPDATE` | Any blog content was edited and saved in Pobo | + +Each request carries: + +- Header `X-Webhook-Signature` — HMAC-SHA256 of the raw body using your webhook secret. +- Header `X-Webhook-Event` — event name (informational; the SDK reads the event from the body). +- JSON body — `{ "event": "...", "timestamp": "ISO-8601", "eshop_id": 123 }`. + +The webhook does **not** carry the changed entities themselves; it is a notification to trigger a sync via the export endpoints (`getProducts` / `iterateProducts` etc., typically combined with `lastUpdateFrom`). + ### Basic Usage ```php @@ -485,6 +530,16 @@ try { } ``` +### Exception Types + +| Exception | Thrown when | +|------------------------|---------------------------------------------------------------------------------------------------------------------------| +| `ValidationException` | Local pre-flight checks fail — empty payload or **more than 100 items** in a bulk import/delete. The HTTP request is not sent. | +| `ApiException` | The HTTP request fails (network/cURL error) or the API returns `>= 400`. `httpCode` and parsed `responseBody` are exposed. | +| `WebhookException` | The webhook signature is missing/invalid, the body cannot be parsed, or the event name is unknown. | + +> **Note:** Per-item validation failures during bulk import (e.g. invalid URL, missing language) do **not** raise `ApiException`. They appear in `$result->errors[]` while the rest of the batch is processed. Always inspect `$result->hasErrors()` after a successful call. + ## Localized Strings ```php @@ -506,6 +561,8 @@ $name->get(Language::CS); // 'Czech Name' $name->toArray(); // ['default' => '...', 'cs' => '...', ...] ``` +> **About `default`:** `default` is a separate, virtual locale — **not an alias for `cs` or any other language**. It is the fallback content used by Pobo when a specific language variant is missing. `name.default` and `url.default` are required on import; per-language values (`cs`, `sk`, …) are optional and follow the multilang validation rule above. + ### Supported Languages | Code | Language | @@ -550,6 +607,34 @@ $name->toArray(); // ['default' => '...', 'cs' => '...', ...] | Description length | 500,000 chars | | SEO description length | 500 chars | +## Testing + +### Unit Tests + +```bash +vendor/bin/phpunit --testsuite=Unit +``` + +Unit tests are pure (no network) and run on every CI build across PHP 8.1–8.5. + +### Integration Tests + +Integration tests hit the real `api.pobo.space` API and verify that the SDK's wire format is in sync with the server. They require a dedicated test e-shop and a valid REST API token. + +```bash +POBO_API_TOKEN="your-test-eshop-token" vendor/bin/phpunit --testsuite=Integration +``` + +If `POBO_API_TOKEN` is not set, all tests in the suite are marked **skipped** (not failed). + +The CI workflow runs integration tests automatically on: + +- pushes to `master`, +- PRs **from the same repository** (PRs from forks are skipped — secrets are not exposed there), +- manual dispatch (`workflow_dispatch`). + +Each run uses a unique ID prefix combining `GITHUB_RUN_ID` and a random suffix, and `tearDown()` removes anything the test created. Two PRs running simultaneously cannot collide. + ## License MIT License diff --git a/phpunit.xml b/phpunit.xml index 6f244f5..5dd9b71 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,6 +7,10 @@ tests + tests/Integration + + + tests/Integration diff --git a/tests/Integration/CategoryLifecycleTest.php b/tests/Integration/CategoryLifecycleTest.php new file mode 100644 index 0000000..d26267c --- /dev/null +++ b/tests/Integration/CategoryLifecycleTest.php @@ -0,0 +1,54 @@ +uniqueId('cat'); + $this->trackCategory($categoryId); + + $category = new Category( + id: $categoryId, + isVisible: true, + name: LocalizedString::create('SDK Integration Test Category') + ->withTranslation(Language::CS, 'SDK Integration Test Kategorie'), + url: LocalizedString::create(sprintf('https://example.com/%s', $categoryId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $categoryId)), + description: LocalizedString::create('

Created by SDK CI

'), + ); + + $importResult = $this->client->importCategories([$category]); + + self::assertTrue($importResult->success); + self::assertSame(0, $importResult->skipped, sprintf('Unexpected skipped items: %s', json_encode($importResult->errors))); + self::assertFalse($importResult->hasErrors()); + self::assertSame(1, $importResult->imported + $importResult->updated); + + $found = null; + foreach ($this->client->iterateCategories(isEdited: false, lang: [Language::ALL]) as $candidate) { + if ($candidate->id === $categoryId) { + $found = $candidate; + break; + } + } + + self::assertNotNull($found, sprintf('Imported category %s was not returned by iterateCategories().', $categoryId)); + self::assertSame('SDK Integration Test Category', $found->name->getDefault()); + + $deleteResult = $this->client->deleteCategories([$categoryId]); + + self::assertTrue($deleteResult->success); + self::assertSame(1, $deleteResult->deleted); + self::assertFalse($deleteResult->hasErrors()); + + $this->untrackCategory($categoryId); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..07f3793 --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,124 @@ + */ + private array $productIdsToCleanup = []; + + /** @var array */ + private array $categoryIdsToCleanup = []; + + /** @var array */ + private array $blogIdsToCleanup = []; + + protected function setUp(): void + { + $token = getenv('POBO_API_TOKEN'); + + if ($token === false || $token === '') { + self::markTestSkipped('Integration tests require POBO_API_TOKEN environment variable.'); + } + + $baseUrl = getenv('POBO_BASE_URL'); + if ($baseUrl === false || $baseUrl === '') { + $baseUrl = 'https://api.pobo.space'; + } + + $this->client = new PoboClient( + apiToken: $token, + baseUrl: $baseUrl, + timeout: 60, + ); + + $runId = getenv('GITHUB_RUN_ID'); + if ($runId === false || $runId === '') { + $runId = (string) getmypid(); + } + $this->idPrefix = sprintf('CI-%s-%s', $runId, bin2hex(random_bytes(3))); + } + + protected function tearDown(): void + { + if ($this->productIdsToCleanup !== []) { + try { + $this->client->deleteProducts($this->productIdsToCleanup); + } catch (\Throwable) { + // Cleanup is best-effort; don't mask the actual test failure. + } + } + + if ($this->categoryIdsToCleanup !== []) { + try { + $this->client->deleteCategories($this->categoryIdsToCleanup); + } catch (\Throwable) { + } + } + + if ($this->blogIdsToCleanup !== []) { + try { + $this->client->deleteBlogs($this->blogIdsToCleanup); + } catch (\Throwable) { + } + } + } + + protected function trackProduct(string $id): void + { + $this->productIdsToCleanup[] = $id; + } + + protected function untrackProduct(string $id): void + { + $this->productIdsToCleanup = array_values(array_filter( + $this->productIdsToCleanup, + fn(string $existing) => $existing !== $id, + )); + } + + protected function trackCategory(string $id): void + { + $this->categoryIdsToCleanup[] = $id; + } + + protected function untrackCategory(string $id): void + { + $this->categoryIdsToCleanup = array_values(array_filter( + $this->categoryIdsToCleanup, + fn(string $existing) => $existing !== $id, + )); + } + + protected function trackBlog(string $id): void + { + $this->blogIdsToCleanup[] = $id; + } + + protected function untrackBlog(string $id): void + { + $this->blogIdsToCleanup = array_values(array_filter( + $this->blogIdsToCleanup, + fn(string $existing) => $existing !== $id, + )); + } + + protected function uniqueId(string $kind): string + { + return sprintf('%s-%s-%s', $this->idPrefix, $kind, bin2hex(random_bytes(2))); + } +} diff --git a/tests/Integration/ProductLifecycleTest.php b/tests/Integration/ProductLifecycleTest.php new file mode 100644 index 0000000..47bcad6 --- /dev/null +++ b/tests/Integration/ProductLifecycleTest.php @@ -0,0 +1,54 @@ +uniqueId('prod'); + $this->trackProduct($productId); + + $product = new Product( + id: $productId, + isVisible: true, + name: LocalizedString::create('SDK Integration Test Product') + ->withTranslation(Language::CS, 'SDK Integration Test Produkt'), + url: LocalizedString::create(sprintf('https://example.com/%s', $productId)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $productId)), + shortDescription: LocalizedString::create('Created by SDK CI'), + ); + + $importResult = $this->client->importProducts([$product]); + + self::assertTrue($importResult->success, 'Import should report success.'); + self::assertSame(0, $importResult->skipped, sprintf('Unexpected skipped items: %s', json_encode($importResult->errors))); + self::assertFalse($importResult->hasErrors(), 'Import should not produce per-item errors.'); + self::assertSame(1, $importResult->imported + $importResult->updated, 'Exactly one item should be imported or updated.'); + + $found = null; + foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $candidate) { + if ($candidate->id === $productId) { + $found = $candidate; + break; + } + } + + self::assertNotNull($found, sprintf('Imported product %s was not returned by iterateProducts().', $productId)); + self::assertSame('SDK Integration Test Product', $found->name->getDefault()); + + $deleteResult = $this->client->deleteProducts([$productId]); + + self::assertTrue($deleteResult->success); + self::assertSame(1, $deleteResult->deleted); + self::assertFalse($deleteResult->hasErrors()); + + $this->untrackProduct($productId); + } +} From c2c77721ac5635b199f384ac0f382e3eee6a3845 Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Tue, 28 Apr 2026 17:02:28 +0200 Subject: [PATCH 6/7] Fix tests --- tests/Integration/CategoryLifecycleTest.php | 3 ++- tests/Integration/ProductLifecycleTest.php | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Integration/CategoryLifecycleTest.php b/tests/Integration/CategoryLifecycleTest.php index d26267c..4a4da99 100644 --- a/tests/Integration/CategoryLifecycleTest.php +++ b/tests/Integration/CategoryLifecycleTest.php @@ -22,7 +22,8 @@ public function testImportGetAndDeleteCategory(): void ->withTranslation(Language::CS, 'SDK Integration Test Kategorie'), url: LocalizedString::create(sprintf('https://example.com/%s', $categoryId)) ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $categoryId)), - description: LocalizedString::create('

Created by SDK CI

'), + description: LocalizedString::create('

Created by SDK CI

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

Vytvořeno SDK CI

'), ); $importResult = $this->client->importCategories([$category]); diff --git a/tests/Integration/ProductLifecycleTest.php b/tests/Integration/ProductLifecycleTest.php index 47bcad6..400821c 100644 --- a/tests/Integration/ProductLifecycleTest.php +++ b/tests/Integration/ProductLifecycleTest.php @@ -22,7 +22,10 @@ public function testImportGetAndDeleteProduct(): void ->withTranslation(Language::CS, 'SDK Integration Test Produkt'), url: LocalizedString::create(sprintf('https://example.com/%s', $productId)) ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $productId)), - shortDescription: LocalizedString::create('Created by SDK CI'), + shortDescription: LocalizedString::create('Created by SDK CI') + ->withTranslation(Language::CS, 'Vytvořeno SDK CI'), + description: LocalizedString::create('

Created by SDK CI

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

Vytvořeno SDK CI

'), ); $importResult = $this->client->importProducts([$product]); From d4f5685142be889818d539cad9686f57ddbf14a7 Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Tue, 28 Apr 2026 17:19:09 +0200 Subject: [PATCH 7/7] Add tests --- composer.json | 9 +- tests/Integration/BulkImportTest.php | 96 +++++++++++++++ tests/Integration/BulkUpdateTest.php | 144 ++++++++++++++++++++++ tests/Integration/IntegrationTestCase.php | 91 ++++++++++++++ 4 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/BulkImportTest.php create mode 100644 tests/Integration/BulkUpdateTest.php diff --git a/composer.json b/composer.json index efa9e08..867e722 100644 --- a/composer.json +++ b/composer.json @@ -13,9 +13,9 @@ "homepage": "https://github.com/pobo-builder/php-sdk", "authors": [ { - "name": "Pobo", + "name": "Tomas Smetka", "email": "tomas@pobo.cz", - "homepage": "https://pobo.cz" + "homepage": "https://pobo.space" } ], "support": { @@ -28,8 +28,9 @@ "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "phpstan/phpstan": "^2.0" + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^2.1", + "fakerphp/faker": "^1.23" }, "autoload": { "psr-4": { diff --git a/tests/Integration/BulkImportTest.php b/tests/Integration/BulkImportTest.php new file mode 100644 index 0000000..2ee20d0 --- /dev/null +++ b/tests/Integration/BulkImportTest.php @@ -0,0 +1,96 @@ +makeProduct(); + } + $expectedIds = array_map(fn($p) => $p->id, $products); + + $importResult = $this->client->importProducts($products); + + self::assertTrue($importResult->success); + self::assertSame(0, $importResult->skipped, sprintf('Unexpected errors: %s', json_encode($importResult->errors))); + self::assertFalse($importResult->hasErrors()); + self::assertSame(self::BULK_SIZE, $importResult->imported + $importResult->updated); + + $foundIds = []; + foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $product) { + if (in_array($product->id, $expectedIds, true)) { + $foundIds[] = $product->id; + } + if (count($foundIds) === self::BULK_SIZE) { + break; + } + } + + self::assertCount(self::BULK_SIZE, $foundIds, 'All imported products should be retrievable.'); + } + + public function testBulkImportCategories(): void + { + $categories = []; + for ($i = 0; $i < self::BULK_SIZE; $i++) { + $categories[] = $this->makeCategory(); + } + $expectedIds = array_map(fn($c) => $c->id, $categories); + + $importResult = $this->client->importCategories($categories); + + self::assertTrue($importResult->success); + self::assertSame(0, $importResult->skipped, sprintf('Unexpected errors: %s', json_encode($importResult->errors))); + self::assertFalse($importResult->hasErrors()); + self::assertSame(self::BULK_SIZE, $importResult->imported + $importResult->updated); + + $foundIds = []; + foreach ($this->client->iterateCategories(isEdited: false, lang: [Language::ALL]) as $category) { + if (in_array($category->id, $expectedIds, true)) { + $foundIds[] = $category->id; + } + if (count($foundIds) === self::BULK_SIZE) { + break; + } + } + + self::assertCount(self::BULK_SIZE, $foundIds, 'All imported categories should be retrievable.'); + } + + public function testBulkImportBlogs(): void + { + $blogs = []; + for ($i = 0; $i < self::BULK_SIZE; $i++) { + $blogs[] = $this->makeBlog(); + } + $expectedIds = array_map(fn($b) => $b->id, $blogs); + + $importResult = $this->client->importBlogs($blogs); + + self::assertTrue($importResult->success); + self::assertSame(0, $importResult->skipped, sprintf('Unexpected errors: %s', json_encode($importResult->errors))); + self::assertFalse($importResult->hasErrors()); + self::assertSame(self::BULK_SIZE, $importResult->imported + $importResult->updated); + + $foundIds = []; + foreach ($this->client->iterateBlogs(isEdited: false, lang: [Language::ALL]) as $blog) { + if (in_array($blog->id, $expectedIds, true)) { + $foundIds[] = $blog->id; + } + if (count($foundIds) === self::BULK_SIZE) { + break; + } + } + + self::assertCount(self::BULK_SIZE, $foundIds, 'All imported blogs should be retrievable.'); + } +} diff --git a/tests/Integration/BulkUpdateTest.php b/tests/Integration/BulkUpdateTest.php new file mode 100644 index 0000000..c66ca53 --- /dev/null +++ b/tests/Integration/BulkUpdateTest.php @@ -0,0 +1,144 @@ +makeProduct(); + } + + $firstResult = $this->client->importProducts($originals); + + self::assertSame(self::BULK_SIZE, $firstResult->imported, 'First pass should be a fresh import.'); + self::assertSame(0, $firstResult->updated); + self::assertFalse($firstResult->hasErrors()); + + // Re-build the same items with the same IDs but fresh faker content. + $updates = array_map(fn($product) => $this->makeProduct($product->id), $originals); + + $secondResult = $this->client->importProducts($updates); + + self::assertSame(0, $secondResult->imported, 'Re-import must not create new records.'); + self::assertSame(self::BULK_SIZE, $secondResult->updated); + self::assertFalse($secondResult->hasErrors()); + + // Verify the new content actually propagated to the server. + $expectedNamesById = []; + foreach ($updates as $product) { + $expectedNamesById[$product->id] = $product->name->getDefault(); + } + + $verified = 0; + foreach ($this->client->iterateProducts(isEdited: false, lang: [Language::ALL]) as $product) { + if (isset($expectedNamesById[$product->id])) { + self::assertSame( + $expectedNamesById[$product->id], + $product->name->getDefault(), + sprintf('Product %s should reflect the updated name.', $product->id), + ); + $verified++; + } + if ($verified === self::BULK_SIZE) { + break; + } + } + + self::assertSame(self::BULK_SIZE, $verified, 'All updated products should be readable.'); + } + + public function testReimportCategoriesTriggersUpdate(): void + { + $originals = []; + for ($i = 0; $i < self::BULK_SIZE; $i++) { + $originals[] = $this->makeCategory(); + } + + $firstResult = $this->client->importCategories($originals); + + self::assertSame(self::BULK_SIZE, $firstResult->imported); + self::assertSame(0, $firstResult->updated); + self::assertFalse($firstResult->hasErrors()); + + $updates = array_map(fn($category) => $this->makeCategory($category->id), $originals); + + $secondResult = $this->client->importCategories($updates); + + self::assertSame(0, $secondResult->imported); + self::assertSame(self::BULK_SIZE, $secondResult->updated); + self::assertFalse($secondResult->hasErrors()); + + $expectedNamesById = []; + foreach ($updates as $category) { + $expectedNamesById[$category->id] = $category->name->getDefault(); + } + + $verified = 0; + foreach ($this->client->iterateCategories(isEdited: false, lang: [Language::ALL]) as $category) { + if (isset($expectedNamesById[$category->id])) { + self::assertSame( + $expectedNamesById[$category->id], + $category->name->getDefault(), + ); + $verified++; + } + if ($verified === self::BULK_SIZE) { + break; + } + } + + self::assertSame(self::BULK_SIZE, $verified); + } + + public function testReimportBlogsTriggersUpdate(): void + { + $originals = []; + for ($i = 0; $i < self::BULK_SIZE; $i++) { + $originals[] = $this->makeBlog(); + } + + $firstResult = $this->client->importBlogs($originals); + + self::assertSame(self::BULK_SIZE, $firstResult->imported); + self::assertSame(0, $firstResult->updated); + self::assertFalse($firstResult->hasErrors()); + + $updates = array_map(fn($blog) => $this->makeBlog($blog->id), $originals); + + $secondResult = $this->client->importBlogs($updates); + + self::assertSame(0, $secondResult->imported); + self::assertSame(self::BULK_SIZE, $secondResult->updated); + self::assertFalse($secondResult->hasErrors()); + + $expectedNamesById = []; + foreach ($updates as $blog) { + $expectedNamesById[$blog->id] = $blog->name->getDefault(); + } + + $verified = 0; + foreach ($this->client->iterateBlogs(isEdited: false, lang: [Language::ALL]) as $blog) { + if (isset($expectedNamesById[$blog->id])) { + self::assertSame( + $expectedNamesById[$blog->id], + $blog->name->getDefault(), + ); + $verified++; + } + if ($verified === self::BULK_SIZE) { + break; + } + } + + self::assertSame(self::BULK_SIZE, $verified); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 07f3793..8f43f08 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -4,13 +4,22 @@ namespace Pobo\Sdk\Tests\Integration; +use Faker\Factory as FakerFactory; +use Faker\Generator as FakerGenerator; use PHPUnit\Framework\TestCase; +use Pobo\Sdk\DTO\Blog; +use Pobo\Sdk\DTO\Category; +use Pobo\Sdk\DTO\LocalizedString; +use Pobo\Sdk\DTO\Product; +use Pobo\Sdk\Enum\Language; use Pobo\Sdk\PoboClient; abstract class IntegrationTestCase extends TestCase { protected PoboClient $client; + protected FakerGenerator $faker; + /** * Unique prefix used for IDs created by a single test run. * Combines GitHub run ID (when in CI) with a random suffix so concurrent @@ -51,6 +60,8 @@ protected function setUp(): void $runId = (string) getmypid(); } $this->idPrefix = sprintf('CI-%s-%s', $runId, bin2hex(random_bytes(3))); + + $this->faker = FakerFactory::create('en_US'); } protected function tearDown(): void @@ -121,4 +132,84 @@ protected function uniqueId(string $kind): string { return sprintf('%s-%s-%s', $this->idPrefix, $kind, bin2hex(random_bytes(2))); } + + /** + * Build a Product with realistic faker content. If $id is null, a new unique + * ID is generated and tracked for cleanup. If $id is provided, the caller is + * responsible for tracking it (typical for update tests reusing existing IDs). + */ + protected function makeProduct(?string $id = null): Product + { + if ($id === null) { + $id = $this->uniqueId('prod'); + $this->trackProduct($id); + } + + $nameEn = $this->faker->unique()->words(3, true); + $nameCs = sprintf('Produkt %s', $this->faker->unique()->word()); + $slug = $this->faker->slug(2); + + return new Product( + id: $id, + isVisible: true, + name: LocalizedString::create($nameEn)->withTranslation(Language::CS, $nameCs), + url: LocalizedString::create(sprintf('https://example.com/%s', $slug)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $slug)), + shortDescription: LocalizedString::create($this->faker->sentence(8)) + ->withTranslation(Language::CS, $this->faker->sentence(8)), + description: LocalizedString::create(sprintf('

%s

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

%s

', $this->faker->paragraph(3))), + ); + } + + /** + * Build a Category with realistic faker content. See {@see makeProduct()} for ID behavior. + */ + protected function makeCategory(?string $id = null): Category + { + if ($id === null) { + $id = $this->uniqueId('cat'); + $this->trackCategory($id); + } + + $nameEn = $this->faker->unique()->words(2, true); + $nameCs = sprintf('Kategorie %s', $this->faker->unique()->word()); + $slug = $this->faker->slug(2); + + return new Category( + id: $id, + isVisible: true, + name: LocalizedString::create($nameEn)->withTranslation(Language::CS, $nameCs), + url: LocalizedString::create(sprintf('https://example.com/%s', $slug)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/%s', $slug)), + description: LocalizedString::create(sprintf('

%s

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

%s

', $this->faker->paragraph(2))), + ); + } + + /** + * Build a Blog with realistic faker content. See {@see makeProduct()} for ID behavior. + */ + protected function makeBlog(?string $id = null): Blog + { + if ($id === null) { + $id = $this->uniqueId('blog'); + $this->trackBlog($id); + } + + $titleEn = $this->faker->unique()->sentence(4); + $titleCs = sprintf('Článek %s', $this->faker->unique()->word()); + $slug = $this->faker->slug(3); + + return new Blog( + id: $id, + isVisible: true, + name: LocalizedString::create($titleEn)->withTranslation(Language::CS, $titleCs), + url: LocalizedString::create(sprintf('https://example.com/blog/%s', $slug)) + ->withTranslation(Language::CS, sprintf('https://example.com/cs/blog/%s', $slug)), + category: $this->faker->randomElement(['news', 'tips', 'reviews', 'guides']), + description: LocalizedString::create(sprintf('

%s

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

%s

', $this->faker->paragraph(4))), + ); + } }