From 46cff4553f571acbd8a120fac1297baa77662903 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Sun, 17 May 2026 22:43:52 +0000 Subject: [PATCH] feat: add eBay and Frappe channel adapters with order infrastructure eBay adapter covers OAuth, inventory API, fulfillment API, taxonomy service and webhook signature verification. Frappe ERP adapter wraps the Frappe HTTP client for order/invoice sync. Includes CustomerResolver, InvoiceMailer, and the EbayWebhookController for inbound eBay marketplace notifications. Co-Authored-By: Claude Sonnet 4.6 --- .../Order/CustomerResolverInterface.php | 26 ++++ src/Application/Order/ErpAdapterInterface.php | 29 ++++ .../Order/InvoiceMailerInterface.php | 16 +++ .../Channel/Ebay/EbayAdapter.php | 126 ++++++++++++++++++ .../Channel/Ebay/EbayFulfillmentApiClient.php | 93 +++++++++++++ .../Channel/Ebay/EbayInventoryApiClient.php | 125 +++++++++++++++++ .../Channel/Ebay/EbayOAuthClient.php | 45 +++++++ .../Channel/Ebay/EbayTaxonomyService.php | 73 ++++++++++ .../Channel/Ebay/EbayWebhookVerifier.php | 36 +++++ .../Channel/Frappe/FrappeErpAdapter.php | 82 ++++++++++++ .../Channel/Frappe/FrappeHttpClient.php | 54 ++++++++ .../Webhook/EbayWebhookController.php | 100 ++++++++++++++ .../Mail/SymfonyInvoiceMailer.php | 55 ++++++++ src/Infrastructure/Order/CustomerResolver.php | 51 +++++++ .../Channel/Ebay/EbayAdapterTest.php | 61 +++++++++ .../Channel/Ebay/EbayWebhookVerifierTest.php | 42 ++++++ .../Channel/Frappe/FrappeErpAdapterTest.php | 82 ++++++++++++ .../Order/CustomerResolverTest.php | 89 +++++++++++++ 18 files changed, 1185 insertions(+) create mode 100644 src/Application/Order/CustomerResolverInterface.php create mode 100644 src/Application/Order/ErpAdapterInterface.php create mode 100644 src/Application/Order/InvoiceMailerInterface.php create mode 100644 src/Infrastructure/Channel/Ebay/EbayAdapter.php create mode 100644 src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php create mode 100644 src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php create mode 100644 src/Infrastructure/Channel/Ebay/EbayOAuthClient.php create mode 100644 src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php create mode 100644 src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php create mode 100644 src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php create mode 100644 src/Infrastructure/Channel/Frappe/FrappeHttpClient.php create mode 100644 src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php create mode 100644 src/Infrastructure/Mail/SymfonyInvoiceMailer.php create mode 100644 src/Infrastructure/Order/CustomerResolver.php create mode 100644 tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php create mode 100644 tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php create mode 100644 tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php create mode 100644 tests/Unit/Infrastructure/Order/CustomerResolverTest.php diff --git a/src/Application/Order/CustomerResolverInterface.php b/src/Application/Order/CustomerResolverInterface.php new file mode 100644 index 0000000..5c0e947 --- /dev/null +++ b/src/Application/Order/CustomerResolverInterface.php @@ -0,0 +1,26 @@ + $address Keys: street, city, zip + */ + public function resolve( + string $platform, + string $platformUserId, + string $name, + string $email, + array $address, + ): Customer; +} diff --git a/src/Application/Order/ErpAdapterInterface.php b/src/Application/Order/ErpAdapterInterface.php new file mode 100644 index 0000000..2d69686 --- /dev/null +++ b/src/Application/Order/ErpAdapterInterface.php @@ -0,0 +1,29 @@ +getSku(); + + $this->apiClient->upsertInventoryItem($sku, [ + 'availability' => [ + 'shipToLocationAvailability' => [ + 'quantity' => $article->getStock(), + ], + ], + 'condition' => $this->mapCondition($article->getCondition()), + 'conditionDescription' => $article->getConditionNotes() ?? '', + 'product' => [ + 'title' => $article->getEbayTitle() ?? $article->getSku(), + 'description' => $article->getEbayDescription() ?? '', + 'aspects' => $this->buildAspects($article), + ], + ]); + + $offerId = $this->apiClient->createOffer([ + 'sku' => $sku, + 'marketplaceId' => 'EBAY_DE', + 'format' => 'FIXED_PRICE', + 'availableQuantity' => $article->getStock(), + 'pricingSummary' => [ + 'price' => [ + 'currency' => 'EUR', + 'value' => number_format((float) ($article->getListingPrice() ?? '0'), 2, '.', ''), + ], + ], + 'listingDescription' => $article->getEbayDescription() ?? '', + 'categoryId' => $this->getCategoryId(), + ]); + + return $this->apiClient->publishOffer($offerId); + } + + public function updateStock(Article $article, int $stock): void + { + $this->apiClient->bulkUpdateInventoryItems([ + 'requests' => [ + [ + 'sku' => $article->getSku(), + 'shipToLocationAvailability' => [ + 'quantity' => $stock, + ], + ], + ], + ]); + } + + public function deactivateListing(Article $article): void + { + $listingId = $article->getEbayListingId(); + if (null === $listingId) { + return; + } + + $this->apiClient->withdrawOffer($listingId); + } + + public function pushTracking(Order $order): void + { + if (null === $order->getTrackingNumber()) { + throw new \RuntimeException('Order has no tracking number'); + } + + $this->apiClient->addTrackingToOrder($order->getPlatformOrderId(), [ + 'lineItems' => [ + ['lineItemId' => $order->getPlatformOrderId(), 'quantity' => 1], + ], + 'shippingCarrierCode' => $order->getCarrier() ?? 'DHL', + 'trackingNumber' => $order->getTrackingNumber(), + ]); + } + + private function mapCondition(ArticleCondition $condition): string + { + return match ($condition) { + ArticleCondition::New => 'NEW', + ArticleCondition::LikeNew => 'LIKE_NEW', + ArticleCondition::Good => 'GOOD', + ArticleCondition::Acceptable => 'ACCEPTABLE', + }; + } + + /** @return array> */ + private function buildAspects(Article $article): array + { + $aspects = []; + foreach ($article->getAttributeValues() as $value) { + $name = $value->getAttributeDefinition()->getName(); + $aspects[$name] = [$value->getValue()]; + } + + return $aspects; + } + + private function getCategoryId(): string + { + return '177'; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php b/src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php new file mode 100644 index 0000000..8226bed --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php @@ -0,0 +1,93 @@ + + */ + public function getOrder(string $orderId): array + { + $token = $this->oauthClient->getAccessToken(); + + $response = $this->httpClient->request( + 'GET', + $this->apiBaseUrl.'/sell/fulfillment/v1/order/'.urlencode($orderId), + ['headers' => ['Authorization' => 'Bearer '.$token]], + ); + + /** @var array $data */ + $data = $response->toArray(); + + $ship = $this->nestedArray($data, 'fulfillmentStartInstructions', 0, 'shippingStep', 'shipTo'); + $addr = $this->nestedArray($ship, 'contactAddress'); + $line = $this->nestedArray($data, 'lineItems', 0); + $buyer = $this->nestedArray($data, 'buyer'); + $buyerAddr = $this->nestedArray($buyer, 'buyerRegistrationAddress'); + $total = $this->nestedArray($data, 'pricingSummary', 'total'); + + return [ + 'orderId' => $this->str($data['orderId'] ?? $orderId), + 'buyerUsername' => $this->str($buyer['username'] ?? ''), + 'buyerName' => $this->str($ship['fullName'] ?? $buyerAddr['fullName'] ?? ''), + 'buyerEmail' => $this->str($ship['email'] ?? $buyerAddr['email'] ?? ($this->str($buyer['username'] ?? '').'@members.ebay.com')), + 'shippingStreet' => $this->str($addr['addressLine1'] ?? ''), + 'shippingCity' => $this->str($addr['city'] ?? ''), + 'shippingZip' => $this->str($addr['postalCode'] ?? ''), + 'shippingCountry' => $this->str($addr['countryCode'] ?? 'DE'), + 'ebayListingId' => $this->str($line['legacyItemId'] ?? ''), + 'salePrice' => $this->str($total['value'] ?? '0.00'), + 'saleDate' => $this->str($data['creationDate'] ?? (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)), + ]; + } + + /** + * Safely retrieves a nested value from a mixed array using a variadic key path. + * Each key can be a string or int. + * + * @param array $data + * + * @return array + */ + private function nestedArray(array $data, string|int ...$keys): array + { + $current = $data; + foreach ($keys as $key) { + if (!\array_key_exists($key, $current)) { + return []; + } + $val = $current[$key]; + if (!\is_array($val)) { + return []; + } + $current = $val; + } + + /** @var array $current */ + return $current; + } + + private function str(mixed $value): string + { + return \is_scalar($value) ? (string) $value : ''; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php b/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php new file mode 100644 index 0000000..e667bc8 --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php @@ -0,0 +1,125 @@ + $body + */ + public function upsertInventoryItem(string $sku, array $body): void + { + $this->request('PUT', self::INVENTORY_BASE.'/inventory_item/'.urlencode($sku), $body); + } + + /** + * Creates an offer (links inventory item to a listing). + * + * @param array $body + * + * @return string offerId + */ + public function createOffer(array $body): string + { + /** @var array{offerId: string} $response */ + $response = $this->request('POST', self::INVENTORY_BASE.'/offer', $body); + + return $response['offerId']; + } + + /** + * Updates an existing offer. + * + * @param array $body + */ + public function updateOffer(string $offerId, array $body): void + { + $this->request('PUT', self::INVENTORY_BASE.'/offer/'.urlencode($offerId), $body); + } + + /** @return string listingId */ + public function publishOffer(string $offerId): string + { + /** @var array{listingId: string} $response */ + $response = $this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/publish', []); + + return $response['listingId']; + } + + public function withdrawOffer(string $offerId): void + { + $this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/withdraw', []); + } + + /** @param array $quantityUpdate */ + public function bulkUpdateInventoryItems(array $quantityUpdate): void + { + $this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate); + } + + /** + * Adds tracking info to an order. + * + * @param array $body + */ + public function addTrackingToOrder(string $orderId, array $body): void + { + $this->request('POST', self::FULFILLMENT_BASE.'/order/'.urlencode($orderId).'/shipping_fulfillment', $body); + } + + /** + * @param array $body + * + * @return array + */ + private function request(string $method, string $path, array $body): array + { + $token = $this->oauthClient->getAccessToken(); + + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId, + ], + ]; + + if ([] !== $body) { + $options['json'] = $body; + } + + $response = $this->httpClient->request($method, $this->apiBaseUrl.$path, $options); + + $statusCode = $response->getStatusCode(); + if ($statusCode >= 400) { + $content = $response->getContent(false); + throw new \RuntimeException("eBay API error {$statusCode}: {$content}"); + } + + if (204 === $statusCode || '' === $response->getContent(false)) { + return []; + } + + /** @var array $data */ + $data = $response->toArray(); + + return $data; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayOAuthClient.php b/src/Infrastructure/Channel/Ebay/EbayOAuthClient.php new file mode 100644 index 0000000..c5b559f --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayOAuthClient.php @@ -0,0 +1,45 @@ +cache->get(self::TOKEN_CACHE_KEY, function (ItemInterface $item): string { + $credentials = base64_encode($this->clientId.':'.$this->clientSecret); + + $response = $this->httpClient->request('POST', $this->oauthBaseUrl.'/identity/v1/oauth2/token', [ + 'headers' => [ + 'Authorization' => 'Basic '.$credentials, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope', + ]); + + /** @var array{access_token: string, expires_in: int} $data */ + $data = $response->toArray(); + + $item->expiresAfter((int) ($data['expires_in'] * 0.8)); + + return $data['access_token']; + }); + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php b/src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php new file mode 100644 index 0000000..1640b18 --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php @@ -0,0 +1,73 @@ +}> + */ + public function getCategoryAspects(string $categoryId): array + { + $cacheKey = 'ebay_aspects_'.md5($this->marketplaceId.$categoryId); + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($categoryId): array { + $item->expiresAfter(86400 * 7); + + $token = $this->oauthClient->getAccessToken(); + + $response = $this->httpClient->request( + 'GET', + $this->apiBaseUrl.'/commerce/taxonomy/v1/category_tree/'.$this->getTreeId().'/get_item_aspects_for_category', + [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId, + ], + 'query' => ['category_id' => $categoryId], + ], + ); + + /** @var array{aspects?: list}>} $data */ + $data = $response->toArray(); + $aspects = []; + + foreach ($data['aspects'] ?? [] as $aspect) { + $aspects[] = [ + 'name' => $aspect['localizedAspectName'], + 'required' => $aspect['aspectConstraint']['aspectRequired'] ?? false, + 'values' => array_column($aspect['aspectValues'] ?? [], 'localizedValue'), + ]; + } + + return $aspects; + }); + } + + private function getTreeId(): string + { + return match ($this->marketplaceId) { + 'EBAY_DE' => '77', + 'EBAY_US' => '0', + 'EBAY_UK' => '3', + default => '77', + }; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php b/src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php new file mode 100644 index 0000000..c524b30 --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php @@ -0,0 +1,36 @@ +verificationToken.$this->endpointUrl, binary: true), + ); + + return hash_equals($expected, $signatureHeader); + } + + /** + * Returns the expected challenge response for endpoint registration. + * eBay sends GET ?challenge_code=XXX — we respond with SHA256(code + token + url). + */ + public function challengeResponse(string $challengeCode): string + { + return hash('sha256', $challengeCode.$this->verificationToken.$this->endpointUrl); + } +} diff --git a/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php b/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php new file mode 100644 index 0000000..f65b86a --- /dev/null +++ b/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php @@ -0,0 +1,82 @@ + $customer->getName(), + 'customer_type' => 'Individual', + 'customer_group' => 'Individual', + 'territory' => 'Germany', + 'custom_superseller_customer_id' => $customer->getId()->toRfc4122(), + ]; + + $ebayUserId = $customer->getPlatformId('ebay'); + if (null !== $ebayUserId) { + $data['custom_ebay_user_id'] = $ebayUserId; + } + + $response = $this->frappe->post('/api/resource/Customer', $data); + + /** @var array{data: array{name: string}} $response */ + return $response['data']['name']; + } + + public function createSalesInvoice(Order $order): string + { + $article = $order->getArticle(); + $customer = $order->getCustomer(); + + $draft = $this->frappe->post('/api/resource/Sales Invoice', [ + 'customer' => $customer->getFrappeCustomerId(), + 'posting_date' => $order->getSaleDate()->format('Y-m-d'), + 'due_date' => $order->getSaleDate()->format('Y-m-d'), + 'items' => [ + [ + 'item_code' => $this->genericItemCode, + 'item_name' => $article->getEbayTitle() ?? $article->getSku(), + 'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()), + 'qty' => 1, + 'rate' => $order->getSalePrice(), + ], + ], + 'custom_platform_order_id' => $order->getPlatformOrderId(), + 'custom_article_inventory_number' => $article->getInventoryNumber(), + ]); + + /** @var array{data: array{name: string}} $draft */ + $invoiceName = $draft['data']['name']; + + $this->frappe->post('/api/resource/Sales Invoice/'.$invoiceName.'/submit', []); + + return $invoiceName; + } + + public function fetchInvoicePdf(string $frappeInvoiceId): string + { + $path = http_build_query([ + 'doctype' => 'Sales Invoice', + 'name' => $frappeInvoiceId, + 'format' => 'Standard', + 'no_letterhead' => '0', + '_lang' => 'de', + ]); + + return $this->frappe->getContent('/api/method/frappe.utils.print_format.download_pdf?'.$path); + } +} diff --git a/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php b/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php new file mode 100644 index 0000000..01e0546 --- /dev/null +++ b/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php @@ -0,0 +1,54 @@ +authHeader = "token {$apiKey}:{$apiSecret}"; + } + + /** + * POST to a Frappe resource endpoint. + * + * @param array $data + * + * @return array + */ + public function post(string $path, array $data): array + { + $response = $this->httpClient->request('POST', $this->baseUrl.$path, [ + 'headers' => [ + 'Authorization' => $this->authHeader, + 'Content-Type' => 'application/json', + ], + 'json' => $data, + ]); + + /** @var array $result */ + $result = $response->toArray(); + + return $result; + } + + /** GET raw binary content (for PDF downloads). */ + public function getContent(string $path): string + { + $response = $this->httpClient->request('GET', $this->baseUrl.$path, [ + 'headers' => ['Authorization' => $this->authHeader], + ]); + + return $response->getContent(); + } +} diff --git a/src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php b/src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php new file mode 100644 index 0000000..e342b24 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php @@ -0,0 +1,100 @@ +query->getString('challenge_code'); + if ('' === $challengeCode) { + return $this->json(['error' => 'Missing challenge_code'], Response::HTTP_BAD_REQUEST); + } + + return $this->json(['challengeResponse' => $this->verifier->challengeResponse($challengeCode)]); + } + + #[Route('', name: '_notify', methods: ['POST'])] + public function notify(Request $request): Response + { + $signature = $request->headers->get('X-EBAY-SIGNATURE', ''); + $body = $request->getContent(); + + if (!$this->verifier->verify($body, $signature)) { + $this->logger->warning('eBay webhook: invalid signature', ['ip' => $request->getClientIp()]); + + return new Response('', Response::HTTP_UNAUTHORIZED); + } + + try { + /** @var array{notification?: array{topic?: string, data?: array}} $payload */ + $payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return new Response('', Response::HTTP_BAD_REQUEST); + } + + $topic = $payload['notification']['topic'] ?? ''; + $data = $payload['notification']['data'] ?? []; + + $this->logger->info('eBay webhook received', ['topic' => $topic]); + + match ($topic) { + 'FIXED_PRICE_TRANSACTION', + 'AUCTION_CHECKOUT_COMPLETE' => $this->handleOrderEvent($data), + 'MARKETPLACE_ACCOUNT_DELETION' => $this->handleAccountDeletion($data), + default => null, + }; + + return new Response('', Response::HTTP_OK); + } + + /** @param array $data */ + private function handleOrderEvent(array $data): void + { + $rawOrderId = $data['orderId'] ?? null; + if (null === $rawOrderId) { + /** @var array $orderData */ + $orderData = \is_array($data['order'] ?? null) ? $data['order'] : []; + $rawOrderId = $orderData['orderId'] ?? ''; + } + $orderId = \is_scalar($rawOrderId) ? (string) $rawOrderId : ''; + if ('' === $orderId) { + $this->logger->error('eBay webhook: order event with no orderId', ['data' => $data]); + + return; + } + + $this->bus->dispatch(new OrderReceivedMessage( + platformOrderId: $orderId, + platformType: 'ebay', + rawPayload: $data, + )); + } + + /** @param array $data */ + private function handleAccountDeletion(array $data): void + { + $this->logger->info('eBay MARKETPLACE_ACCOUNT_DELETION received', ['data' => $data]); + } +} diff --git a/src/Infrastructure/Mail/SymfonyInvoiceMailer.php b/src/Infrastructure/Mail/SymfonyInvoiceMailer.php new file mode 100644 index 0000000..2081b08 --- /dev/null +++ b/src/Infrastructure/Mail/SymfonyInvoiceMailer.php @@ -0,0 +1,55 @@ +getOrder(); + $article = $order->getArticle(); + $customer = $order->getCustomer(); + + $body = \sprintf( + "Neue Bestellung eingegangen — bitte sofort versenden.\n\n" + ."Bestellnummer : %s\n" + ."Artikel : %s\n" + ."Inventarnummer: %s\n" + ."Käufer : %s\n" + ."Verkaufspreis : €%s\n\n" + .'Die Rechnung liegt diesem E-Mail als PDF bei.', + $order->getPlatformOrderId(), + $article->getEbayTitle() ?? $article->getSku(), + $article->getInventoryNumber(), + $customer->getName(), + $order->getSalePrice(), + ); + + $email = (new Email()) + ->from($this->senderEmail) + ->to($this->supplierEmail) + ->subject('Neue Bestellung: '.$order->getPlatformOrderId()) + ->text($body) + ->attachFromPath( + $invoice->getFullPath(), + 'Rechnung-'.$invoice->getFrappeInvoiceId().'.pdf', + 'application/pdf', + ); + + $this->mailer->send($email); + } +} diff --git a/src/Infrastructure/Order/CustomerResolver.php b/src/Infrastructure/Order/CustomerResolver.php new file mode 100644 index 0000000..3ece85c --- /dev/null +++ b/src/Infrastructure/Order/CustomerResolver.php @@ -0,0 +1,51 @@ +customers->findByPlatformId($platform, $platformUserId); + if (null !== $customer) { + return $customer; + } + + $probe = new Customer($name, $email, $address); + $customer = $this->customers->findByMatchingKey($probe->getMatchingKey()); + if (null !== $customer) { + $customer->addPlatformId($platform, $platformUserId); + $this->customers->save($customer); + + return $customer; + } + + $customer = new Customer($name, $email, $address); + $customer->addPlatformId($platform, $platformUserId); + + $frappeId = $this->erp->createCustomer($customer); + $customer->setFrappeCustomerId($frappeId); + + $this->customers->save($customer); + + return $customer; + } +} diff --git a/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php b/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php new file mode 100644 index 0000000..01b60e9 --- /dev/null +++ b/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php @@ -0,0 +1,61 @@ +apiClient = $this->createMock(EbayInventoryApiClient::class); + $this->adapter = new EbayAdapter($this->apiClient); + $this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $this->article->setEbayTitle('Dell Latitude 5520'); + $this->article->setListingPrice('299.00'); + } + + public function test_get_type_returns_ebay(): void + { + $this->assertSame('ebay', $this->adapter->getType()); + } + + public function test_publish_listing_calls_upsert_create_and_publish(): void + { + $this->apiClient->expects($this->once())->method('upsertInventoryItem'); + $this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123'); + $this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456'); + + $listingId = $this->adapter->publishListing($this->article); + + $this->assertSame('listing-456', $listingId); + } + + public function test_deactivate_listing_withdraws_offer(): void + { + $this->article->setEbayListingId('offer-123'); + + $this->apiClient->expects($this->once())->method('withdrawOffer')->with('offer-123'); + + $this->adapter->deactivateListing($this->article); + } + + public function test_deactivate_listing_is_noop_when_no_listing_id(): void + { + $this->apiClient->expects($this->never())->method('withdrawOffer'); + + $this->adapter->deactivateListing($this->article); + } +} diff --git a/tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php b/tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php new file mode 100644 index 0000000..1c716e9 --- /dev/null +++ b/tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php @@ -0,0 +1,42 @@ +verifier = new EbayWebhookVerifier( + verificationToken: 'my-secret-token', + endpointUrl: 'https://example.com/webhooks/ebay', + ); + } + + public function test_valid_signature_passes(): void + { + $body = '{"notification":{"data":{"orderId":"123"}}}'; + $expected = base64_encode(hash('sha256', $body.'my-secret-tokenhttps://example.com/webhooks/ebay', binary: true)); + + $this->assertTrue($this->verifier->verify($body, $expected)); + } + + public function test_invalid_signature_fails(): void + { + $this->assertFalse($this->verifier->verify('{"body":"x"}', 'invalidsignature')); + } + + public function test_challenge_response_returns_correct_hash(): void + { + $challengeCode = 'abc123'; + $expected = hash('sha256', $challengeCode.'my-secret-tokenhttps://example.com/webhooks/ebay'); + + $this->assertSame($expected, $this->verifier->challengeResponse($challengeCode)); + } +} diff --git a/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php b/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php new file mode 100644 index 0000000..bef8d86 --- /dev/null +++ b/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php @@ -0,0 +1,82 @@ +frappe = $this->createMock(FrappeHttpClient::class); + $this->adapter = new FrappeErpAdapter($this->frappe, 'REFURB-HW'); + } + + public function test_create_customer_returns_frappe_id(): void + { + $this->frappe + ->method('post') + ->with('/api/resource/Customer', $this->isType('array')) + ->willReturn(['data' => ['name' => 'CUST-00001']]); + + $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); + $customer->addPlatformId('ebay', 'buyer123'); + + $result = $this->adapter->createCustomer($customer); + + $this->assertSame('CUST-00001', $result); + } + + public function test_create_sales_invoice_submits_and_returns_id(): void + { + $this->frappe + ->expects($this->exactly(2)) + ->method('post') + ->willReturnOnConsecutiveCalls( + ['data' => ['name' => 'SINV-00001']], + ['data' => ['name' => 'SINV-00001', 'docstatus' => 1]], + ); + + $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); + $customer->setFrappeCustomerId('CUST-00001'); + + $order = new Order( + new Article(new ArticleType('Laptop'), 'LAP-001', 'INV-001', 1, ArticleCondition::Good), + $customer, + new Platform('ebay', 'eBay DE'), + 'ORDER-001', + '299.99', + new \DateTimeImmutable('2026-05-13'), + ); + + $result = $this->adapter->createSalesInvoice($order); + + $this->assertSame('SINV-00001', $result); + } + + public function test_fetch_invoice_pdf_returns_binary(): void + { + $this->frappe + ->method('getContent') + ->with($this->stringContains('SINV-00001')) + ->willReturn('%PDF-binary-content'); + + $result = $this->adapter->fetchInvoicePdf('SINV-00001'); + + $this->assertStringStartsWith('%PDF', $result); + } +} diff --git a/tests/Unit/Infrastructure/Order/CustomerResolverTest.php b/tests/Unit/Infrastructure/Order/CustomerResolverTest.php new file mode 100644 index 0000000..2fff7bf --- /dev/null +++ b/tests/Unit/Infrastructure/Order/CustomerResolverTest.php @@ -0,0 +1,89 @@ +customerRepo = $this->createMock(CustomerRepositoryInterface::class); + $this->erp = $this->createMock(ErpAdapterInterface::class); + $this->resolver = new CustomerResolver($this->customerRepo, $this->erp); + } + + public function test_stage_1_platform_id_match_returns_existing_customer_without_erp_call(): void + { + $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); + $existing->addPlatformId('ebay', 'buyer123'); + + $this->customerRepo + ->method('findByPlatformId') + ->with('ebay', 'buyer123') + ->willReturn($existing); + + $this->erp->expects($this->never())->method('createCustomer'); + $this->customerRepo->expects($this->never())->method('save'); + + $result = $this->resolver->resolve( + 'ebay', 'buyer123', + 'Max Mustermann', 'max@test.de', + ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], + ); + + $this->assertSame($existing, $result); + } + + public function test_stage_2_address_match_adds_platform_id_and_saves(): void + { + $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); + + $this->customerRepo->method('findByPlatformId')->willReturn(null); + $this->customerRepo + ->method('findByMatchingKey') + ->with($existing->getMatchingKey()) + ->willReturn($existing); + $this->customerRepo->expects($this->once())->method('save')->with($existing); + + $this->erp->expects($this->never())->method('createCustomer'); + + $result = $this->resolver->resolve( + 'ebay', 'buyer456', + 'Max Mustermann', 'max@test.de', + ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], + ); + + $this->assertSame($existing, $result); + $this->assertSame('buyer456', $result->getPlatformId('ebay')); + } + + public function test_no_match_creates_new_customer_via_erp(): void + { + $this->customerRepo->method('findByPlatformId')->willReturn(null); + $this->customerRepo->method('findByMatchingKey')->willReturn(null); + $this->customerRepo->expects($this->once())->method('save'); + $this->erp->method('createCustomer')->willReturn('CUST-99999'); + + $result = $this->resolver->resolve( + 'ebay', 'newbuyer', + 'Neue Käuferin', 'neu@test.de', + ['street' => 'Neustr 5', 'city' => 'München', 'zip' => '80333'], + ); + + $this->assertSame('CUST-99999', $result->getFrappeCustomerId()); + $this->assertSame('newbuyer', $result->getPlatformId('ebay')); + $this->assertSame('Neue Käuferin', $result->getName()); + } +}