diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 8c633499..88513a12 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -40,7 +40,15 @@ 'php_unit_dedicate_assert' => ['target' => 'newest'], 'php_unit_dedicate_assert_internal_type' => true, 'php_unit_mock' => true, - 'php_unit_test_case_static_method_calls' => ['call_type' => 'static'], + 'php_unit_test_case_static_method_calls' => ['methods' => [ + 'any' => 'this', + 'never' => 'this', + 'atLeast' => 'this', + 'atLeastOnce' => 'this', + 'once' => 'this', + 'exactly' => 'this', + 'atMost' => 'this', + ]], 'self_accessor' => false, 'single_line_throw' => false, 'single_quote' => ['strings_containing_single_quote_chars' => true], diff --git a/composer.json b/composer.json index d471bd18..2046b82b 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "swag/swag-extension-store", - "version": "3.5.0", + "version": "6.0.0", "description": "SWAG Extension Store", "type": "shopware-platform-plugin", "license": "MIT", diff --git a/src/Controller/InAppPurchasesController.php b/src/Controller/InAppPurchasesController.php index 57ff91b0..5b3fbf61 100644 --- a/src/Controller/InAppPurchasesController.php +++ b/src/Controller/InAppPurchasesController.php @@ -79,7 +79,7 @@ public function orderCart(RequestDataBag $data, Context $context): Response \assert($positions instanceof RequestDataBag); $extensionName = $data->get('name'); - $positionCollection = InAppPurchaseCartPositionCollection::fromArray($positions->all()); + $positionCollection = InAppPurchaseCartPositionCollection::fromArray(array_values($positions->all())); $app = $this->getAppByName($extensionName, $context); if (!$app) { diff --git a/src/Exception/ExtensionStoreApiException.php b/src/Exception/ExtensionStoreApiException.php index 76055c0b..ce7bdd34 100644 --- a/src/Exception/ExtensionStoreApiException.php +++ b/src/Exception/ExtensionStoreApiException.php @@ -15,19 +15,16 @@ class ExtensionStoreApiException extends StoreApiException public function __construct(ClientException $exception) { - $data = json_decode($exception->getResponse()->getBody()->getContents(), true); - parent::__construct($exception); + $data = $this->getData($exception); $this->apiCode = $data['code'] ?? ''; $this->description = $data['description'] ?? ''; } public function getErrors(bool $withTrace = false): \Generator { - $errors = parent::getErrors($withTrace); - - foreach ($errors as $error) { + foreach (parent::getErrors($withTrace) as $error) { yield [ ...$error, 'apiCode' => $this->apiCode, @@ -35,4 +32,27 @@ public function getErrors(bool $withTrace = false): \Generator ]; } } + + /** + * @return array + */ + private function getData(ClientException $exception): array + { + $body = $exception->getResponse()->getBody(); + + // The parent constructor already read the body to its end and left the stream at EOF, + // so reading it again without rewinding yields an empty string. + if ($body->isSeekable()) { + $body->rewind(); + } + + try { + $data = json_decode($body->getContents(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + // A body may be valid JSON without being an object, e.g. a bare string or number. + return \is_array($data) ? $data : []; + } } diff --git a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.scss b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.scss index 48ccbca0..dac86d6d 100644 --- a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.scss +++ b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.scss @@ -3,6 +3,7 @@ .sw-dashboard-statistics-promotion-banner { display: block; container-type: inline-size; + container-name: sw-dashboard-statistics-promotion-banner; margin: 32px auto; border-radius: $border-radius-lg; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%), 0 2px 1px 0 rgba(0, 0, 0, 6%), 0 1px 1px 0 rgba(0, 0, 0, 8%); @@ -101,7 +102,7 @@ } } - @container (max-width: 750px) { + @container sw-dashboard-statistics-promotion-banner (max-width: 750px) { &-details { display: block; } @@ -152,10 +153,7 @@ margin-top: 14px; } - @container (max-width: 525px) { - &-app { - } - + @container sw-dashboard-statistics-promotion-banner (max-width: 525px) { &-go-to-app { float: left; } diff --git a/src/Resources/app/administration/src/module/sw-dashboard/page/sw-dashboard-index/sw-dashboard-index.html.twig b/src/Resources/app/administration/src/module/sw-dashboard/page/sw-dashboard-index/sw-dashboard-index.html.twig index d0d499c1..d8341893 100644 --- a/src/Resources/app/administration/src/module/sw-dashboard/page/sw-dashboard-index/sw-dashboard-index.html.twig +++ b/src/Resources/app/administration/src/module/sw-dashboard/page/sw-dashboard-index/sw-dashboard-index.html.twig @@ -1,4 +1,4 @@ {% block sw_dashboard_index_content_intro_card %} - {% parent %} + {% parent %} {% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/snippet/de-DE.json b/src/Resources/app/administration/src/module/sw-extension-store/snippet/de-DE.json index 506e9c41..57f929ac 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/snippet/de-DE.json +++ b/src/Resources/app/administration/src/module/sw-extension-store/snippet/de-DE.json @@ -1,5 +1,11 @@ { "sw-extension-store": { + "component": { + "sw-extension-permissions-details-modal": { + "operationUpdate": "Bearbeiten", + "operationDelete": "Löschen" + } + }, "general": { "title": "Store", "taxNotice": "* Alle Preise verstehen sich zzgl. Mehrwertsteuer" diff --git a/src/Resources/app/administration/src/module/sw-extension-store/snippet/en-GB.json b/src/Resources/app/administration/src/module/sw-extension-store/snippet/en-GB.json index f301123a..73651a0e 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/snippet/en-GB.json +++ b/src/Resources/app/administration/src/module/sw-extension-store/snippet/en-GB.json @@ -1,5 +1,11 @@ { "sw-extension-store": { + "component": { + "sw-extension-permissions-details-modal": { + "operationUpdate": "Edit", + "operationDelete": "Delete" + } + }, "general": { "title": "Store", "taxNotice": "* All prices are exclusive of VAT" diff --git a/src/Resources/app/administration/static/img/analytics/promotion/graphic.svg b/src/Resources/app/administration/static/img/analytics/promotion/graphic.svg index 02844e9e..70644014 100644 --- a/src/Resources/app/administration/static/img/analytics/promotion/graphic.svg +++ b/src/Resources/app/administration/static/img/analytics/promotion/graphic.svg @@ -1,64 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/Resources/app/administration/static/img/payments/dashboard/shopware-payments-dashboard-marketing.png b/src/Resources/app/administration/static/img/payments/dashboard/shopware-payments-dashboard-marketing.png new file mode 100644 index 00000000..8ba9de8f Binary files /dev/null and b/src/Resources/app/administration/static/img/payments/dashboard/shopware-payments-dashboard-marketing.png differ diff --git a/src/Services/InAppPurchasesService.php b/src/Services/InAppPurchasesService.php index 6cd0948b..634df301 100644 --- a/src/Services/InAppPurchasesService.php +++ b/src/Services/InAppPurchasesService.php @@ -41,7 +41,7 @@ public function listPurchases(string $extensionName, Context $context): InAppPur { $purchases = $this->client->listInAppPurchases($extensionName, $context); - return $purchases->filter(fn (InAppPurchaseStruct $purchase) => $purchase->getStatus() === InAppPurchaseStatus::ACTIVE); + return $purchases->filter(static fn (InAppPurchaseStruct $purchase) => $purchase->getStatus() === InAppPurchaseStatus::ACTIVE); } public function getInAppPurchase(string $extensionName, string $inAppPurchase, Context $context): InAppPurchaseStruct diff --git a/src/Services/StoreClient.php b/src/Services/StoreClient.php index 6e250c5a..7f09e85c 100644 --- a/src/Services/StoreClient.php +++ b/src/Services/StoreClient.php @@ -122,7 +122,7 @@ public function createInAppPurchaseCart(string $extensionName, string $feature, } $inAppPurchaseCart = InAppPurchaseCartStruct::fromArray(json_decode((string) $response->getBody(), true)); - $inAppPurchaseCart->getPositions()->map(function (InAppPurchaseCartPositionStruct $position) use ($extensionName): void { + $inAppPurchaseCart->getPositions()->map(static function (InAppPurchaseCartPositionStruct $position) use ($extensionName): void { $position->setExtensionName($position->getExtensionName() ?: $extensionName); }); diff --git a/src/Struct/InAppPurchaseCartPositionCollection.php b/src/Struct/InAppPurchaseCartPositionCollection.php index b398507d..dde77af3 100644 --- a/src/Struct/InAppPurchaseCartPositionCollection.php +++ b/src/Struct/InAppPurchaseCartPositionCollection.php @@ -19,7 +19,7 @@ class InAppPurchaseCartPositionCollection extends Collection { /** - * @param InAppPurchaseCartPosition[] $data + * @param list $data */ public static function fromArray(array $data): self { @@ -53,7 +53,7 @@ public function filterValidInAppPurchases( InAppPurchaseCartPositionCollection $allPurchases, array $validPurchases, ): self { - return $allPurchases->filter(function (InAppPurchaseCartPositionStruct $purchase) use ($validPurchases) { + return $allPurchases->filter(static function (InAppPurchaseCartPositionStruct $purchase) use ($validPurchases) { return \in_array($purchase->getInAppFeatureIdentifier(), $validPurchases, true); }); } diff --git a/src/Struct/InAppPurchaseCartPositionStruct.php b/src/Struct/InAppPurchaseCartPositionStruct.php index 9bf5ac24..bec02b4d 100644 --- a/src/Struct/InAppPurchaseCartPositionStruct.php +++ b/src/Struct/InAppPurchaseCartPositionStruct.php @@ -8,14 +8,49 @@ use Shopware\Core\Framework\Struct\Struct; /** - * @codeCoverageIgnore - * * @phpstan-import-type InAppPurchase from InAppPurchaseStruct * @phpstan-import-type InAppPurchasePriceModel from InAppPurchasePriceModelStruct * @phpstan-import-type InAppPurchaseSubscriptionChange from InAppPurchaseSubscriptionChangeStruct * - * @phpstan-type InAppPurchaseCartPosition array{subscriptionChange?: InAppPurchaseSubscriptionChange, extensionName: string, inAppFeatureIdentifier: string, netPrice: float, grossPrice: float, taxRate: float, taxValue: float} - * @phpstan-type InAppPurchaseCartItem array{extensionName: string, inAppFeatureIdentifier: string, netPrice: float, taxValue: float, grossPrice: float, taxRate: float, variant: string, subscriptionChange?: array{type: string, currentInAppFeatureIdentifier: string}} + * @phpstan-type InAppPurchaseCartPosition array{ + * subscriptionChange?: InAppPurchaseSubscriptionChange, + * extensionName: string, + * inAppFeatureIdentifier: string, + * priceModel?: array{ + * type: string, + * price: float, + * duration: int|null, + * oneTimeOnly: bool, + * }, + * feature?: array{ + * name: string, + * identifier: string, + * description: string|null, + * priceModels: array{ + * type: string, + * price: float, + * duration: int|null, + * oneTimeOnly: bool, + * }, + * }, + * netPrice: float, + * grossPrice: float, + * taxRate: float, + * taxValue: float, + * } + * @phpstan-type InAppPurchaseCartItem array{ + * extensionName: string, + * inAppFeatureIdentifier: string, + * netPrice: float, + * taxValue: float, + * grossPrice: float, + * taxRate: float, + * variant: string, + * subscriptionChange?: array{ + * type: string, + * currentInAppFeatureIdentifier: string + * } + * } */ #[Package('checkout')] class InAppPurchaseCartPositionStruct extends Struct diff --git a/src/Struct/InAppPurchaseCartStruct.php b/src/Struct/InAppPurchaseCartStruct.php index 8b06b679..46ba01f0 100644 --- a/src/Struct/InAppPurchaseCartStruct.php +++ b/src/Struct/InAppPurchaseCartStruct.php @@ -13,7 +13,15 @@ * @phpstan-import-type InAppPurchaseCartPosition from InAppPurchaseCartPositionStruct * * @phpstan-type Shop array{id: int, domain: string}|array{} - * @phpstan-type InAppPurchaseCart array{positions: InAppPurchaseCartPosition[], bookingShop: Shop, licenseShop: Shop, netPrice: float, grossPrice: float, taxRate: float, taxValue: float} + * @phpstan-type InAppPurchaseCart array{ + * positions: list, + * bookingShop?: Shop, + * licenseShop?: Shop, + * netPrice?: float, + * grossPrice?: float, + * taxRate?: float, + * taxValue?: float + * } */ #[Package('checkout')] class InAppPurchaseCartStruct extends Struct diff --git a/src/Struct/InAppPurchaseCollection.php b/src/Struct/InAppPurchaseCollection.php index eea2385b..e24c1a83 100644 --- a/src/Struct/InAppPurchaseCollection.php +++ b/src/Struct/InAppPurchaseCollection.php @@ -18,7 +18,7 @@ class InAppPurchaseCollection extends Collection { /** - * @param InAppPurchase[] $data + * @param array $data */ public static function fromArray(array $data): self { @@ -42,7 +42,7 @@ public function filterValidInAppPurchases( InAppPurchaseCollection $allPurchases, array $validPurchases, ): InAppPurchaseCollection { - return $allPurchases->filter(function (InAppPurchaseStruct $purchase) use ($validPurchases) { + return $allPurchases->filter(static function (InAppPurchaseStruct $purchase) use ($validPurchases) { return \in_array($purchase->getIdentifier(), $validPurchases, true); }); } diff --git a/src/Struct/InAppPurchasePriceModelStruct.php b/src/Struct/InAppPurchasePriceModelStruct.php index b916baa9..8891191a 100644 --- a/src/Struct/InAppPurchasePriceModelStruct.php +++ b/src/Struct/InAppPurchasePriceModelStruct.php @@ -10,7 +10,14 @@ /** * @codeCoverageIgnore * - * @phpstan-type InAppPurchasePriceModel array{type: string, price: float, duration: int|null, oneTimeOnly: bool|null, conditionsType: string|null} + * @phpstan-type InAppPurchasePriceModel array{ + * variant?: string, + * type?: string, + * price?: float, + * duration?: int|null, + * oneTimeOnly?: bool|null, + * conditionsType?: string|null + * } */ #[Package('checkout')] class InAppPurchasePriceModelStruct extends Struct diff --git a/src/Struct/InAppPurchaseStruct.php b/src/Struct/InAppPurchaseStruct.php index 9632c717..0afe78f0 100644 --- a/src/Struct/InAppPurchaseStruct.php +++ b/src/Struct/InAppPurchaseStruct.php @@ -8,11 +8,20 @@ use Shopware\Core\Framework\Struct\Struct; /** - * @codeCoverageIgnore - * * @phpstan-import-type InAppPurchasePriceModel from InAppPurchasePriceModelStruct * - * @phpstan-type InAppPurchase array{identifier: string, name: string, description: string|null, priceModels: InAppPurchasePriceModel[]} + * @phpstan-type InAppPurchase array{ + * id?: int, + * extensionName?: string, + * identifier: string, + * name: string, + * description: string|null, + * priceModels: list, + * serviceConditions?: string|null, + * websiteGtc?: string|null, + * preselectedVariant?: string|null, + * status?: string, + * } */ #[Package('checkout')] class InAppPurchaseStruct extends Struct diff --git a/src/Struct/InAppPurchaseSubscriptionChangeStruct.php b/src/Struct/InAppPurchaseSubscriptionChangeStruct.php index ab1534e1..2ef5e9fa 100644 --- a/src/Struct/InAppPurchaseSubscriptionChangeStruct.php +++ b/src/Struct/InAppPurchaseSubscriptionChangeStruct.php @@ -6,8 +6,6 @@ use Shopware\Core\Framework\Struct\Struct; /** - * @codeCoverageIgnore - * * @phpstan-import-type InAppPurchase from InAppPurchaseStruct * @phpstan-import-type InAppPurchasePendingDowngrade from InAppPurchasePendingDowngradeStruct * diff --git a/tests/Controller/InAppPurchasesControllerTest.php b/tests/Controller/InAppPurchasesControllerTest.php index 8354e9e6..a2f8a716 100644 --- a/tests/Controller/InAppPurchasesControllerTest.php +++ b/tests/Controller/InAppPurchasesControllerTest.php @@ -37,7 +37,7 @@ public function testGetInAppFeature(): void $otherExtension->setName('otherExtension'); $service = $this->createMock(InAppPurchasesService::class); $dataProvider = $this->createMock(AbstractExtensionDataProvider::class); - $dataProvider->expects(static::once()) + $dataProvider->expects($this->once()) ->method('getInstalledExtensions') ->willReturn(new ExtensionCollection([ 'otherExtension' => $otherExtension, @@ -65,7 +65,7 @@ public function testGetInAppFeatureWithUnknownExtension(): void $extension->setName('testExtension'); $service = $this->createMock(InAppPurchasesService::class); $dataProvider = $this->createMock(AbstractExtensionDataProvider::class); - $dataProvider->expects(static::once()) + $dataProvider->expects($this->once()) ->method('getInstalledExtensions') ->willReturn(new ExtensionCollection(['testExtension' => $extension])); @@ -87,7 +87,7 @@ public function testCreateCart(): void { $cartStruct = $this->getInAppPurchaseCartStruct(); $service = $this->createMock(InAppPurchasesService::class); - $service->expects(static::once()) + $service->expects($this->once()) ->method('createCart') ->willReturn($cartStruct); @@ -123,7 +123,7 @@ public function testCreateCartWithEmptyVariant(): void { $cartStruct = $this->getInAppPurchaseCartStruct(); $service = $this->createMock(InAppPurchasesService::class); - $service->expects(static::once()) + $service->expects($this->once()) ->method('createCart') ->willReturn($cartStruct); @@ -158,7 +158,7 @@ public function testCreateCartWithEmptyVariant(): void public function testOrderCartWithValidItem(): void { $service = $this->createMock(InAppPurchasesService::class); - $service->expects(static::once()) + $service->expects($this->once()) ->method('orderCart') ->willReturn(new JsonResponse(null, Response::HTTP_CREATED)); @@ -166,12 +166,12 @@ public function testOrderCartWithValidItem(): void $response->purchases = ['some-app-and-feature-name']; $gateway = $this->createMock(InAppPurchasesGateway::class); - $gateway->expects(static::once()) + $gateway->expects($this->once()) ->method('process') ->willReturn($response); $appRepository = $this->createMock(EntityRepository::class); - $appRepository->expects(static::once()) + $appRepository->expects($this->once()) ->method('search') ->with(static::isInstanceOf(Criteria::class)) ->willReturn($this->getSearchResult()); @@ -210,18 +210,18 @@ public function testOrderCartWithInvalidItem(): void $this->expectExceptionMessage('The extension provider disallowed your purchase. Please contact the extension provider.'); $service = $this->createMock(InAppPurchasesService::class); - $service->expects(static::never()) + $service->expects($this->never()) ->method('orderCart'); $response = new InAppPurchasesResponse(); $gateway = $this->createMock(InAppPurchasesGateway::class); - $gateway->expects(static::once()) + $gateway->expects($this->once()) ->method('process') ->willReturn($response); $appRepository = $this->createMock(EntityRepository::class); - $appRepository->expects(static::once()) + $appRepository->expects($this->once()) ->method('search') ->with(static::isInstanceOf(Criteria::class)) ->willReturn($this->getSearchResult()); @@ -253,7 +253,7 @@ public function testListPurchasesWithValidItems(): void { $context = Context::createDefaultContext(); $service = $this->createMock(InAppPurchasesService::class); - $service->expects(static::once()) + $service->expects($this->once()) ->method('listPurchases') ->with('TestApp', $context) ->willReturn($this->getInAppPurchaseCollection()); @@ -262,12 +262,12 @@ public function testListPurchasesWithValidItems(): void $response->purchases = ['testFeature2', 'testFeature', 'testFeature3']; $gateway = $this->createMock(InAppPurchasesGateway::class); - $gateway->expects(static::once()) + $gateway->expects($this->once()) ->method('process') ->willReturn($response); $appRepository = $this->createMock(EntityRepository::class); - $appRepository->expects(static::once()) + $appRepository->expects($this->once()) ->method('search') ->with(static::isInstanceOf(Criteria::class)) ->willReturn($this->getSearchResult()); @@ -290,7 +290,7 @@ public function testListPurchasesWithInvalidItems(): void { $context = Context::createDefaultContext(); $service = $this->createMock(InAppPurchasesService::class); - $service->expects(static::once()) + $service->expects($this->once()) ->method('listPurchases') ->with('TestApp', $context) ->willReturn($this->getInAppPurchaseCollection()); @@ -299,12 +299,12 @@ public function testListPurchasesWithInvalidItems(): void $response->purchases = ['testFeature2', 'testFeature']; $gateway = $this->createMock(InAppPurchasesGateway::class); - $gateway->expects(static::once()) + $gateway->expects($this->once()) ->method('process') ->willReturn($response); $appRepository = $this->createMock(EntityRepository::class); - $appRepository->expects(static::once()) + $appRepository->expects($this->once()) ->method('search') ->with(static::isInstanceOf(Criteria::class)) ->willReturn($this->getSearchResult()); @@ -328,7 +328,7 @@ public function testRefreshInAppPurchases(): void $context = Context::createDefaultContext(); $inAppPurchaseSyncService = $this->createMock(InAppPurchaseUpdater::class); - $inAppPurchaseSyncService->expects(static::once()) + $inAppPurchaseSyncService->expects($this->once()) ->method('update') ->with($context); @@ -398,7 +398,7 @@ private function validateResponse(Response $response, int $statusCode = Response private function getInAppPurchaseCollection(bool $valid = true): InAppPurchaseCollection { - return InAppPurchaseCollection::fromArray(array_filter([ + return InAppPurchaseCollection::fromArray(array_values(array_filter([ [ 'extensionName' => 'testExtension', 'identifier' => 'testFeature', @@ -441,7 +441,7 @@ private function getInAppPurchaseCollection(bool $valid = true): InAppPurchaseCo 'conditionsType' => null, ]], ], - ])); + ]))); } /** @@ -454,7 +454,7 @@ private function getSearchResult(): EntitySearchResult $app->setUniqueIdentifier(Uuid::randomHex()); return new EntitySearchResult( - 'aoo', + 'app', 1, new EntityCollection([$app]), null, diff --git a/tests/Controller/LicenseControllerTest.php b/tests/Controller/LicenseControllerTest.php index 2ff899eb..efa1fb35 100644 --- a/tests/Controller/LicenseControllerTest.php +++ b/tests/Controller/LicenseControllerTest.php @@ -57,7 +57,7 @@ public function testPurchaseExtension(): void public function testAvailablePaymentMeans(): void { $service = $this->createMock(LicenseService::class); - $service->expects(static::once()) + $service->expects($this->once()) ->method('availablePaymentMeans') ->willReturn(['payment-mean-1', 'payment-mean-2']); @@ -74,7 +74,7 @@ public function testOrderCart(): void ]); $service = $this->createMock(LicenseService::class); - $service->expects(static::once()) + $service->expects($this->once()) ->method('orderCart'); $response = (new LicenseController($service))->orderCart($requestDataBag, Context::createDefaultContext()); diff --git a/tests/Exception/ExtensionStoreApiExceptionTest.php b/tests/Exception/ExtensionStoreApiExceptionTest.php new file mode 100644 index 00000000..934de52b --- /dev/null +++ b/tests/Exception/ExtensionStoreApiExceptionTest.php @@ -0,0 +1,111 @@ +createClientException((string) json_encode([ + 'code' => 'STORE__EXTENSION_NOT_PURCHASABLE', + 'title' => 'Extension cannot be purchased', + 'description' => 'The extension is not available for your shop.', + 'documentationLink' => 'https://docs.shopware.com/store-error', + ], \JSON_THROW_ON_ERROR))); + + $error = $this->firstError($exception); + + static::assertSame('STORE__EXTENSION_NOT_PURCHASABLE', $error['apiCode']); + static::assertSame('Extension cannot be purchased', $error['title']); + static::assertSame('The extension is not available for your shop.', $error['description']); + static::assertSame('The extension is not available for your shop.', $error['detail']); + static::assertIsArray($error['meta']); + static::assertSame('https://docs.shopware.com/store-error', $error['meta']['documentationLink']); + } + + #[DataProvider('unusableBodyProvider')] + public function testErrorFallsBackToEmptyValuesOnAnUnusableBody(string $body): void + { + $exception = new ExtensionStoreApiException($this->createClientException($body)); + + $error = $this->firstError($exception); + + static::assertSame('', $error['apiCode']); + static::assertSame('', $error['title']); + static::assertSame('', $error['description']); + static::assertSame('Store is not reachable', $error['detail']); + static::assertIsArray($error['meta']); + static::assertSame('', $error['meta']['documentationLink']); + } + + /** + * @return \Generator + */ + public static function unusableBodyProvider(): \Generator + { + yield 'gateway returns an html error page instead of json' => ['gateway timeout']; + yield 'response has no body at all' => ['']; + yield 'body is valid json but decodes to a string' => ['"just a string"']; + yield 'body is valid json but decodes to a number' => ['42']; + yield 'body is valid json but decodes to null' => ['null']; + yield 'body is a json list instead of an error object' => ['[1, 2, 3]']; + } + + public function testErrorKeepsTheFieldsTheParentParsedWhenTheBodyCannotBeReadTwice(): void + { + $body = new NoSeekStream(Utils::streamFor((string) json_encode([ + 'code' => 'STORE__EXTENSION_NOT_PURCHASABLE', + 'title' => 'Extension cannot be purchased', + 'description' => 'The extension is not available for your shop.', + 'documentationLink' => 'https://docs.shopware.com/store-error', + ], \JSON_THROW_ON_ERROR))); + + $exception = new ExtensionStoreApiException(new ClientException( + 'Store is not reachable', + new Request('GET', '/swplatform/extensionstore/baskets'), + new Response(400, [], $body), + )); + + $error = $this->firstError($exception); + + static::assertSame('Extension cannot be purchased', $error['title']); + static::assertIsArray($error['meta']); + static::assertSame('https://docs.shopware.com/store-error', $error['meta']['documentationLink']); + static::assertSame('The extension is not available for your shop.', $error['detail']); + static::assertSame('', $error['apiCode']); + static::assertSame('', $error['description']); + } + + /** + * The parent declares a sealed error shape, so the extra fields this exception adds are invisible + * to static analysis when read straight off the generator. + * + * @return array + */ + private function firstError(ExtensionStoreApiException $exception): array + { + return $exception->getErrors()->current(); + } + + private function createClientException(string $body): ClientException + { + return new ClientException( + 'Store is not reachable', + new Request('GET', '/swplatform/extensionstore/baskets'), + new Response(400, [], $body), + ); + } +} diff --git a/tests/Struct/InAppPurchaseStructTest.php b/tests/Struct/InAppPurchaseStructTest.php new file mode 100644 index 00000000..d61282c6 --- /dev/null +++ b/tests/Struct/InAppPurchaseStructTest.php @@ -0,0 +1,59 @@ + 'feature_1', + 'name' => 'Feature 1', + 'description' => 'Description 1', + 'serviceConditions' => 'Conditions', + 'websiteGtc' => 'https://example.com/gtc', + 'preselectedVariant' => 'monthly', + 'priceModels' => [[ + 'variant' => 'monthly', + 'type' => 'rent', + 'price' => 10.0, + 'duration' => 12, + 'oneTimeOnly' => false, + 'conditionsType' => null, + ]], + 'status' => 'active', + ]); + + static::assertSame('feature_1', $purchase->getIdentifier()); + static::assertSame(InAppPurchaseStatus::ACTIVE, $purchase->getStatus()); + static::assertCount(1, $purchase->getPriceModels()); + static::assertSame('monthly', $purchase->getPriceModels()->first()?->getVariant()); + } + + public function testFromArrayDefaultsStatusToInactive(): void + { + $purchase = InAppPurchaseStruct::fromArray([ + 'identifier' => 'feature_2', + 'name' => 'Feature 2', + 'description' => null, + 'priceModels' => [[ + 'variant' => 'yearly', + 'type' => 'rent', + 'price' => 59.5, + 'duration' => 12, + 'oneTimeOnly' => false, + 'conditionsType' => null, + ]], + ]); + + static::assertSame(InAppPurchaseStatus::INACTIVE, $purchase->getStatus()); + } +}