From ae69ceb7239bf79c3832e0922e589302c2857555 Mon Sep 17 00:00:00 2001 From: Tomas Smetka Date: Tue, 5 May 2026 15:51:25 +0200 Subject: [PATCH] Add tests and brands webhook --- README.md | 56 ++++++++++++++++++--- src/Enum/WebhookEvent.php | 34 ++++++++++--- tests/Enum/WebhookEventTest.php | 81 ++++++++++++++++++++++++++++++- tests/WebhookHandlerTest.php | 86 +++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 55054f2..8617e73 100644 --- a/README.md +++ b/README.md @@ -329,14 +329,31 @@ foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) { Pobo can notify your application when content changes in the administration. Register a webhook URL in the Pobo e-shop settings; Pobo POSTs to it with HMAC-SHA256 signed requests. -| Event | Constant | Fired when | -|----------------------|-----------------------------------|----------------------------------------| -| `Products.update` | `WebhookEvent::PRODUCTS_UPDATE` | a product was edited and saved in Pobo | -| `Categories.update` | `WebhookEvent::CATEGORIES_UPDATE` | a category was edited | -| `Blogs.update` | `WebhookEvent::BLOGS_UPDATE` | a blog was edited | +### Supported events + +| Event | Constant | Fired when | +|------------------------|-------------------------------------|-------------------------------------| +| `Products.create` | `WebhookEvent::PRODUCTS_CREATE` | a product was created | +| `Products.update` | `WebhookEvent::PRODUCTS_UPDATE` | a product was edited | +| `Products.delete` | `WebhookEvent::PRODUCTS_DELETE` | a product was soft-deleted | +| `Categories.create` | `WebhookEvent::CATEGORIES_CREATE` | a category was created | +| `Categories.update` | `WebhookEvent::CATEGORIES_UPDATE` | a category was edited | +| `Categories.delete` | `WebhookEvent::CATEGORIES_DELETE` | a category was soft-deleted | +| `Brands.create` | `WebhookEvent::BRANDS_CREATE` | a brand was created | +| `Brands.update` | `WebhookEvent::BRANDS_UPDATE` | a brand was edited | +| `Brands.delete` | `WebhookEvent::BRANDS_DELETE` | a brand was soft-deleted | +| `Blogs.create` | `WebhookEvent::BLOGS_CREATE` | a blog was created | +| `Blogs.update` | `WebhookEvent::BLOGS_UPDATE` | a blog was edited | +| `Blogs.delete` | `WebhookEvent::BLOGS_DELETE` | a blog was soft-deleted | + +The enum exposes `isCreate()` / `isUpdate()` / `isDelete()` helpers if you only need to branch on the lifecycle action regardless of entity type. + +> **Note:** the API also defines `Languages.create/update/delete` events. The SDK does not expose them yet — payloads carrying these events will throw `WebhookException::unknownEvent()`. The webhook is a notification only — it does not carry the changed entities. Use it as a trigger to re-sync via the export endpoints (typically combined with `lastUpdateFrom`). +### Basic usage + ```php use Pobo\Sdk\WebhookHandler; use Pobo\Sdk\Enum\WebhookEvent; @@ -348,9 +365,18 @@ try { $payload = $handler->handleFromGlobals(); match ($payload->event) { - WebhookEvent::PRODUCTS_UPDATE => syncProducts($client), - WebhookEvent::CATEGORIES_UPDATE => syncCategories($client), - WebhookEvent::BLOGS_UPDATE => syncBlogs($client), + WebhookEvent::PRODUCTS_CREATE, + WebhookEvent::PRODUCTS_UPDATE, + WebhookEvent::PRODUCTS_DELETE => syncProducts($client), + WebhookEvent::CATEGORIES_CREATE, + WebhookEvent::CATEGORIES_UPDATE, + WebhookEvent::CATEGORIES_DELETE => syncCategories($client), + WebhookEvent::BRANDS_CREATE, + WebhookEvent::BRANDS_UPDATE, + WebhookEvent::BRANDS_DELETE => syncBrands($client), + WebhookEvent::BLOGS_CREATE, + WebhookEvent::BLOGS_UPDATE, + WebhookEvent::BLOGS_DELETE => syncBlogs($client), }; http_response_code(200); @@ -362,6 +388,20 @@ try { `$payload` exposes `event` (`WebhookEvent` enum), `timestamp` (`DateTimeInterface`), and `eshopId` (int). +### Branching only on lifecycle action + +```php +$payload = $handler->handleFromGlobals(); + +if ($payload->event->isDelete()) { + invalidateCache($payload->event, $payload->eshopId); +} + +if ($payload->event->isCreate() || $payload->event->isUpdate()) { + triggerResync($payload->event, $payload->eshopId); +} +``` + ## Error Handling ```php diff --git a/src/Enum/WebhookEvent.php b/src/Enum/WebhookEvent.php index bd92b4a..2f934e1 100644 --- a/src/Enum/WebhookEvent.php +++ b/src/Enum/WebhookEvent.php @@ -6,9 +6,21 @@ enum WebhookEvent: string { + case PRODUCTS_CREATE = 'Products.create'; case PRODUCTS_UPDATE = 'Products.update'; + case PRODUCTS_DELETE = 'Products.delete'; + + case CATEGORIES_CREATE = 'Categories.create'; case CATEGORIES_UPDATE = 'Categories.update'; + case CATEGORIES_DELETE = 'Categories.delete'; + + case BRANDS_CREATE = 'Brands.create'; + case BRANDS_UPDATE = 'Brands.update'; + case BRANDS_DELETE = 'Brands.delete'; + + case BLOGS_CREATE = 'Blogs.create'; case BLOGS_UPDATE = 'Blogs.update'; + case BLOGS_DELETE = 'Blogs.delete'; /** * @return array @@ -20,11 +32,21 @@ public static function values(): array public static function fromString(string $value): ?self { - return match ($value) { - 'Products.update' => self::PRODUCTS_UPDATE, - 'Categories.update' => self::CATEGORIES_UPDATE, - 'Blogs.update' => self::BLOGS_UPDATE, - default => null, - }; + return self::tryFrom($value); + } + + public function isCreate(): bool + { + return str_ends_with($this->value, '.create'); + } + + public function isUpdate(): bool + { + return str_ends_with($this->value, '.update'); + } + + public function isDelete(): bool + { + return str_ends_with($this->value, '.delete'); } } diff --git a/tests/Enum/WebhookEventTest.php b/tests/Enum/WebhookEventTest.php index c0f45ca..0a63610 100644 --- a/tests/Enum/WebhookEventTest.php +++ b/tests/Enum/WebhookEventTest.php @@ -11,28 +11,70 @@ final class WebhookEventTest extends TestCase { public function testAllCasesExist(): void { + $this->assertSame('Products.create', WebhookEvent::PRODUCTS_CREATE->value); $this->assertSame('Products.update', WebhookEvent::PRODUCTS_UPDATE->value); + $this->assertSame('Products.delete', WebhookEvent::PRODUCTS_DELETE->value); + + $this->assertSame('Categories.create', WebhookEvent::CATEGORIES_CREATE->value); $this->assertSame('Categories.update', WebhookEvent::CATEGORIES_UPDATE->value); + $this->assertSame('Categories.delete', WebhookEvent::CATEGORIES_DELETE->value); + + $this->assertSame('Brands.create', WebhookEvent::BRANDS_CREATE->value); + $this->assertSame('Brands.update', WebhookEvent::BRANDS_UPDATE->value); + $this->assertSame('Brands.delete', WebhookEvent::BRANDS_DELETE->value); + + $this->assertSame('Blogs.create', WebhookEvent::BLOGS_CREATE->value); $this->assertSame('Blogs.update', WebhookEvent::BLOGS_UPDATE->value); + $this->assertSame('Blogs.delete', WebhookEvent::BLOGS_DELETE->value); } public function testValues(): void { $values = WebhookEvent::values(); + $this->assertCount(12, $values); + $this->assertContains('Products.create', $values); $this->assertContains('Products.update', $values); + $this->assertContains('Products.delete', $values); + $this->assertContains('Categories.create', $values); $this->assertContains('Categories.update', $values); + $this->assertContains('Categories.delete', $values); + $this->assertContains('Brands.create', $values); + $this->assertContains('Brands.update', $values); + $this->assertContains('Brands.delete', $values); + $this->assertContains('Blogs.create', $values); $this->assertContains('Blogs.update', $values); - $this->assertCount(3, $values); + $this->assertContains('Blogs.delete', $values); } - public function testFromStringReturnsCorrectEnum(): void + public function testFromStringReturnsCorrectEnumForExistingEvents(): void { $this->assertSame(WebhookEvent::PRODUCTS_UPDATE, WebhookEvent::fromString('Products.update')); $this->assertSame(WebhookEvent::CATEGORIES_UPDATE, WebhookEvent::fromString('Categories.update')); $this->assertSame(WebhookEvent::BLOGS_UPDATE, WebhookEvent::fromString('Blogs.update')); } + public function testFromStringReturnsCorrectEnumForCreateEvents(): void + { + $this->assertSame(WebhookEvent::PRODUCTS_CREATE, WebhookEvent::fromString('Products.create')); + $this->assertSame(WebhookEvent::CATEGORIES_CREATE, WebhookEvent::fromString('Categories.create')); + $this->assertSame(WebhookEvent::BRANDS_CREATE, WebhookEvent::fromString('Brands.create')); + $this->assertSame(WebhookEvent::BLOGS_CREATE, WebhookEvent::fromString('Blogs.create')); + } + + public function testFromStringReturnsCorrectEnumForDeleteEvents(): void + { + $this->assertSame(WebhookEvent::PRODUCTS_DELETE, WebhookEvent::fromString('Products.delete')); + $this->assertSame(WebhookEvent::CATEGORIES_DELETE, WebhookEvent::fromString('Categories.delete')); + $this->assertSame(WebhookEvent::BRANDS_DELETE, WebhookEvent::fromString('Brands.delete')); + $this->assertSame(WebhookEvent::BLOGS_DELETE, WebhookEvent::fromString('Blogs.delete')); + } + + public function testFromStringReturnsCorrectEnumForBrandUpdate(): void + { + $this->assertSame(WebhookEvent::BRANDS_UPDATE, WebhookEvent::fromString('Brands.update')); + } + public function testFromStringReturnsNullForUnknown(): void { $this->assertNull(WebhookEvent::fromString('Unknown.event')); @@ -41,5 +83,40 @@ public function testFromStringReturnsNullForUnknown(): void $this->assertNull(WebhookEvent::fromString('PRODUCTS.UPDATE')); $this->assertNull(WebhookEvent::fromString('blogs.update')); $this->assertNull(WebhookEvent::fromString('BLOGS.UPDATE')); + $this->assertNull(WebhookEvent::fromString('brands.create')); + $this->assertNull(WebhookEvent::fromString('Languages.create')); // not in SDK yet + } + + public function testIsCreateHelper(): void + { + $this->assertTrue(WebhookEvent::PRODUCTS_CREATE->isCreate()); + $this->assertTrue(WebhookEvent::CATEGORIES_CREATE->isCreate()); + $this->assertTrue(WebhookEvent::BRANDS_CREATE->isCreate()); + $this->assertTrue(WebhookEvent::BLOGS_CREATE->isCreate()); + + $this->assertFalse(WebhookEvent::PRODUCTS_UPDATE->isCreate()); + $this->assertFalse(WebhookEvent::BRANDS_DELETE->isCreate()); + } + + public function testIsUpdateHelper(): void + { + $this->assertTrue(WebhookEvent::PRODUCTS_UPDATE->isUpdate()); + $this->assertTrue(WebhookEvent::CATEGORIES_UPDATE->isUpdate()); + $this->assertTrue(WebhookEvent::BRANDS_UPDATE->isUpdate()); + $this->assertTrue(WebhookEvent::BLOGS_UPDATE->isUpdate()); + + $this->assertFalse(WebhookEvent::PRODUCTS_CREATE->isUpdate()); + $this->assertFalse(WebhookEvent::BRANDS_DELETE->isUpdate()); + } + + public function testIsDeleteHelper(): void + { + $this->assertTrue(WebhookEvent::PRODUCTS_DELETE->isDelete()); + $this->assertTrue(WebhookEvent::CATEGORIES_DELETE->isDelete()); + $this->assertTrue(WebhookEvent::BRANDS_DELETE->isDelete()); + $this->assertTrue(WebhookEvent::BLOGS_DELETE->isDelete()); + + $this->assertFalse(WebhookEvent::PRODUCTS_CREATE->isDelete()); + $this->assertFalse(WebhookEvent::BRANDS_UPDATE->isDelete()); } } diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index 842caf7..cd64f12 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -158,4 +158,90 @@ public function testHandleWithDifferentTimestampFormats(): void $this->assertInstanceOf(\DateTimeInterface::class, $result->timestamp); } + + /** + * @return array + */ + public static function brandEventProvider(): array + { + return [ + 'Brands.create' => ['Brands.create', WebhookEvent::BRANDS_CREATE], + 'Brands.update' => ['Brands.update', WebhookEvent::BRANDS_UPDATE], + 'Brands.delete' => ['Brands.delete', WebhookEvent::BRANDS_DELETE], + ]; + } + + /** + * @dataProvider brandEventProvider + */ + public function testHandleValidBrandEvent(string $eventValue, WebhookEvent $expected): void + { + $payload = json_encode([ + 'event' => $eventValue, + 'timestamp' => '2026-05-05T14:30:00+02:00', + 'eshop_id' => 789, + ]); + + $signature = hash_hmac('sha256', $payload, self::WEBHOOK_SECRET); + + $result = $this->handler->handle($payload, $signature); + + $this->assertSame($expected, $result->event); + $this->assertSame(789, $result->eshopId); + } + + /** + * @return array + */ + public static function createDeleteEventProvider(): array + { + return [ + 'Products.create' => ['Products.create', WebhookEvent::PRODUCTS_CREATE], + 'Products.delete' => ['Products.delete', WebhookEvent::PRODUCTS_DELETE], + 'Categories.create' => ['Categories.create', WebhookEvent::CATEGORIES_CREATE], + 'Categories.delete' => ['Categories.delete', WebhookEvent::CATEGORIES_DELETE], + 'Blogs.create' => ['Blogs.create', WebhookEvent::BLOGS_CREATE], + 'Blogs.delete' => ['Blogs.delete', WebhookEvent::BLOGS_DELETE], + ]; + } + + /** + * @dataProvider createDeleteEventProvider + */ + public function testHandleValidCreateOrDeleteEvent(string $eventValue, WebhookEvent $expected): void + { + $payload = json_encode([ + 'event' => $eventValue, + 'timestamp' => '2026-05-05T14:30:00+02:00', + 'eshop_id' => 555, + ]); + + $signature = hash_hmac('sha256', $payload, self::WEBHOOK_SECRET); + + $result = $this->handler->handle($payload, $signature); + + $this->assertSame($expected, $result->event); + } + + public function testHandleBrandsCreateMatchableInUserCode(): void + { + // Verifies the typical match() pattern works for brand events. + $payload = json_encode([ + 'event' => 'Brands.create', + 'timestamp' => '2026-05-05T14:30:00+02:00', + 'eshop_id' => 1, + ]); + + $signature = hash_hmac('sha256', $payload, self::WEBHOOK_SECRET); + $result = $this->handler->handle($payload, $signature); + + $branch = match ($result->event) { + WebhookEvent::BRANDS_CREATE => 'created', + WebhookEvent::BRANDS_UPDATE => 'updated', + WebhookEvent::BRANDS_DELETE => 'deleted', + default => 'other', + }; + + $this->assertSame('created', $branch); + } }