diff --git a/_api_app/phpunit.xml b/_api_app/phpunit.xml index 506b9a38e..a7900cf71 100644 --- a/_api_app/phpunit.xml +++ b/_api_app/phpunit.xml @@ -11,11 +11,17 @@ tests/Feature + + app/Plugins/*/tests + app + + app/Plugins/*/tests + diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" deleted file mode 100644 index 231ad6a23..000000000 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" +++ /dev/null @@ -1,239 +0,0 @@ -
-

Cart

- -
-
Shopping cart is empty
-
- -
-
Shopping cart is expired
-
- - - -
-
-
 Title 
-
 Quantity 
-
 Price 
-
 Sum  (EUR)
-
- -
    - - - -
    -
    - -
    - -
     OK 
    -
    -
    - -
    - - -
    -
    - -
    -
    -
    Shipping
    -
    EUR
    -
    -
    -
     Discount 
    -
    -
    -
    -
     Total 
    -
    EUR
    -
    -
    -
     Incl vat  (%)
    -
    EUR
    -
    -
    -
    - -
    -
    -

    Billing address

    - - - -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    - - -
    - -
    -

     Shipping address 

    - -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    -
    - - - -
    -

    Choose payment method

    - -

     Payment description 

    - -
    - - - - - -
    -
    - -
    -

     Checkout description 

    - -
    - Total - EUR -
    - - - - - - -
    -
     Checkout and pay 
    -
    - -

    -  Return to store  -

    -
    -
    -
    diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" deleted file mode 100644 index eb82c3f9d..000000000 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" +++ /dev/null @@ -1,239 +0,0 @@ -
    -

    Cart

    - -
    -
    Custom cartTextEmpty
    -
    - -
    -
    Custom cartTextExpired
    -
    - - - -
    -
    -
    Custom cartTextTitle
    -
    Custom cartTextQuantity
    -
    Custom cartTextPrice
    -
    Custom cartTextSum (EUR)
    -
    - -
      - - - -
      -
      - -
      - -
      Custom cartTextPromoBtn
      -
      -
      - -
      - - -
      -
      - -
      -
      -
      Custom shippingTotalText
      -
      EUR
      -
      -
      -
      Custom cartTextDiscount
      -
      -
      -
      -
      Custom cartTextTotal
      -
      EUR
      -
      -
      -
      Custom cartTextInclVat (%)
      -
      EUR
      -
      -
      -
      - -
      -
      -

      Custom cartTextBillAddr

      - - - -
      -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      -
      - - -
      - -
      -

      Custom cartTextShipAddr

      - -
      -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      - -
      - - -
      -
      -
      - - - -
      -

      Custom cartTextCPM

      - -

      Custom cartTextPaymentDescription

      - -
      - - - - - -
      -
      - -
      -

      Custom cartTextCheckoutDescription

      - -
      - Custom cartTextTotal - EUR -
      - - - - - - -
      -
      Custom cartTextCheckoutButton
      -
      - -

      - Custom cartTextReturnStore -

      -
      -
      -
      diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" deleted file mode 100644 index d47d7f9a6..000000000 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" +++ /dev/null @@ -1,217 +0,0 @@ -
      -

      Cart

      - - - - - - - -
      -
      -
      Title
      -
      Quantity
      -
      Price
      -
      Sum (EUR)
      -
      - -
        - - - -
        -
        - -
        - - -
        -
        - -
        - - -
        -
        - -
        -
        -
        Shipping
        -
        EUR
        -
        - -
        -
        Total
        -
        EUR
        -
        - -
        -
        - -
        -
        -

        Billing address

        - - - -
        - - - - - - - - -
        - - -
        - -
        - - -
        - -
        - - -
        - -
        - - -
        - -
        - - -
        -
        - - -
        - - - - -
        -

        Choose payment method

        - - -
        - - -
        -
        - -
        - -
        - Total - EUR -
        - - - - - - -
        - -
        - -

        - -

        -
        -
        -
        diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" deleted file mode 100644 index a2fdb5a3c..000000000 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" +++ /dev/null @@ -1,223 +0,0 @@ -
        -

        Cart

        - - - - - - - -
        -
        -
        Custom cartTextTitle
        -
        Custom cartTextQuantity
        -
        Custom cartTextPrice
        -
        Custom cartTextSum (EUR)
        -
        - -
          - - - -
          -
          - -
          - - -
          -
          - -
          - - -
          -
          - -
          -
          -
          Custom shippingTotalText
          -
          EUR
          -
          - -
          -
          Custom cartTextTotal
          -
          EUR
          -
          - -
          -
          - -
          -
          -

          Custom cartTextBillAddr

          - - - -
          - - - - - - - - -
          - - -
          - -
          - - -
          - -
          - - -
          - -
          - - -
          - -
          - - -
          -
          - - -
          - - - - - -
          -

          Custom cartTextCPM

          - -

          Custom cartTextPaymentDescription

          - -
          - - -
          -
          - -
          -

          Custom cartTextCheckoutDescription

          - -
          - Custom cartTextTotal - EUR -
          - - - - - - -
          - -
          - -

          - -

          -
          -
          -
          diff --git a/_api_app/tests/Feature/SectionHeadShopStylesTest.php b/_api_app/tests/Feature/SectionHeadShopStylesTest.php deleted file mode 100644 index 1f09ca1fe..000000000 --- a/_api_app/tests/Feature/SectionHeadShopStylesTest.php +++ /dev/null @@ -1,76 +0,0 @@ -bertaRoot = setUpShopTestRoot(); - // The section renderers read the site owner's account - File::ensureDirectoryExists($this->bertaRoot . '/engine/config'); - File::put($this->bertaRoot . '/engine/config/inc.conf.php', "bertaRoot}/engine/inc.hosting.php"); - // SetupMiddleware reconciles the real .env file on every request. - withoutMiddleware(SetupMiddleware::class); - actingAs(new User); -}); - -afterEach(function () { - if (isset($this->bertaRoot)) { - File::deleteDirectory($this->bertaRoot); - } -}); - -/** - * @param array> $settings - */ -function writeMessySettings(string $bertaRoot, array $settings): void -{ - $groups = ''; - foreach ($settings as $group => $values) { - $groups .= "<{$group}>" . implode('', array_map( - fn ($name, $value) => "<{$name}>", - array_keys($values), - $values, - )) . ""; - } - - File::put($bertaRoot . '/storage/settings.messy.xml', "{$groups}"); -} - -it('links the static shop styles and writes the price and cart heading fonts', function () { - writeShopSettings($this->bertaRoot, ['priceItemFontcolor' => '#ff0000', 'priceItemgoogleFont' => 'Roboto:400,700', 'priceItemfontSize' => '']); - writeMessySettings($this->bertaRoot, ['entryHeading' => ['fontWeight' => 'bold', 'fontStyle' => '']]); - - get('http://shop.test/v1/sites/sections/render-head') - ->assertOk() - ->assertSee('/_plugin_shop/css/shop.css?', false) - ->assertDontSee('shop.css.php', false) - // An empty setting shows its default, as the site's settings files read it - ->assertSee('#pageEntries .cartPrice { color: #ff0000; font-family: Roboto; font-size: 12px; font-weight: bold; font-style: normal; font-variant: normal; line-height: 14px; }', false) - ->assertSee('#shoppingCartTitle, #shoppingCartEmpty { font-family: Arial, sans-serif; font-weight: bold; font-style: normal; font-variant: normal; }', false) - ->assertDontSee('#shoppingCart { position: relative;', false); -}); - -it('writes the cart link and price layout of a responsive, centered page', function () { - writeMessySettings($this->bertaRoot, ['pageLayout' => ['responsive' => 'yes', 'centeredContents' => 'yes']]); - - get('http://shop.test/v1/sites/sections/render-head') - ->assertOk() - ->assertSee('#shoppingCart { position: relative; float: right; right: inherit; margin: 0 10px 10px 10px; }#shoppingCart { margin-top: 20px; }', false) - ->assertSee('@media (max-width: 767px) {.bt-auto-responsive #shoppingCart { margin-top: 20px; }', false); -}); - -it('leaves the shop styles out on a domain without a shop key', function () { - get('http://other.test/v1/sites/sections/render-head') - ->assertOk() - ->assertDontSee('/_plugin_shop/css/', false) - ->assertDontSee('.cartPrice', false); -}); diff --git a/_api_app/tests/Feature/ShopCartRenderServiceTest.php b/_api_app/tests/Feature/ShopCartRenderServiceTest.php deleted file mode 100644 index d44f2a150..000000000 --- a/_api_app/tests/Feature/ShopCartRenderServiceTest.php +++ /dev/null @@ -1,183 +0,0 @@ -bertaRoot = setUpShopTestRoot(); -}); - -afterEach(function () { - if (isset($this->bertaRoot)) { - File::deleteDirectory($this->bertaRoot); - } -}); - -/** - * The shopping cart section as the site shows it to visitors - * - * @param array $shop - */ -function renderSiteCart(string $bertaRoot, array $shop = []): string -{ - writeShopSettings($bertaRoot, $shop); - - return (string) (new ShopCartRenderService)->render( - '', - ['template' => ['template' => 'messy-0.4.2']], - (new ShopSettingsDataService(''))->get(), - [], - Request::create('http://shop.test/cart'), - [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart']]], - 'cart', - false, - ); -} - -it('gives the site script the cart page to open after PayPal, on the site only', function (bool $isEditMode, string $link, ?string $cartUrl) { - $html = (string) (new ShopCartRenderService)->renderCartLink( - Request::create('http://shop.test/'), - 'blog', - [], - [], - [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart', 'published' => 1]]], - null, - true, - false, - $isEditMode, - ); - - expect($html)->toContain("href=\"{$link}\""); - $cartUrl === null - ? expect($html)->not->toContain('data-cart-url') - : expect($html)->toContain("data-cart-url=\"{$cartUrl}\""); -})->with([ - 'on the site' => [false, "javascript:openShoppingCart('/blog/cart/');", '/blog/cart/'], - 'in the editor' => [true, '?site=blog§ion=cart', null], -]); - -describe('PayPal buttons', function () { - $restSettings = ['paypalClientId' => 'client-id', 'paypalClientSecret' => 'client-secret', 'paypalCurrencyCode' => 'EUR']; - - it('start with the client ID, script and currency of a shop with REST credentials', function () use ($restSettings) { - Http::fake(); - - expect(renderSiteCart($this->bertaRoot, [...$restSettings, 'paypalSandbox' => 'yes']))->toContain( - '
          ' - ); - Http::assertNothingSent(); - }); - - it('are left out, so the cart keeps the redirect to PayPal', function (array $shop) { - expect(renderSiteCart($this->bertaRoot, $shop))->toContain('
          '); - })->with([ - 'Classic credentials' => [['paypalUserName' => 'seller_api1.berta.test', 'paypalUserPassword' => 'secret', 'paypalSignature' => 'signature']], - 'no PayPal credentials' => [[]], - 'bank transfer only' => [[...$restSettings, 'paymentMethod' => 'bank']], - ]); - - it('are left out in the editor', function () use ($restSettings) { - writeShopSettings($this->bertaRoot, $restSettings); - - $html = (string) (new ShopCartRenderService)->render( - '', - ['template' => ['template' => 'messy-0.4.2']], - (new ShopSettingsDataService(''))->get(), - [], - Request::create('http://shop.test/cart'), - [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart']]], - 'cart', - true, - ); - - expect($html)->toContain('
          '); - }); -}); - -it('preselects bank transfer when PayPal is not set up', function () { - $html = renderSiteCart($this->bertaRoot); - - expect($html) - ->not->toContain('id="pm_paypal"') - ->not->toContain('id="pm_creditcard"') - ->toMatch('/id="pm_bank_transfer" checked="checked"/'); -}); - -it('preselects card payment when PayPal is set up', function () { - $html = renderSiteCart($this->bertaRoot, [ - 'paypalUserName' => 'seller_api1.berta.test', - 'paypalUserPassword' => 'secret', - 'paypalSignature' => 'signature', - ]); - - expect($html) - ->toContain('id="pm_paypal"') - ->toMatch('/id="pm_creditcard" checked="checked"/') - ->not->toMatch('/id="pm_bank_transfer"[^>]*checked/'); -}); - -it('shows the promo code entry only for a promo code with a discount', function (array $shop, bool $isShown) { - $html = renderSiteCart($this->bertaRoot, $shop); - - expect(str_contains($html, 'id="promo_code"'))->toBe($isShown); -})->with([ - 'a code and a discount' => [['promoCode' => 'SAVE10', 'promoCodeDiscount' => '10'], true], - 'no code' => [['promoCode' => '', 'promoCodeDiscount' => '10'], false], - 'no discount' => [['promoCode' => 'SAVE10', 'promoCodeDiscount' => ''], false], -]); - -it('escapes shipping region names once', function () { - writeShopSettings($this->bertaRoot); - - $html = (string) (new ShopCartRenderService)->render( - '', - ['template' => ['template' => 'messy-0.4.2']], - (new ShopSettingsDataService(''))->get(), - [['id' => 1, 'name' => 'Rock & Roll', 'vat' => 0]], - Request::create('http://shop.test/cart'), - [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart']]], - 'cart', - false, - ); - - expect($html)->toContain(''); -}); - -describe('editable texts', function () { - $customTexts = collect([ - 'cartManualTransfer', 'cartTextAddress', 'cartTextBillAddr', 'cartTextCPM', 'cartTextCardPayment', - 'cartTextCheckoutButton', 'cartTextCheckoutDescription', 'cartTextComments', 'cartTextCompany', - 'cartTextCompanyRegNo', 'cartTextDeliverBilling', 'cartTextDiscount', 'cartTextEmail', 'cartTextEmpty', - 'cartTextExpired', 'cartTextFillRequiredFields', 'cartTextInclVat', 'cartTextLegalAddress', - 'cartTextLegalPerson', 'cartTextNameSurname', 'cartTextPaymentDescription', 'cartTextPaypalPayment', - 'cartTextPhoneNumber', 'cartTextPrice', 'cartTextPromoBtn', 'cartTextPromoCode', 'cartTextQuantity', - 'cartTextReceiveNews', 'cartTextReturnStore', 'cartTextShipAddr', 'cartTextShippingTo', 'cartTextSum', - 'cartTextTerms', 'cartTextTitle', 'cartTextTotal', 'cartTextVatNo', 'shippingTotalText', - ])->mapWithKeys(fn ($setting) => [$setting => "Custom {$setting}"])->all(); - - it('renders the cart texts', function (bool $isEditMode, array $siteTexts) { - $html = (string) (new ShopCartRenderService)->render( - 'blog', - ['template' => ['template' => 'messy-0.4.2'], 'siteTexts' => $siteTexts], - (new ShopSettingsDataService(''))->get(), - [['id' => 1, 'name' => 'Europe', 'vat' => 21]], - Request::create('http://shop.test/cart'), - [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart']]], - 'cart', - $isEditMode, - ); - - expect($html)->toMatchSnapshot(); - })->with([ - 'on the site, by default' => [false, []], - 'on the site, customised' => [false, $customTexts], - 'in the editor, by default' => [true, []], - 'in the editor, customised' => [true, $customTexts], - ]); -}); diff --git a/_api_app/tests/Feature/ShopControllerTest.php b/_api_app/tests/Feature/ShopControllerTest.php deleted file mode 100644 index 3a8210397..000000000 --- a/_api_app/tests/Feature/ShopControllerTest.php +++ /dev/null @@ -1,541 +0,0 @@ - 'shop.test']); -}); - -// A real unauthenticated request can't run here: the guard's failure path -// logs out through the legacy engine. -it('requires authentication for the settings config', function () { - $middleware = Route::getRoutes()->getByName('settings-config')->gatherMiddleware(); - - expect($middleware)->toContain(Authenticate::class); -}); - -it('returns 403 for the settings config on a domain without a shop key', function () { - actingAs(new User); - - getJson('http://other.test' . route('settings-config', [], false)) - ->assertForbidden() - ->assertJsonPath('message', 'Shop not available!'); -}); - -it('returns 403 from every shop admin endpoint on a domain without a shop key', function (string $method, string $path) { - actingAs(new User); - - $this->json($method, 'http://other.test' . route('shop-state', [], false) . $path) - ->assertForbidden() - ->assertJsonPath('message', 'Shop not available!'); -})->with([ - 'state' => ['GET', ''], - 'products' => ['GET', '/products'], - 'product update' => ['PATCH', '/products'], - 'orders' => ['GET', '/orders'], - 'order update' => ['PATCH', '/orders'], - 'order invoice' => ['GET', '/orders/1/invoice'], - 'regional costs' => ['GET', '/regional-costs'], - 'region add' => ['POST', '/region'], - 'region update' => ['PATCH', '/region'], - 'region delete' => ['DELETE', '/region/0/1'], - 'regional cost add' => ['POST', '/regional-costs'], - 'regional cost update' => ['PATCH', '/regional-costs'], - 'regional cost delete' => ['DELETE', '/regional-costs/0/1'], - 'settings' => ['GET', '/settings'], - 'settings update' => ['PATCH', '/settings'], - 'settings upload' => ['POST', '/settings/upload'], - 'settings config' => ['GET', '/settings-config'], -]); - -it('returns the shop settings config grouped by section', function () { - actingAs(new User); - - getJson('http://shop.test' . route('settings-config', [], false)) - ->assertOk() - ->assertJsonPath('data.group_config._.title', 'Configuration values') - ->assertJsonPath('data.group_config.currency.default', 'EUR'); -}); - -it('translates the shop settings config', function () { - $bertaRoot = setUpShopTestRoot(); - actingAs(new User); - - try { - expect((new ShopSettingsDataService(''))->getConfig('lv')) - ->group_config->_->title->toBe('Konfigurācijas vērtības') - ->group_config->currency->default->toBe('EUR'); - } finally { - File::deleteDirectory($bertaRoot); - } -}); - -it('returns the settings config in the language asked for', function () { - actingAs(new User); - - getJson('http://shop.test' . route('settings-config', ['language' => 'lv'], false)) - ->assertOk() - ->assertJsonPath('data.group_config._.title', 'Konfigurācijas vērtības') - ->assertJsonPath('data.group_config.email.description', 'Obligāts. Tava e-pasta adrese, uz kuru veikals sūta katru jauno pasūtījumu.'); -}); - -it('returns the settings config texts, not translation keys, in every language', function (string $language) { - $texts = collect((new ShopSettingsDataService(''))->getConfig($language)) - ->dot() - ->filter(fn ($value, $path) => preg_match('/\.(title|description)$/', $path)) - ->values(); - - expect($texts)->not->toBeEmpty() - ->and($texts->filter(fn ($text) => preg_match('/^shop_\w+_description$/', (string) $text))->all())->toBe([]); -})->with(['en', 'lv', 'pl', 'es', 'fr', 'nl', 'ru']); - -it('returns 422 for a settings config language that is not a language code', function () { - actingAs(new User); - - getJson('http://shop.test' . route('settings-config', ['language' => '../inc.settings'], false)) - ->assertUnprocessable() - ->assertJsonValidationErrors('language'); -}); - -it('sends the settings config in the site\'s language with the shop state', function () { - $bertaRoot = setUpShopTestRoot(); - actingAs(new User); - (new SiteSettingsDataService(''))->saveValueByPath('/settings/language/language', 'lv'); - - try { - getJson('http://shop.test' . route('shop-state', [], false) . '?site=') - ->assertOk() - ->assertJsonPath('data.settingsConfig.group_config._.title', 'Konfigurācijas vērtības'); - } finally { - File::deleteDirectory($bertaRoot); - } -}); - -it('returns the explanation of each PayPal credentials group', function () { - actingAs(new User); - - getJson('http://shop.test' . route('settings-config', [], false)) - ->assertOk() - ->assertJsonPath('data.group_paypal_rest._.title', 'PayPal REST app credentials (recommended)') - ->assertJsonPath('data.group_paypal_rest._.description', fn ($description) => str_contains($description, 'https://developer.paypal.com/dashboard/applications/live')) - ->assertJsonPath('data.group_paypal_classic._.description', fn ($description) => str_contains($description, 'https://developer.paypal.com/api/nvp-soap/apiCredentials/')) - ->assertJsonMissingPath('data.group_config._.description'); -}); - -describe('regions', function () { - beforeEach(function () { - $this->bertaRoot = setUpShopTestRoot(); - actingAs(new User); - }); - - afterEach(function () { - if (isset($this->bertaRoot)) { - File::deleteDirectory($this->bertaRoot); - } - }); - - it('stores a decimal VAT rate typed with a comma', function () { - postJson('http://shop.test' . route('regions', [], false), ['site' => '', 'data' => ['name' => 'Finland', 'vat' => '25,5']]) - ->assertOk() - ->assertJsonPath('data.data.vat', 25.5); - - expect((new ShopShippingRegionsDataService(''))->query()->value('vat'))->toEqual(25.5); - }); - - it('updates the VAT rate of a region', function () { - (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); - - patchJson('http://shop.test' . route('regions', [], false), ['path' => '/1/vat', 'value' => '13.5']) - ->assertOk() - ->assertJsonPath('data.value', 13.5); - - expect((new ShopShippingRegionsDataService(''))->query()->value('vat'))->toEqual(13.5); - }); - - it('returns 400 for a VAT rate that is not a number from 0 to 100', function (string $vat) { - (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); - - patchJson('http://shop.test' . route('regions', [], false), ['path' => '/1/vat', 'value' => $vat]) - ->assertStatus(400) - ->assertJsonPath('message', 'VAT must be a number from 0 to 100'); - - expect((new ShopShippingRegionsDataService(''))->query()->value('vat'))->toEqual(24); - })->with(['abc', '101', '-1']); - - it('stores a renamed region as typed', function () { - (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); - - patchJson('http://shop.test' . route('regions', [], false), ['path' => '/1/name', 'value' => 'Rock & "Roll"']) - ->assertOk() - ->assertJsonPath('data.value', 'Rock & "Roll"'); - - expect((new ShopShippingRegionsDataService(''))->query()->value('name'))->toBe('Rock & "Roll"'); - }); - - it('updates the weight and price of the shipping cost the path names', function () { - $regions = new ShopShippingRegionsDataService(''); - $costs = new ShopShippingCostsDataService(''); - $regions->addRegion('Latvia', 21); - $regions->addRegion('Europe', 21); - $costs->addRegionCost(1, 500, 5); - $costs->addRegionCost(1, 1000, 8); - $costs->addRegionCost(2, 500, 10); - $costs->addRegionCost(2, 1000, 15); - - patchJson('http://shop.test' . route('regional-costs', [], false), ['path' => '/3/price', 'value' => '7.5']) - ->assertOk() - ->assertJsonPath('data.value', '7.5'); - patchJson('http://shop.test' . route('regional-costs', [], false), ['path' => '/4/weight', 'value' => '1500']) - ->assertOk(); - - expect($costs->query()->orderBy('id')->get(['id', 'id_region', 'weight', 'price'])->map(fn ($cost) => (array) $cost)->all())->toEqual([ - ['id' => 1, 'id_region' => 1, 'weight' => 500, 'price' => 5], - ['id' => 2, 'id_region' => 1, 'weight' => 1000, 'price' => 8], - ['id' => 3, 'id_region' => 2, 'weight' => 500, 'price' => 7.5], - ['id' => 4, 'id_region' => 2, 'weight' => 1500, 'price' => 15], - ]); - }); - - it('numbers new regions and their costs after the existing ones', function () { - (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); - (new ShopShippingRegionsDataService(''))->query()->where('id', 1)->update(['id' => 7]); - - postJson('http://shop.test' . route('regions', [], false), ['site' => '', 'data' => ['name' => 'Estonia', 'vat' => '22']]) - ->assertOk() - ->assertJsonPath('data.data.id', 8); - - postJson('http://shop.test' . route('regional-costs', [], false), ['site' => '', 'data' => ['id_region' => 8, 'weight' => '500', 'price' => '4.5']]) - ->assertOk() - ->assertJsonPath('data.data.id', 1); - postJson('http://shop.test' . route('regional-costs', [], false), ['site' => '', 'data' => ['id_region' => 8, 'weight' => '1000', 'price' => '6']]) - ->assertOk() - ->assertJsonPath('data.data.id', 2); - }); - - it('unescapes the region names an older shop version stored HTML-escaped', function () { - writeShopSettings($this->bertaRoot, ['version' => '4']); - $database = new SQLite3($this->bertaRoot . '/storage/shop-db.sqlite'); - $database->exec('CREATE TABLE "shipping_region" ("id" integer not null, "name" varchar not null, "vat" integer not null, primary key ("id"))'); - $database->exec("INSERT INTO shipping_region (name, vat) VALUES ('Rock & "Roll"', 0), ('Plain', 0)"); - $database->close(); - - $regions = new ShopShippingRegionsDataService(''); - - expect($regions->query()->orderBy('id')->pluck('name')->all())->toBe(['Rock & "Roll"', 'Plain']) - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(9); - }); -}); - -/** - * Checked-out orders ORD-1 to ORD-{$count}, each a minute after the previous one unless they share - * $createdAt and each with a paid poster, plus a newer basket that isn't an order yet - */ -function placeShopOrders(int $count, ?string $createdAt = null): void -{ - $clients = new ShopClientsDataService(''); - $orders = new ShopOrdersDataService(''); - (new ShopProductsDataService(''))->query()->insert(['id' => 1, 'uniqid' => 'poster', 'name' => 'Poster']); - - foreach (range(1, $count) as $id) { - $placedAt = $createdAt ?? Carbon::create(2026, 1, 1)->addMinutes($id)->toDateTimeString(); - $clients->query()->insert(['id' => $id, 'created_at' => $placedAt, 'session_id' => 'checkout', 'order_id' => "ORD-{$id}"]); - $orders->query()->insert(['id' => $id, 'created_at' => $placedAt, 'id_client' => $id, 'id_product' => 1, 'paid' => 1, 'price' => 10]); - } - - $clients->query()->insert(['id' => $count + 1, 'created_at' => '2027-01-01 00:00:00', 'session_id' => str_repeat('a', 32)]); -} - -/** - * @return array{orders: list>, page: int, lastPage: int, total: int, statusFilter: string|null} - */ -function getShopOrdersPage(int $page, ?string $status = null): array -{ - $statusQuery = $status === null ? '' : "&status={$status}"; - - return getJson('http://shop.test' . route('orders', [], false) . "?site=&page={$page}{$statusQuery}") - ->assertOk() - ->json('data')['']; -} - -function shopOrderInvoiceUrl(int $id, string $site = ''): string -{ - return 'http://shop.test' . route('order-invoice', ['id' => $id], false) . '?site=' . $site; -} - -/** - * @param array> $columnsById - */ -function updateShopOrders(array $columnsById): void -{ - foreach ($columnsById as $id => $columns) { - (new ShopClientsDataService(''))->query()->where('id', $id)->update($columns); - } -} - -/** - * @return array - */ -function shopOrderStatuses(): array -{ - return (new ShopClientsDataService(''))->query()->orderBy('id')->pluck('status', 'id')->all(); -} - -/** - * @return array - */ -function shopOrderNotes(): array -{ - return (new ShopClientsDataService(''))->query()->orderBy('id')->pluck('admin_notes', 'id')->all(); -} - -describe('orders', function () { - beforeEach(function () { - $this->bertaRoot = setUpShopTestRoot(); - actingAs(new User); - }); - - afterEach(function () { - if (isset($this->bertaRoot)) { - File::deleteDirectory($this->bertaRoot); - } - }); - - it('sends the newest orders as the first page with the shop state, without baskets', function () { - placeShopOrders(25); - - $firstPage = getJson('http://shop.test' . route('shop-state', [], false) . '?site=') - ->assertOk() - ->json('data.orders')['']; - - expect($firstPage)->toMatchArray(['page' => 1, 'lastPage' => 2, 'total' => 25]) - ->and(array_column($firstPage['orders'], 'order_id'))->toBe(array_map(fn (int $id) => "ORD-{$id}", range(25, 6))); - }); - - it('returns the requested page of orders with their paid products', function () { - placeShopOrders(25); - - $secondPage = getShopOrdersPage(2); - - expect($secondPage)->toMatchArray(['page' => 2, 'lastPage' => 2, 'total' => 25]) - ->and(array_column($secondPage['orders'], 'order_id'))->toBe(['ORD-5', 'ORD-4', 'ORD-3', 'ORD-2', 'ORD-1']) - ->and($secondPage['orders'][0]['orders'])->toEqual([['id_client' => 5, 'name' => 'Poster', 'qty' => 1, 'sum_total' => 10]]); - }); - - it('returns the last page for a page past the end', function () { - placeShopOrders(25); - - $page = getShopOrdersPage(9); - - expect($page['page'])->toBe(2) - ->and(array_column($page['orders'], 'order_id'))->toBe(['ORD-5', 'ORD-4', 'ORD-3', 'ORD-2', 'ORD-1']); - }); - - it('pages orders placed in the same second without repeating or skipping any', function () { - placeShopOrders(25, '2026-01-01 12:00:00'); - - $ids = [ - ...array_column(getShopOrdersPage(1)['orders'], 'id'), - ...array_column(getShopOrdersPage(2)['orders'], 'id'), - ]; - - expect($ids)->toBe(range(25, 1)); - }); - - it('pages only the orders with the filtered status', function () { - placeShopOrders(25); - updateShopOrders(array_fill_keys(range(1, 21), ['status' => 'shipped']) + [22 => ['status' => 'paid']]); - - $shippedPage = getShopOrdersPage(2, 'shipped'); - - expect($shippedPage)->toMatchArray(['page' => 2, 'lastPage' => 2, 'total' => 21, 'statusFilter' => 'shipped']) - ->and(array_column($shippedPage['orders'], 'order_id'))->toBe(['ORD-1']) - ->and(array_column(getShopOrdersPage(1, 'paid')['orders'], 'order_id'))->toBe(['ORD-22']); - }); - - it('finds the orders without a status, leaving out baskets', function () { - placeShopOrders(3); - updateShopOrders([2 => ['status' => 'paid']]); - - $page = getShopOrdersPage(1, 'none'); - - expect($page)->toMatchArray(['total' => 2, 'statusFilter' => 'none']) - ->and(array_column($page['orders'], 'order_id'))->toBe(['ORD-3', 'ORD-1']); - }); - - it('returns 422 for an unknown status filter', function () { - getJson('http://shop.test' . route('orders', [], false) . '?site=&status=lost') - ->assertUnprocessable() - ->assertJsonValidationErrors(['status' => 'The selected status is invalid.']); - }); - - it('sets the status of an order', function (string $status) { - placeShopOrders(2); - - patchJson('http://shop.test' . route('orders', [], false), ['path' => '/2/status', 'value' => $status]) - ->assertOk() - ->assertJsonPath('data', ['site' => '', 'path' => '2/status', 'value' => $status]); - - expect(shopOrderStatuses())->toBe([1 => null, 2 => $status, 3 => null]); - })->with(['awaiting_payment', 'paid', 'shipped', 'cancelled', 'refunded']); - - it('keeps a PayPal order from awaiting payment', function () { - placeShopOrders(1); - updateShopOrders([1 => ['paypal' => 1, 'status' => 'paid']]); - - patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/status', 'value' => 'awaiting_payment']) - ->assertStatus(400) - ->assertJsonPath('message', 'An order paid with PayPal or by debit or credit card is already paid!'); - - expect(shopOrderStatuses()[1])->toBe('paid'); - }); - - it('returns 404 for a status change of no order', function (string $path, string $message) { - placeShopOrders(1); - - patchJson('http://shop.test' . route('orders', [], false), ['path' => $path, 'value' => 'paid']) - ->assertNotFound() - ->assertJsonPath('message', $message); - - expect(shopOrderStatuses())->toBe([1 => null, 2 => null]); - })->with([ - 'a basket' => ['/2/status', 'Order not found!'], - 'a missing order' => ['/9/status', 'Order not found!'], - 'an unknown site' => ['nosite/1/status', 'Site not found!'], - 'the notes of a basket' => ['/2/admin_notes', 'Order not found!'], - ]); - - it('returns 422 for a status change of another field or to an unknown status', function () { - placeShopOrders(1); - - patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/order_id', 'value' => 'lost']) - ->assertUnprocessable() - ->assertJsonValidationErrors(['path', 'value']); - - expect(shopOrderStatuses())->toBe([1 => null, 2 => null]); - }); - - it('saves the private notes of an order', function () { - placeShopOrders(2); - $notes = "Called the customer.\nShips on Friday."; - - patchJson('http://shop.test' . route('orders', [], false), ['path' => '/2/admin_notes', 'value' => $notes]) - ->assertOk() - ->assertJsonPath('data', ['site' => '', 'path' => '2/admin_notes', 'value' => $notes]); - - expect(shopOrderNotes())->toBe([1 => null, 2 => $notes, 3 => null]) - ->and(getShopOrdersPage(1)['orders'][0])->toMatchArray(['order_id' => 'ORD-2', 'admin_notes' => $notes]); - }); - - it('clears the private notes of an order', function () { - placeShopOrders(1); - updateShopOrders([1 => ['admin_notes' => 'Ships on Friday.']]); - - patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/admin_notes', 'value' => '']) - ->assertOk() - ->assertJsonPath('data.value', null); - - expect(shopOrderNotes())->toBe([1 => null, 2 => null]); - }); - - it('returns 422 for private notes over 5000 characters', function () { - placeShopOrders(1); - - patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/admin_notes', 'value' => str_repeat('a', 5001)]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['value']); - - expect(shopOrderNotes())->toBe([1 => null, 2 => null]); - }); - - it('downloads the invoice issued to the customer unchanged', function () { - writeShopSettings($this->bertaRoot, ['invoices' => 'yes', 'sellerDetails' => 'Riga Prints SIA']); - placeShopOrders(1); - File::ensureDirectoryExists(dirname(shopFile('invoices/invoice-ORD-1.pdf'))); - File::put(shopFile('invoices/invoice-ORD-1.pdf'), '%PDF-issued'); - - $response = get(shopOrderInvoiceUrl(1)); - - $response->assertDownload('invoice-ORD-1.pdf')->assertHeader('Content-Type', 'application/pdf'); - expect($response->streamedContent())->toBe('%PDF-issued'); - }); - - it('makes and keeps the invoice of an order that has none', function () { - writeShopSettings($this->bertaRoot, ['invoices' => 'yes', 'sellerDetails' => 'Riga Prints SIA']); - placeShopOrders(1); - updateShopOrders([1 => ['name' => 'Ann Buyer', 'price_total' => 15, 'shipping' => 5, 'vat' => 21, 'payment_method' => 'bank_transfer', 'paypal' => 0]]); - - $response = get(shopOrderInvoiceUrl(1)); - - $response->assertDownload('invoice-ORD-1.pdf'); - expect($response->streamedContent()) - ->toStartWith('%PDF-') - ->toBe(File::get(shopFile('invoices/invoice-ORD-1.pdf'))); - }); - - it('returns 404 for an invoice when invoices are off or the shop has no seller details', function (array $shop) { - writeShopSettings($this->bertaRoot, $shop); - placeShopOrders(1); - - getJson(shopOrderInvoiceUrl(1)) - ->assertNotFound() - ->assertJsonPath('message', 'Invoices are turned off, or the shop has no seller details.'); - - expect(shopFiles())->toBe([]); - })->with([ - 'invoices off' => [['sellerDetails' => 'Riga Prints SIA']], - 'no seller details' => [['invoices' => 'yes']], - ]); - - it('returns 404 for the invoice of no order', function (int $id, string $site, string $message) { - writeShopSettings($this->bertaRoot, ['invoices' => 'yes', 'sellerDetails' => 'Riga Prints SIA']); - placeShopOrders(1); - - getJson(shopOrderInvoiceUrl($id, $site)) - ->assertNotFound() - ->assertJsonPath('message', $message); - })->with([ - 'an unknown order' => [99, '', 'Order not found!'], - 'a basket' => [2, '', 'Order not found!'], - 'an unknown site' => [1, 'elsewhere', 'Site not found!'], - ]); - - it('adds an empty status, private notes, payment method, VAT number and payment reference to the orders of an older shop version', function () { - writeShopSettings($this->bertaRoot, ['version' => '5']); - $database = new SQLite3($this->bertaRoot . '/storage/shop-db.sqlite'); - $database->exec('CREATE TABLE "client" ("id" integer not null, "created_at" datetime not null, "session_id" varchar not null, "order_id" varchar, primary key ("id"))'); - $database->exec("INSERT INTO client (created_at, session_id, order_id) VALUES ('2026-01-01 00:00:00', 'checkout', 'ORD-1')"); - $database->close(); - - $page = getShopOrdersPage(1); - - expect($page['orders'])->toHaveCount(1) - ->and($page['orders'][0])->toHaveKey('status', null) - ->and($page['orders'][0])->toHaveKey('admin_notes', null) - ->and($page['orders'][0])->toHaveKey('payment_method', null) - ->and($page['orders'][0])->toHaveKey('vat_no', null) - ->and($page['orders'][0])->toHaveKey('payment_reference', null) - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(9); - }); -}); diff --git a/_api_app/tests/Feature/ShopInvoiceTest.php b/_api_app/tests/Feature/ShopInvoiceTest.php deleted file mode 100644 index cd39afa59..000000000 --- a/_api_app/tests/Feature/ShopInvoiceTest.php +++ /dev/null @@ -1,142 +0,0 @@ -bertaRoot = setUpShopTestRoot(); -}); - -afterEach(function () { - if (isset($this->bertaRoot)) { - File::deleteDirectory($this->bertaRoot); - } -}); - -/** - * A checked-out order of a chair for 100 and a T-shirt for 25, with a 10% promo discount and 10 shipping, 21% VAT - * included, as the shop admin finds it - * - * @param array $columns The order's `client` columns besides the amounts - */ -function savedShopOrder(array $columns = []): object -{ - (new ShopClientsDataService(''))->query()->insert([ - 'id' => 1, - 'created_at' => '2026-10-12 10:20:00', - 'session_id' => 'checkout', - 'order_id' => '2026-7', - 'name' => 'Ann Buyer', - 'phone' => '+371 20000000', - 'address' => 'Brivibas 1, Riga', - 'email' => 'ann@buyer.test', - 'vat' => 21, - 'shipping' => 10, - 'price_total' => 122.5, - 'promo_code' => 'SAVE10', - 'paypal' => 0, - 'payment_method' => 'bank_transfer', - ...$columns, - ]); - (new ShopProductsDataService(''))->query()->insert([ - ['id' => 1, 'uniqid' => 'chair', 'name' => 'Chair'], - ['id' => 2, 'uniqid' => 'tshirt', 'name' => 'T-shirt M'], - ]); - (new ShopOrdersDataService(''))->query()->insert([ - ['id' => 1, 'created_at' => '2026-10-12 10:00:00', 'id_client' => 1, 'id_product' => 1, 'paid' => 1, 'price' => 100], - ['id' => 2, 'created_at' => '2026-10-12 10:00:00', 'id_client' => 1, 'id_product' => 2, 'paid' => 1, 'price' => 25], - ]); - - return (new ShopClientsDataService(''))->findOrder(1); -} - -function savedShopOrderInvoice(object $order): string -{ - $shop = ['currency' => 'EUR', 'email' => 'seller@berta.test', 'sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]; - - return ShopInvoice::fromSavedOrder($order, $shop, 'Riga Prints')->html(); -} - -/** - * The invoice's text as its reader sees it, without the markup - */ -function shopInvoiceText(string $html): string -{ - return preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($html), ENT_QUOTES)); -} - -it('shows the seller, the amounts and the bank transfer of a saved order', function () { - $order = savedShopOrder(); - - $html = savedShopOrderInvoice($order); - - expect(shopInvoiceText($html)) - ->toContain('Invoice 2026-7 12.10.2026 Riga Prints') - ->toContain('Chair 1 100.00 100.00') - ->toContain('T-shirt M 1 25.00 25.00') - ->toContain('Subtotal 125.00 Discount (10%) -12.50 Shipping 10.00 Total excl. VAT 101.24 VAT (21%) 21.26 Total 122.50 EUR') - ->toContain('Please pay 122.50 EUR by bank transfer to the account in the seller details, with the payment reference 2026-7.') - ->not->toContain('seller@berta.test') - ->not->toContain('Paid '); - expect($html) - ->toContain('Riga Prints SIA
          ' . "\n" . 'IBAN LV80BANK0000435195001') - ->toContain('Ann Buyer
          Brivibas 1, Riga
          +371 20000000
          ann@buyer.test'); -}); - -it('shows the company details of a legal person, and the shipping address of an order shipped elsewhere', function () { - $order = savedShopOrder([ - 'company' => 'Buyer SIA', - 'company_reg_no' => '40003000000', - 'vat_no' => 'LV40003000000', - 'legal_address' => 'Brivibas 2, Riga', - 'ship_name' => 'Juris Buyer', - 'ship_address' => 'Lacplesa 5, Riga', - ]); - - $html = savedShopOrderInvoice($order); - - expect($html) - ->toContain('Buyer SIA
          Reg. no. 40003000000
          VAT no. LV40003000000
          Legal address: Brivibas 2, Riga
          Ann Buyer') - ->toContain('SHIPPING ADDRESS

          ') - ->toContain('Juris Buyer
          Lacplesa 5, Riga') - ->not->toContain('Same as billing address'); -}); - -it('shows how and when a paid order was paid', function (array $payment, array $paid) { - $order = savedShopOrder($payment); - - $text = shopInvoiceText(savedShopOrderInvoice($order)); - - expect($text) - ->toContain(...$paid) - ->not->toContain('Please pay'); -})->with([ - 'with PayPal' => [ - ['payment_method' => 'paypal', 'paypal' => 1, 'payment_reference' => '3C679366HH908993F'], - ['PAYMENT Paid with PayPal on 12.10.2026.', 'PayPal transaction 3C679366HH908993F'], - ], - 'by card' => [ - ['payment_method' => 'creditcard', 'paypal' => 1, 'payment_reference' => '3C679366HH908993F'], - ['PAYMENT Paid by debit or credit card on 12.10.2026.'], - ], - 'an order placed before the payment method was saved' => [ - ['payment_method' => null, 'paypal' => 1], - ['PAYMENT Paid with PayPal on 12.10.2026.'], - ], -]); - -it('escapes the customer details', function () { - $order = savedShopOrder(['name' => 'Ann', 'company' => 'Buyer & Co', 'company_reg_no' => '1', 'legal_address' => 'Riga']); - - $html = savedShopOrderInvoice($order); - - expect($html) - ->toContain('<b>Ann</b>') - ->toContain('Buyer & <i>Co</i>') - ->not->toContain('Ann'); -}); diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php deleted file mode 100644 index 7ff02e5fd..000000000 --- a/_api_app/tests/Feature/ShopStorefrontControllerTest.php +++ /dev/null @@ -1,1596 +0,0 @@ -bertaRoot = setUpShopTestRoot(); - - File::put($this->bertaRoot . '/storage/sections.xml', '' - . '
          <![CDATA[Shop]]>
          ' - . '
          <![CDATA[Cart]]>
          ' - . '
          '); - File::put($this->bertaRoot . '/storage/blog.shop.xml', '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . ''); - - (new ShopShippingRegionsDataService(''))->addRegion('Europe', 21); - (new ShopShippingCostsDataService(''))->addRegionCost(1, 500, 5); - (new ShopShippingCostsDataService(''))->addRegionCost(1, 2000, 10); - - // SetupMiddleware reconciles the real .env file on every request. - withoutMiddleware(SetupMiddleware::class); - Http::preventStrayRequests(); -}); - -afterEach(function () { - if (isset($this->bertaRoot)) { - File::deleteDirectory($this->bertaRoot); - } -}); - -function nvpSettings(): array -{ - return ['paypalUserName' => 'seller_api1.berta.test', 'paypalUserPassword' => 'secret', 'paypalSignature' => 'signature', 'paypalCurrencyCode' => 'EUR']; -} - -function restSettings(): array -{ - return ['paypalClientId' => 'client-id', 'paypalClientSecret' => 'client-secret', 'paypalCurrencyCode' => 'EUR']; -} - -/** - * Invoices turned on, with the seller details they need - */ -function invoiceSettings(): array -{ - return ['invoices' => 'yes', 'sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]; -} - -/** - * A second site of a multisite, selling the main site's products - * - * @param array $shop - */ -function setUpSecondShopSite(string $bertaRoot, array $shop = []): void -{ - writeShopSettings($bertaRoot, $shop, 'second'); - File::put($bertaRoot . '/storage/-sites/sites.xml', '' - . '<![CDATA[Main]]>' - . '<![CDATA[Second]]>' - . ''); - File::copy($bertaRoot . '/storage/sections.xml', $bertaRoot . '/storage/-sites/second/sections.xml'); - File::copy($bertaRoot . '/storage/blog.shop.xml', $bertaRoot . '/storage/-sites/second/blog.shop.xml'); - (new ShopShippingRegionsDataService('second'))->addRegion('Europe', 21); -} - -function storefrontUrl(string $path, array $query = []): string -{ - return 'http://shop.test/v1/plugin/shop/storefront/' . $path . ($query ? '?' . http_build_query($query) : ''); -} - -function shopDb(): Connection -{ - return (new ShopClientsDataService(''))->getConnection(); -} - -/** - * A site visitor whose shop_client cookie holds the basket token - */ -function asShopCustomer(?string $token, string $site = ''): TestCase -{ - return withCredentials()->withUnencryptedCookie(ShopBasketService::clientCookie($site), (string) $token); -} - -function addToShopBasket(string $uniqid, ?string $attribute = null, ?string $token = null, string $site = ''): TestResponse -{ - return asShopCustomer($token, $site) - ->postJson(storefrontUrl('basket', array_filter(['site' => $site])), ['uniqid' => $uniqid, 'attribute' => $attribute]); -} - -/** - * Fills a new basket and returns its cookie token - * - * @param list $items - */ -function fillShopBasket(array $items, string $site = ''): string -{ - $token = null; - foreach ($items as $item) { - $token = addToShopBasket($item[0], $item[1] ?? null, $token, $site) - ->assertOk() - ->getCookie(ShopBasketService::clientCookie($site), false) - ->getValue(); - } - - return $token; -} - -function shopCheckout(string $token, array $form = [], string $site = ''): TestResponse -{ - return asShopCustomer($token, $site) - ->postJson(storefrontUrl('checkout', array_filter(['site' => $site])), array_merge([ - 'name' => 'Ann Buyer', - 'phone' => '+371 20000000', - 'address' => 'Brivibas 1, Riga', - 'email' => 'ann@buyer.test', - 'notes' => '', - 'legal_person' => false, - 'deliver_to_billing' => true, - 'send_news' => false, - 'promo_code' => '', - 'region' => 1, - 'payment_method' => 'bank_transfer', - ], $form)); -} - -/** - * The order email's text as its reader sees it, without the markup - */ -function shopMailText(ShopOrderMail $mail): string -{ - return preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($mail->render()), ENT_QUOTES)); -} - -/** - * Where the order email's html and text parts match SpamAssassin's HEXHASH_WORD pattern: a word, a hash-like run - * of digits and hyphens (as an order number is), then a word. Two matches in an email add spam points. - * - * @return list - */ -function shopMailHexHashWords(ShopOrderMail $mail): array -{ - // Spam filters read the html with whitespace at block tags, as a reader sees it - $html = html_entity_decode(strip_tags(preg_replace('/<(br|p|div|td|tr|h1|title)\b/i', ' $0', $mail->render())), ENT_QUOTES); - $text = view('Plugins/Shop/orderMailText', $mail->buildViewData())->render(); - preg_match_all( - '/\s[A-Z]?[a-z]{1,15}\s(?![a-z]{10,20}\s)[a-z]{0,10}(?!-?\d{1,5}-)(?!\d{10}\s)(?:(?!--)[-0-9a-f]){10,64}(?:[g-z][a-z]{0,10})?\s[A-Z]?[a-z]{1,15}\b/', - preg_replace('/\s+/', ' ', "{$html} {$text}"), - $matches, - ); - - return $matches[0]; -} - -function fakeNvp(string $captureAck = 'Success'): void -{ - Http::fake(['api-3t.paypal.com/nvp' => fn (HttpRequest $request) => match ($request['METHOD']) { - 'SetExpressCheckout' => Http::response('ACK=Success&TOKEN=' . SHOP_NVP_TOKEN), - 'DoExpressCheckoutPayment' => Http::response("ACK={$captureAck}&PAYMENTINFO_0_TRANSACTIONID=8AB12345CD678901E"), - }]); -} - -/** - * @param array|null $order PayPal's answer to creating an order, by default a redirect order's - */ -function fakeRest(?Closure $capture = null, ?array $order = null): void -{ - Http::fake([ - 'api-m.paypal.com/v1/oauth2/token' => Http::response(['access_token' => 'A21AA', 'expires_in' => 32400]), - 'api-m.paypal.com/v2/checkout/orders' => Http::response($order ?? [ - 'id' => SHOP_REST_TOKEN, - 'status' => 'PAYER_ACTION_REQUIRED', - 'links' => [['rel' => 'payer-action', 'href' => 'https://www.paypal.com/checkoutnow?token=' . SHOP_REST_TOKEN]], - ]), - 'api-m.paypal.com/v2/checkout/orders/*/capture' => $capture ?? Http::response(paidRestOrder()), - ]); -} - -function paidRestOrder(): array -{ - return [ - 'id' => SHOP_REST_TOKEN, - 'status' => 'COMPLETED', - 'purchase_units' => [['payments' => ['captures' => [['id' => '3C679366HH908993F', 'status' => 'COMPLETED']]]]], - ]; -} - -function returnFromPaypal(string $token): TestResponse -{ - return get(storefrontUrl('paypal/return', ['token' => $token, 'PayerID' => 'PAYER1'])); -} - -/** - * The site's front page, where PayPal's return sends the customer of a paid order with its number - */ -function paidReturnUrl(string $orderId): string -{ - return 'http://shop.test/?shop_payment=paid&shop_order=' . $orderId; -} - -/** - * The number of the year's nth order - */ -function orderNumber(int $nth): string -{ - return now()->year . "-{$nth}"; -} - -function capturesSent(): int -{ - return Http::recorded(fn (HttpRequest $request) => str_ends_with($request->url(), '/capture') - || ($request->data()['METHOD'] ?? null) === 'DoExpressCheckoutPayment')->count(); -} - -it('returns 403 on a domain without the shop key', function () { - postJson('http://other.test/v1/plugin/shop/storefront/stock', ['uniqids' => [SHOP_CHAIR]]) - ->assertForbidden() - ->assertJsonPath('message', 'Shop not available!'); -}); - -it('returns 404 for a site that does not exist', function () { - postJson(storefrontUrl('stock', ['site' => '../../elsewhere']), ['uniqids' => [SHOP_CHAIR]]) - ->assertNotFound(); - - expect(File::exists($this->bertaRoot . '/elsewhere'))->toBeFalse(); -}); - -describe('stock', function () { - it('returns the stock of each entry with all its variants together', function () { - (new ShopProductsDataService(''))->addProduct('T-shirt S', SHOP_TSHIRT, 3); - - postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR, SHOP_TSHIRT, 'draft0000001']]) - ->assertOk() - ->assertExactJson(['message' => '', 'data' => [ - ['uniqid' => SHOP_CHAIR, 'instock' => 1], - ['uniqid' => SHOP_TSHIRT, 'instock' => 4], - ]]); - }); - - it('puts the reservations of abandoned baskets back in stock', function () { - fillShopBasket([[SHOP_CHAIR]]); - travel(31)->minutes(); - - postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]) - ->assertJsonPath('data.0.instock', 1); - - expect(shopDb()->table('orders')->count())->toBe(0); - }); -}); - -describe('basket', function () { - it('reserves a product at the price of its entry, not the posted one', function () { - $response = asShopCustomer(null) - ->postJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR, 'price' => 0.01, 'weight' => 0]) - ->assertOk() - ->assertJsonPath('data.added', true); - - expect($response->getCookie(ShopBasketService::clientCookie(''), false)->isHttpOnly())->toBeTrue(); - expect(shopDb()->table('orders')->first())->price->toEqual(100)->weight->toEqual(400); - expect(shopDb()->table('product')->where('name', 'Chair')->first())->instock->toEqual(0)->reservation->toEqual(1); - }); - - it('reserves the variant of the chosen cart attribute', function () { - fillShopBasket([[SHOP_TSHIRT, 'M']]); - - expect(shopDb()->table('product')->where('uniqid', SHOP_TSHIRT)->pluck('name')->all())->toBe(['T-shirt M']); - }); - - it('returns 404 for a cart attribute the entry does not have', function () { - addToShopBasket(SHOP_TSHIRT, 'XL')->assertNotFound(); - addToShopBasket(SHOP_CHAIR, 'XL')->assertNotFound(); - }); - - it('returns 404 for an entry without a price', function () { - addToShopBasket('draft0000001')->assertNotFound(); - }); - - it('returns 409 when the variant is out of stock', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - - addToShopBasket(SHOP_CHAIR, null, $token) - ->assertConflict() - ->assertJsonPath('data.added', false); - - expect(shopDb()->table('orders')->count())->toBe(1); - }); - - it('puts a removed unit back in stock', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - - asShopCustomer($token) - ->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR]) - ->assertOk(); - - expect(shopDb()->table('orders')->count())->toBe(0); - expect(shopDb()->table('product')->where('name', 'Chair')->first())->instock->toEqual(1)->reservation->toEqual(0); - }); - - it('keeps the basket for its lifetime from the last product added', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - travel(25)->minutes(); - addToShopBasket(SHOP_TSHIRT, 'S', $token)->assertOk(); - travel(25)->minutes(); - - shopCheckout($token)->assertOk(); - }); - - it('keeps the basket for its lifetime from the last product removed', function () { - $token = fillShopBasket([[SHOP_CHAIR], [SHOP_TSHIRT, 'S']]); - travel(25)->minutes(); - asShopCustomer($token)->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_TSHIRT, 'attribute' => 'S'])->assertOk(); - travel(25)->minutes(); - - shopCheckout($token)->assertOk(); - }); - - it('returns 410 for a product added to an expired basket, then starts a new basket', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - travel(31)->minutes(); - - addToShopBasket(SHOP_TSHIRT, 'S', $token) - ->assertGone() - ->assertJsonPath('data.expired', true); - - expect(shopDb()->table('client')->value('session_id'))->toBe('timeout') - ->and(shopDb()->table('orders')->count())->toBe(0) - ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); - - $newToken = addToShopBasket(SHOP_TSHIRT, 'S', $token) - ->assertOk() - ->getCookie(ShopBasketService::clientCookie(''), false) - ->getValue(); - - expect($newToken)->not->toBe($token); - shopCheckout($newToken)->assertOk(); - }); - - it('returns 410 for a product removed from an expired basket', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - travel(31)->minutes(); - - asShopCustomer($token) - ->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR]) - ->assertGone(); - - expect(shopDb()->table('client')->value('session_id'))->toBe('timeout'); - }); -}); - -describe('basket renewal', function () { - it('keeps the basket for its lifetime from the renewal', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - travel(25)->minutes(); - - asShopCustomer($token) - ->postJson(storefrontUrl('basket/renew')) - ->assertOk() - ->assertJsonPath('data.expires_in', 1800); - - travel(25)->minutes(); - postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]); - - expect(shopDb()->table('orders')->count())->toBe(1); - shopCheckout($token)->assertOk(); - }); - - it('lasts the minutes of the shop setting', function () { - writeShopSettings($this->bertaRoot, ['basketExpiresMinutes' => '10']); - $token = fillShopBasket([[SHOP_CHAIR]]); - - asShopCustomer($token) - ->postJson(storefrontUrl('basket/renew')) - ->assertJsonPath('data.expires_in', 600); - - travel(11)->minutes(); - - asShopCustomer($token)->postJson(storefrontUrl('basket/renew'))->assertGone(); - }); - - it('returns 410 and releases the stock of an expired basket', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - travel(31)->minutes(); - - asShopCustomer($token) - ->postJson(storefrontUrl('basket/renew')) - ->assertGone() - ->assertJsonPath('data.expired', true); - - expect(shopDb()->table('client')->value('session_id'))->toBe('timeout') - ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); - }); - - it('returns 410 without a basket', function () { - asShopCustomer('unknown-token')->postJson(storefrontUrl('basket/renew'))->assertGone(); - }); - - it('returns 410 for an empty basket', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - asShopCustomer($token)->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR])->assertOk(); - - asShopCustomer($token)->postJson(storefrontUrl('basket/renew'))->assertGone(); - }); -}); - -it('keeps a separate basket on each site of a multisite', function () { - setUpSecondShopSite($this->bertaRoot); - - // The cookies a browser keeps for the domain, both sites' baskets among them - $cookies = []; - $remember = function (TestResponse $response) use (&$cookies) { - foreach ($response->headers->getCookies() as $cookie) { - $cookies[$cookie->getName()] = $cookie->getValue(); - } - }; - $remember(withCredentials()->withUnencryptedCookies($cookies)->postJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR])->assertOk()); - $remember(withCredentials()->withUnencryptedCookies($cookies)->postJson(storefrontUrl('basket', ['site' => 'second']), ['uniqid' => SHOP_TSHIRT, 'attribute' => 'S'])->assertOk()); - - shopCheckout($cookies[ShopBasketService::clientCookie('')] ?? '')->assertOk(); - shopCheckout($cookies[ShopBasketService::clientCookie('second')] ?? '', [], 'second')->assertOk(); -}); - -it('numbers the orders of each site of a multisite on their own, and keeps their invoices apart', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - writeShopSettings($this->bertaRoot, invoiceSettings()); - setUpSecondShopSite($this->bertaRoot, invoiceSettings()); - shopCheckout(fillShopBasket([[SHOP_CHAIR]]))->assertOk()->assertJsonPath('data.order_id', '2026-1'); - - shopCheckout(fillShopBasket([[SHOP_CHAIR]], 'second'), [], 'second')->assertOk()->assertJsonPath('data.order_id', '2026-1'); - - expect(shopFiles())->toBe(['invoices/invoice-2026-1.pdf']) - ->and(shopFiles('second'))->toBe(['invoices/invoice-2026-1.pdf']); -}); - -it('takes a product that is no longer for sale out of the basket', function (string $blog) { - $token = fillShopBasket([[SHOP_CHAIR], [SHOP_TSHIRT, 'M']]); - File::put($this->bertaRoot . '/storage/blog.shop.xml', $blog); - - asShopCustomer($token) - ->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR]) - ->assertOk(); - - expect(shopDb()->table('orders')->join('product', 'product.id', '=', 'orders.id_product')->pluck('product.name')->all())->toBe(['T-shirt M']) - ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); -})->with([ - 'without a price' => '', - 'deleted' => '', -]); - -describe('regional costs', function () { - it('returns the shipping price of the price tier above the basket weight', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - - asShopCustomer($token) - ->getJson(storefrontUrl('regional-costs', ['region' => 1])) - ->assertOk() - ->assertJson(['data' => ['shipping' => 5, 'vat' => 21]]); - }); - - it('returns the heaviest tier price for a basket heavier than all tiers', function () { - (new ShopProductsDataService(''))->addProduct('Chair', SHOP_CHAIR, 10); - $token = fillShopBasket(array_fill(0, 6, [SHOP_CHAIR])); - - asShopCustomer($token) - ->getJson(storefrontUrl('regional-costs', ['region' => 1])) - ->assertJson(['data' => ['shipping' => 10]]); - }); - - it('returns a decimal VAT rate', function () { - (new ShopShippingRegionsDataService(''))->saveValueByPath('1/vat', '25,5'); - - getJson(storefrontUrl('regional-costs', ['region' => 1]))->assertJsonPath('data.vat', 25.5); - }); - - it('returns 404 for an unknown region', function () { - getJson(storefrontUrl('regional-costs', ['region' => 99]))->assertNotFound(); - }); -}); - -describe('promo code', function () { - it('returns the discount of the shop promo code', function () { - getJson(storefrontUrl('promo-code', ['code' => 'SAVE10']))->assertJsonPath('data.discount', 10); - }); - - it('returns no discount for a wrong code', function () { - getJson(storefrontUrl('promo-code', ['code' => 'SAVE20']))->assertJsonPath('data.discount', null); - }); -}); - -describe('bank transfer checkout', function () { - it('saves the order, then emails it to the seller and the customer', function () { - Mail::fake(); - $token = fillShopBasket([[SHOP_CHAIR], [SHOP_TSHIRT, 'M']]); - - $response = shopCheckout($token, ['promo_code' => 'SAVE10']) - ->assertOk() - ->assertJsonPath('data.status', 'placed'); - - // 125 - 10% + 10 shipping for 600 g - expect(shopDb()->table('client')->where('session_id', 'checkout')->first()) - ->name->toBe('Ann Buyer') - ->order_id->toBe($response->json('data.order_id')) - ->price_total->toEqual(122.5) - ->shipping->toEqual(10) - ->vat->toEqual(21) - ->paypal->toEqual(0) - ->payment_method->toBe('bank_transfer') - ->status->toBe('awaiting_payment'); - expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(2) - ->and(shopDb()->table('product')->sum('reservation'))->toEqual(0) - ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue(); - - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller - && $mail->hasTo('seller@berta.test') - && $mail->totals['vat'] === 21.26 - && $mail->totals['net'] === 101.24); - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller && $mail->hasTo('ann@buyer.test')); - }); - - it('numbers the orders of each year from 1, also orders placed in the same second', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-12-31 23:59:00')); - $firstToken = fillShopBasket([[SHOP_CHAIR]]); - $secondToken = fillShopBasket([[SHOP_TSHIRT, 'S']]); - $numbers = [ - shopCheckout($firstToken)->assertOk()->json('data.order_id'), - shopCheckout($secondToken)->assertOk()->json('data.order_id'), - ]; - travelTo(Carbon::parse('2027-01-01 00:01:00')); - - $numbers[] = shopCheckout(fillShopBasket([[SHOP_TSHIRT, 'M']]))->assertOk()->json('data.order_id'); - - expect($numbers)->toBe(['2026-1', '2026-2', '2027-1']) - ->and(shopDb()->table('client')->where('session_id', 'checkout')->orderBy('id')->pluck('order_id')->all())->toBe($numbers); - }); - - it('dates an order when it is placed, not when its basket was last renewed', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - $token = fillShopBasket([[SHOP_CHAIR]]); - travelTo(Carbon::parse('2026-10-12 10:20:00')); - - shopCheckout($token)->assertOk(); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->value('created_at'))->toBe('2026-10-12 10:20:00'); - }); - - it('saves the VAT number of a legal person only, and emails it with the company details', function (bool $isLegalPerson, ?string $savedVatNumber) { - Mail::fake(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, [ - 'legal_person' => $isLegalPerson, - 'company' => 'Riga Prints SIA', - 'company_reg_no' => '40003000000', - 'vat_no' => 'LV40003000000', - 'legal_address' => 'Brivibas 2, Riga', - ])->assertOk(); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->value('vat_no'))->toBe($savedVatNumber); - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller - && str_contains($mail->render(), 'Reg. no. 40003000000
          VAT no. LV40003000000
          ') === $isLegalPerson); - })->with([ - 'a legal person' => [true, 'LV40003000000'], - 'a private person' => [false, null], - ]); - - it('takes a legal person without a VAT number', function () { - Mail::fake(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['legal_person' => true, 'company' => 'Riga Prints SIA', 'company_reg_no' => '40003000000', 'legal_address' => 'Brivibas 2, Riga']) - ->assertOk(); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->first()) - ->company->toBe('Riga Prints SIA') - ->vat_no->toBeNull(); - }); - - it('thanks the customer for shopping with the site the page title names', function (array $texts, string $thanks) { - Mail::fake(); - writeShopSettings($this->bertaRoot, [], '', $texts); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token)->assertOk(); - - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller - && str_contains(shopMailText($mail), $thanks)); - })->with([ - 'with a page title' => [['pageTitle' => 'Riga Prints'], 'Thanks for shopping with Riga Prints!'], - 'without one' => [[], 'Thanks for shopping with us!'], - ]); - - it('asks the customer to pay to the seller details and tells the seller the order is not paid yet', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, ['sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]); - $token = fillShopBasket([[SHOP_CHAIR]]); - - $orderId = shopCheckout($token)->assertOk()->json('data.order_id'); - - // 100 + 5 shipping - Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($orderId) { - $html = $mail->render(); - $text = shopMailText($mail); - - return ! $mail->forSeller - && $mail->hasSubject('Your order') - && str_contains($text, 'To complete it, please pay by bank transfer.') - && str_contains($html, "Transfer 105.00 EUR to the account below, with the payment reference {$orderId}, so we can match your payment.") - && str_contains($text, "Amount 105.00 EUR Reference {$orderId}") - && substr_count($html, 'IBAN LV80BANK0000435195001') === 1 - && str_contains($html, 'Riga Prints SIA
          ') - && str_contains($text, 'PAYMENT Bank transfer, awaiting payment') - && ! str_contains($html, 'PayPal transaction') - && ! str_contains($html, 'being processed'); - }); - Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($orderId) { - $html = $mail->render(); - - return $mail->forSeller - && $mail->hasSubject("New order {$orderId}: awaiting bank transfer") - && str_contains($html, 'No payment has been received yet.') - && str_contains($html, "Wait for 105.00 EUR to arrive in your bank account, with the payment reference {$orderId}.") - && str_contains($html, 'IBAN LV80BANK0000435195001') - && ! str_contains($html, 'did not get bank details'); - }); - }); - - it('tells the customer the bank details follow and the seller to send them when the shop has none', function () { - Mail::fake(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token)->assertOk(); - - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller - && str_contains($mail->render(), "We'll send you the bank details for the transfer shortly")); - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller - && str_contains($mail->render(), 'the customer did not get bank details. Send them yours.')); - }); - - it('sends a plain text part with the same words as the html one', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, ['sellerDetails' => 'Riga Prints SIA, IBAN LV80BANK0000435195001']); - $token = fillShopBasket([[SHOP_CHAIR]]); - - $orderId = shopCheckout($token, ['name' => "Ann & O'Brien"])->assertOk()->json('data.order_id'); - - Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($orderId) { - if ($mail->forSeller) { - $mail->assertSeeInText("Ann & O'Brien placed an order of 105.00 EUR and chose to pay by bank transfer.") - ->assertSeeInText('Total: 105.00 EUR'); - } else { - $mail->assertSeeInText("Transfer 105.00 EUR to the account below, with the payment reference {$orderId}, so we can match your payment.") - ->assertSeeInText('IBAN LV80BANK0000435195001'); - } - $mail->assertDontSeeInText(''); - - return true; - }); - }); - - it('words the order number so spam filters do not take it for a hash', function (array $shop) { - Mail::fake(); - writeShopSettings($this->bertaRoot, $shop); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token)->assertOk(); - - Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) { - expect(shopMailHexHashWords($mail))->toBe([]); - - return true; - }); - })->with([ - 'with seller details' => [['sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]], - 'without seller details' => [[]], - ]); - - it('takes off at most the price of the products for a promo discount over 100%', function () { - writeShopSettings($this->bertaRoot, ['promoCodeDiscount' => '150']); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['promo_code' => 'SAVE10'])->assertOk(); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->value('price_total'))->toEqual(5); - }); - - it('emails the VAT and the total excluding it at a decimal VAT rate', function () { - Mail::fake(); - (new ShopShippingRegionsDataService(''))->saveValueByPath('1/vat', '25.5'); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token)->assertOk(); - - // 100 + 5 shipping, of which 25.5% VAT - expect(shopDb()->table('client')->where('session_id', 'checkout')->value('vat'))->toEqual(25.5); - Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) { - $text = shopMailText($mail); - - return $mail->totals['vat_percent'] === 25.5 - && str_contains($text, 'Total excl. VAT 83.67') - && str_contains($text, 'VAT (25.5%) 21.33'); - }); - }); - - it('returns 500 and neither saves nor emails an order that fails to save', function () { - Mail::fake(); - Exceptions::fake(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopDb()->statement("CREATE TRIGGER fail_checkout BEFORE UPDATE OF session_id ON client WHEN NEW.session_id = 'checkout' BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END"); - - shopCheckout($token)->assertInternalServerError(); - - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() - ->and(shopDb()->table('orders')->where('paid', 1)->count())->toBe(0); - Mail::assertNothingSent(); - }); - - it('gives the number of an order that fails to save to the next order', function () { - Mail::fake(); - Exceptions::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - $failingToken = fillShopBasket([[SHOP_CHAIR]]); - $token = fillShopBasket([[SHOP_TSHIRT, 'S']]); - shopDb()->statement("CREATE TRIGGER fail_checkout BEFORE UPDATE OF session_id ON client WHEN NEW.session_id = 'checkout' BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END"); - shopCheckout($failingToken)->assertInternalServerError(); - shopDb()->statement('DROP TRIGGER fail_checkout'); - - shopCheckout($token)->assertOk()->assertJsonPath('data.order_id', '2026-1'); - }); - - it('places the order when sending its emails fails', function () { - Exceptions::fake(); - config(['plugin-Shop.mailer.transport' => 'smtp', 'plugin-Shop.mailer.host' => '127.0.0.1', 'plugin-Shop.mailer.port' => 1]); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token) - ->assertOk() - ->assertJsonPath('data.status', 'placed'); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeTrue(); - Exceptions::assertReported(TransportException::class); - }); - - it('returns 410 and releases the stock of an expired basket', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - travel(31)->minutes(); - - shopCheckout($token)->assertGone()->assertJsonPath('data.expired', true); - - expect(shopDb()->table('client')->value('session_id'))->toBe('timeout') - ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); - }); - - it('returns 410 without a basket', function () { - shopCheckout('unknown-token')->assertGone(); - }); - - it('starts a new basket for a cookie holding the placed order marker, leaving the orders as they were', function (int $minutes) { - Mail::fake(); - shopCheckout(fillShopBasket([[SHOP_CHAIR]]))->assertOk(); - $order = shopDb()->table('client')->where('session_id', 'checkout')->first(); - travel($minutes)->minutes(); - - $token = addToShopBasket(SHOP_TSHIRT, 'S', 'checkout') - ->assertOk() - ->getCookie(ShopBasketService::clientCookie(''), false) - ->getValue(); - - expect($token)->not->toBe('checkout')->toHaveLength(32); - expect(shopDb()->table('client')->find($order->id)) - ->session_id->toBe('checkout') - ->name->toBe('Ann Buyer'); - expect(shopDb()->table('orders')->where('id_client', $order->id)->count())->toBe(1); - })->with([ - 'within the basket lifetime' => 0, - 'after it' => 31, - ]); - - it('rejects a payment method the shop does not accept', function () { - writeShopSettings($this->bertaRoot, ['paymentMethod' => 'paypal', ...nvpSettings()]); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token) - ->assertUnprocessable() - ->assertJsonValidationErrors(['payment_method' => 'The shop does not accept this payment method.']); - }); - - it('requires the shipping address unless delivering to the billing address', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['deliver_to_billing' => false]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['ship_name', 'ship_phone', 'ship_address']); - }); - - it('requires the company details of a legal person', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['legal_person' => true]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['company', 'company_reg_no', 'legal_address']); - }); - - it('requires a shipping region', function () { - Mail::fake(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['region' => '']) - ->assertUnprocessable() - ->assertJsonValidationErrors(['region' => 'The region field is required.']); - - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); - Mail::assertNothingSent(); - }); - - it('places a basket checked out twice at once only once', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - $token = fillShopBasket([[SHOP_CHAIR]]); - $basket = new ShopBasketService(''); - $checkout = new ShopCheckoutService('', $basket); - // The second checkout, e.g. from another tab, found the basket open before the first one placed it - $client = $basket->findClient($token); - $totals = $checkout->totals($client, 1, ''); - shopCheckout($token)->assertOk()->assertJsonPath('data.order_id', '2026-1'); - - $orderId = $checkout->placeManualOrder($client, $checkout->order(['payment_method' => 'bank_transfer']), $totals); - - expect($orderId)->toBeNull(); - expect(shopDb()->table('client')->where('session_id', 'checkout')->pluck('order_id')->all())->toBe(['2026-1']); - expect(shopDb()->table('order_numbers')->value('last_number'))->toEqual(1); - Mail::assertSent(ShopOrderMail::class, 2); - }); -}); - -describe('PayPal checkout', function () { - it('rejects PayPal when the shop has no PayPal credentials', function () { - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal'])->assertUnprocessable(); - }); - - it('starts an NVP payment for the server-side total without saving or emailing the order', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal', 'promo_code' => 'SAVE10']) - ->assertOk() - ->assertJsonPath('data.status', 'redirect') - ->assertJsonPath('data.redirect_url', 'https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&useraction=commit&token=' . SHOP_NVP_TOKEN); - - Http::assertSent(fn (HttpRequest $request) => $request['METHOD'] === 'SetExpressCheckout' - && $request['PAYMENTREQUEST_0_DESC'] === 'Order' - && $request['PAYMENTREQUEST_0_AMT'] === '95.00' - && $request['PAYMENTREQUEST_0_ITEMAMT'] === '90.00' - && $request['L_PAYMENTREQUEST_0_AMT1'] === '-10.00' - && (string) $request['NOSHIPPING'] === '1' - && $request['EMAIL'] === 'ann@buyer.test' - && ! isset($request['SOLUTIONTYPE']) - && ! isset($request['LANDINGPAGE'])); - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() - ->and(shopDb()->table('orders')->where('paid', 1)->count())->toBe(0); - Mail::assertNothingSent(); - }); - - it("starts an NVP card payment on PayPal's guest checkout", function () { - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'creditcard']) - ->assertOk() - ->assertJsonPath('data.status', 'redirect'); - - Http::assertSent(fn (HttpRequest $request) => $request['METHOD'] === 'SetExpressCheckout' - && $request['SOLUTIONTYPE'] === 'Sole' - && $request['LANDINGPAGE'] === 'Billing' - && $request['EMAIL'] === 'ann@buyer.test'); - }); - - it('charges in the PayPal currency in capitals, and in US dollars when it is empty', function (string $setting, string $currency) { - writeShopSettings($this->bertaRoot, [...restSettings(), 'paypalCurrencyCode' => $setting]); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') - && $request['purchase_units'][0]['amount']['currency_code'] === $currency); - })->with([ - 'typed in lowercase' => ['eur', 'EUR'], - 'left empty' => ['', 'USD'], - ]); - - it('starts a REST payment on the guest checkout page for card payments', function () { - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'creditcard']) - ->assertOk() - ->assertJsonPath('data.redirect_url', 'https://www.paypal.com/checkoutnow?token=' . SHOP_REST_TOKEN) - ->assertJsonPath('data.token', SHOP_REST_TOKEN); - - Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') - && $request['purchase_units'][0]['amount']['value'] === '105.00' - && $request['payment_source']['paypal']['experience_context']['landing_page'] === 'GUEST_CHECKOUT' - && $request['payment_source']['paypal']['experience_context']['shipping_preference'] === 'NO_SHIPPING' - && $request['payment_source']['paypal']['email_address'] === 'ann@buyer.test' - && $request['payment_source']['paypal']['name'] === ['given_name' => 'Ann', 'surname' => 'Buyer']); - }); - - it('starts a PayPal order for the buttons without a redirect to PayPal or an order number', function () { - writeShopSettings($this->bertaRoot, restSettings(), '', ['pageTitle' => 'Riga Prints']); - // An order without a payment source waits for approval in PayPal's buttons - fakeRest(order: [ - 'id' => SHOP_REST_TOKEN, - 'status' => 'CREATED', - 'links' => [ - ['rel' => 'self', 'href' => 'https://api-m.paypal.com/v2/checkout/orders/' . SHOP_REST_TOKEN], - ['rel' => 'approve', 'href' => 'https://www.paypal.com/checkoutnow?token=' . SHOP_REST_TOKEN], - ['rel' => 'update', 'href' => 'https://api-m.paypal.com/v2/checkout/orders/' . SHOP_REST_TOKEN], - ['rel' => 'capture', 'href' => 'https://api-m.paypal.com/v2/checkout/orders/' . SHOP_REST_TOKEN . '/capture'], - ], - ]); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'creditcard', 'paypal_buttons' => true]) - ->assertOk() - ->assertJsonPath('data.token', SHOP_REST_TOKEN) - ->assertJsonMissingPath('data.order_id'); - - // The buttons choose the PayPal login or the card form, PayPal only skips the shipping address - Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') - && $request['purchase_units'][0]['description'] === 'Order from Riga Prints' - && ! isset($request['purchase_units'][0]['custom_id']) - && $request['payment_source']['paypal']['experience_context']['shipping_preference'] === 'NO_SHIPPING' - && ! isset($request['payment_source']['paypal']['experience_context']['landing_page']) - && $request['payment_source']['paypal']['email_address'] === 'ann@buyer.test' - && $request['payment_source']['paypal']['name'] === ['given_name' => 'Ann', 'surname' => 'Buyer']); - }); - - it('fills in the name and email PayPal accepts', function (string $name, string $email, ?array $paypalName, ?string $paypalEmail) { - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal', 'paypal_buttons' => true, 'name' => $name, 'email' => $email])->assertOk(); - - Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') - && ($request['payment_source']['paypal']['name'] ?? null) === $paypalName - && ($request['payment_source']['paypal']['email_address'] ?? null) === $paypalEmail); - })->with([ - 'a first name and surname' => ['Ann Marie Buyer', 'ann@buyer.test', ['given_name' => 'Ann Marie', 'surname' => 'Buyer'], 'ann@buyer.test'], - 'a name and surname split by a comma' => ['Ann, Buyer', 'ann@buyer.test', ['given_name' => 'Ann', 'surname' => 'Buyer'], 'ann@buyer.test'], - 'a single name' => ['Ann', 'ann@buyer.test', null, 'ann@buyer.test'], - 'an email without a domain name' => ['Ann Buyer', 'ann@localhost', ['given_name' => 'Ann', 'surname' => 'Buyer'], null], - ]); - - it('returns 500 and saves nothing when PayPal gives a redirect no approval page', function () { - Exceptions::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(order: ['id' => SHOP_REST_TOKEN, 'status' => 'CREATED', 'links' => []]); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal'])->assertInternalServerError(); - - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); - Exceptions::assertReported(fn (RuntimeException $e) => $e->getMessage() === 'PayPal order has no approval link'); - }); - - it('uses REST when the shop has both REST and NVP credentials', function () { - writeShopSettings($this->bertaRoot, [...nvpSettings(), ...restSettings()]); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - Http::assertNotSent(fn (HttpRequest $request) => str_contains($request->url(), 'api-3t.paypal.com')); - }); - - it('keeps the reservations for the basket lifetime from the start of the payment', function () { - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - travel(25)->minutes(); - - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - travel(25)->minutes(); - postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]); - - expect(shopDb()->table('orders')->count())->toBe(1); - }); -}); - -describe('PayPal return', function () { - it('saves and emails an order once PayPal takes the payment', function (string $api) { - Mail::fake(); - writeShopSettings($this->bertaRoot, $api === 'nvp' ? nvpSettings() : restSettings()); - $api === 'nvp' ? fakeNvp() : fakeRest(); - $paypalToken = $api === 'nvp' ? SHOP_NVP_TOKEN : SHOP_REST_TOKEN; - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - $response = returnFromPaypal($paypalToken); - - $order = shopDb()->table('client')->where('session_id', 'checkout')->first(); - $response->assertRedirect(paidReturnUrl($order->order_id)); - expect($order)->paypal->toEqual(1)->status->toBe('paid')->price_total->toEqual(105); - expect(File::json(shopFile("paypal/{$paypalToken}.json")))->order_id->toBe($order->order_id); - expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(1) - ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue() - ->and($response->getCookie('shopping_cart', false)->isCleared())->toBeTrue(); - Mail::assertSent(ShopOrderMail::class, 2); - })->with(['nvp', 'rest']); - - it('tells the customer the order is paid and the seller to ship it', function (string $method, string $title, string $paidWith) { - Mail::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => $method])->assertOk(); - - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->first()) - ->payment_method->toBe($method) - ->payment_reference->toBe('3C679366HH908993F'); - Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($title, $paidWith) { - $html = $mail->render(); - - return ! $mail->forSeller - && $mail->hasSubject('Your order') - && str_contains($html, "We've received your payment of 105.00 EUR {$paidWith} and confirmed your order {$mail->order['order_id']}.") - && str_contains(shopMailText($mail), "PAYMENT {$title}, paid") - && str_contains(shopMailText($mail), 'PayPal transaction 3C679366HH908993F') - && ! str_contains($html, 'bank transfer') - && shopMailHexHashWords($mail) === []; - }); - Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($paidWith) { - $html = $mail->render(); - - return $mail->forSeller - && $mail->hasSubject("New order {$mail->order['order_id']}: paid {$paidWith}") - && str_contains($html, "Ann Buyer paid 105.00 EUR {$paidWith}.") - && str_contains($html, 'ship the order to the address below') - && str_contains(shopMailText($mail), 'PayPal transaction 3C679366HH908993F') - && ! str_contains($html, '(paypal)') - && shopMailHexHashWords($mail) === []; - }); - })->with([ - 'PayPal' => ['paypal', 'PayPal', 'with PayPal'], - 'card' => ['creditcard', 'Debit or credit card', 'by debit or credit card'], - ]); - - it('saves nothing when PayPal declines the payment', function (string $api) { - Mail::fake(); - writeShopSettings($this->bertaRoot, $api === 'nvp' ? nvpSettings() : restSettings()); - $api === 'nvp' - ? fakeNvp('Failure') - : fakeRest(fn () => Http::response(['name' => 'UNPROCESSABLE_ENTITY', 'details' => [['issue' => 'INSTRUMENT_DECLINED']]], 422)); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - returnFromPaypal($api === 'nvp' ? SHOP_NVP_TOKEN : SHOP_REST_TOKEN) - ->assertRedirect('http://shop.test/?shop_payment=failed'); - - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); - expect(shopFiles())->toBe([]); - Mail::assertNothingSent(); - })->with(['nvp', 'rest']); - - it('numbers a PayPal order once it is paid, so an unpaid payment takes no number', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - shopCheckout(fillShopBasket([[SHOP_CHAIR]]), ['payment_method' => 'paypal'])->assertOk(); - shopCheckout(fillShopBasket([[SHOP_TSHIRT, 'S']]))->assertOk()->assertJsonPath('data.order_id', '2026-1'); - - returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(2))); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->pluck('order_id', 'payment_method')->all()) - ->toBe(['paypal' => '2026-2', 'bank_transfer' => '2026-1']); - }); - - it('leaves products added after the payment started unpaid', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - addToShopBasket(SHOP_TSHIRT, 'S', $token)->assertOk(); - - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - expect(shopDb()->table('orders')->join('product', 'product.id', '=', 'orders.id_product')->pluck('orders.paid', 'product.name')->all()) - ->toEqual(['Chair' => 1, 'T-shirt S' => 0]); - }); - - it('reserves the products again when their reservation expired while the customer was at PayPal', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - travel(31)->minutes(); - postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]])->assertJsonPath('data.0.instock', 1); - - returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(1) - ->and(shopDb()->table('product')->where('uniqid', SHOP_CHAIR)->value('instock'))->toEqual(0); - }); - - it('takes no payment for products sold while the customer was at PayPal', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - travel(31)->minutes(); - addToShopBasket(SHOP_CHAIR)->assertOk(); - - returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect('http://shop.test/?shop_payment=failed'); - - expect(capturesSent())->toBe(0) - ->and(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeFalse() - ->and(shopDb()->table('orders')->count())->toBe(1); - Mail::assertNothingSent(); - }); - - it('captures nothing for a payment it did not start', function () { - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - - returnFromPaypal('EC-UNKNOWN')->assertRedirect('http://shop.test/?shop_payment=failed'); - - expect(capturesSent())->toBe(0); - }); - - it('answers a repeated return with the first outcome without capturing again', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - expect(capturesSent())->toBe(1); - Mail::assertSent(ShopOrderMail::class, 2); - }); - - it('reports an unconfirmed payment when PayPal does not answer, and asks again on the next return', function () { - Mail::fake(); - Exceptions::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - $attempts = 0; - fakeRest(function () use (&$attempts) { - return $attempts++ === 0 ? Http::failedConnection() : Http::response(paidRestOrder()); - }); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=unconfirmed'); - expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeFalse(); - - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeTrue(); - Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/capture') - && $request->header('PayPal-Request-Id') === ['capture-' . SHOP_REST_TOKEN]); - }); - - it('takes the payment on a return after an unconfirmed one, also for products reserved again', function () { - Mail::fake(); - Exceptions::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - $attempts = 0; - fakeRest(function () use (&$attempts) { - return $attempts++ === 0 ? Http::failedConnection() : Http::response(paidRestOrder()); - }); - $token = fillShopBasket([[SHOP_CHAIR]]); - // Another customer's order, so the chair's reservation gets a new id when it's reserved again - shopCheckout(fillShopBasket([[SHOP_TSHIRT, 'S']]))->assertOk(); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - // The chair's reservation expires while the customer is at PayPal, the first return reserves it again - travel(31)->minutes(); - postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]])->assertJsonPath('data.0.instock', 1); - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=unconfirmed'); - - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(2))); - - expect(capturesSent())->toBe(2); - expect(shopDb()->table('orders')->join('product', 'product.id', '=', 'orders.id_product')->pluck('orders.paid', 'product.name')->all()) - ->toEqual(['T-shirt S' => 1, 'Chair' => 1]); - expect(shopDb()->table('product')->where('uniqid', SHOP_CHAIR)->value('instock'))->toEqual(0); - }); - - it('returns 409 for a bank transfer checkout of a basket while PayPal takes its payment', function () { - Mail::fake(); - Sleep::fake(syncWithCarbon: true); - writeShopSettings($this->bertaRoot, restSettings()); - $token = fillShopBasket([[SHOP_CHAIR]]); - $bankCheckout = null; - fakeRest(function () use (&$bankCheckout, $token) { - // The customer places the basket by bank transfer in another tab meanwhile - $bankCheckout = shopCheckout($token); - - return Http::response(paidRestOrder()); - }); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - $bankCheckout->assertConflict(); - expect(shopDb()->table('client')->where('session_id', 'checkout')->pluck('payment_method')->all())->toBe(['paypal']); - expect(shopDb()->table('order_numbers')->value('last_number'))->toEqual(1); - Mail::assertSent(ShopOrderMail::class, 2); - }); - - it('emails a paid order that fails to save under its number', function () { - Mail::fake(); - Exceptions::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - shopDb()->statement("CREATE TRIGGER fail_checkout BEFORE UPDATE OF session_id ON client WHEN NEW.session_id = 'checkout' BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END"); - - returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->hasTo('seller@berta.test') - && $mail->order['order_id'] === '2026-1'); - Exceptions::assertReported(fn (QueryException $e) => str_contains($e->getMessage(), 'disk I/O error')); - }); - - it('tells the customer the payment went through when the email fails', function () { - Exceptions::fake(); - config(['plugin-Shop.mailer.transport' => 'smtp', 'plugin-Shop.mailer.host' => '127.0.0.1', 'plugin-Shop.mailer.port' => 1]); - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeTrue(); - Exceptions::assertReported(TransportException::class); - }); - - it('keeps the basket when the customer cancels at PayPal', function () { - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - get(storefrontUrl('paypal/cancel', ['token' => SHOP_NVP_TOKEN])) - ->assertRedirect('http://shop.test/?shop_payment=cancelled'); - - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() - ->and(capturesSent())->toBe(0); - }); - - it('sends the customer back to the page they checked out from with the paid order number', function () { - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal', 'return_path' => '/shop/?tag=chairs'])->assertOk(); - - returnFromPaypal(SHOP_NVP_TOKEN) - ->assertRedirect('http://shop.test/shop/?tag=chairs&shop_payment=paid&shop_order=' . orderNumber(1)); - }); - - it('sends the customer who cancels back to the page they checked out from, on the site', function (string $returnPath, string $returnUrl) { - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal', 'return_path' => $returnPath])->assertOk(); - - get(storefrontUrl('paypal/cancel', ['token' => SHOP_NVP_TOKEN]))->assertRedirect($returnUrl); - })->with([ - 'a section page' => ['/shop/?tag=chairs', 'http://shop.test/shop/?tag=chairs&shop_payment=cancelled'], - 'a path that looks like another host' => ['//evil.test/', 'http://shop.test//evil.test/?shop_payment=cancelled'], - ]); - - it('refuses a return page that is not a path on the site', function (string $returnPath) { - writeShopSettings($this->bertaRoot, nvpSettings()); - fakeNvp(); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal', 'return_path' => $returnPath]) - ->assertUnprocessable() - ->assertJsonValidationErrors('return_path'); - })->with([ - 'another host' => 'https://evil.test/', - 'a userinfo' => '@evil.test/', - 'a header after a line break' => "/shop/\r\nX-Injected: 1", - 'a fragment' => '/shop/#top', - 'a backslash' => '/\evil.test/', - ]); -}); - -describe('PayPal buttons', function () { - it('saves and emails an order paid with the buttons, and returns its number', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - $response = asShopCustomer($token) - ->postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN]) - ->assertOk() - ->assertJsonPath('data.status', 'paid') - ->assertJsonPath('data.order_id', '2026-1'); - - expect(shopDb()->table('client')->where('session_id', 'checkout')->value('order_id'))->toBe('2026-1') - ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue() - ->and($response->getCookie('shopping_cart', false)->isCleared())->toBeTrue(); - Mail::assertSent(ShopOrderMail::class, 2); - }); - - it('saves nothing when PayPal declines a payment from the buttons', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(fn () => Http::response(['name' => 'UNPROCESSABLE_ENTITY', 'details' => [['issue' => 'INSTRUMENT_DECLINED']]], 422)); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'creditcard'])->assertOk(); - - asShopCustomer($token) - ->postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN]) - ->assertOk() - ->assertJsonPath('data.status', 'failed') - ->assertJsonPath('data.order_id', null); - - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); - Mail::assertNothingSent(); - }); - - it('answers a repeated capture with the first outcome and number without capturing again', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN])->assertJsonPath('data.status', 'paid'); - - postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN]) - ->assertJsonPath('data.status', 'paid') - ->assertJsonPath('data.order_id', '2026-1'); - - expect(capturesSent())->toBe(1); - }); - - it('takes no second payment for a basket already paid', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - $order = fn (string $id) => ['id' => $id, 'status' => 'CREATED', 'links' => [['rel' => 'approve', 'href' => "https://www.paypal.com/checkoutnow?token={$id}"]]]; - Http::fake([ - 'api-m.paypal.com/v1/oauth2/token' => Http::response(['access_token' => 'A21AA', 'expires_in' => 32400]), - 'api-m.paypal.com/v2/checkout/orders' => Http::sequence()->push($order('CARDORDER1'))->push($order('PAYPALORDER1')), - 'api-m.paypal.com/v2/checkout/orders/*/capture' => Http::response(paidRestOrder()), - ]); - $token = fillShopBasket([[SHOP_CHAIR]]); - // The card form opened, then the customer switched to PayPal's popup - shopCheckout($token, ['payment_method' => 'creditcard', 'paypal_buttons' => true])->assertJsonPath('data.token', 'CARDORDER1'); - shopCheckout($token, ['payment_method' => 'paypal', 'paypal_buttons' => true])->assertJsonPath('data.token', 'PAYPALORDER1'); - postJson(storefrontUrl('paypal/capture'), ['token' => 'PAYPALORDER1'])->assertJsonPath('data.status', 'paid'); - - postJson(storefrontUrl('paypal/capture'), ['token' => 'CARDORDER1'])->assertJsonPath('data.status', 'failed'); - - expect(capturesSent())->toBe(1) - ->and(shopDb()->table('client')->where('session_id', 'checkout')->count())->toBe(1); - Mail::assertSent(ShopOrderMail::class, 2); - }); - - it('takes no second payment for a basket approved twice at once', function () { - Mail::fake(); - Sleep::fake(syncWithCarbon: true); - writeShopSettings($this->bertaRoot, restSettings()); - $order = fn (string $id) => ['id' => $id, 'status' => 'CREATED', 'links' => [['rel' => 'approve', 'href' => "https://www.paypal.com/checkoutnow?token={$id}"]]]; - $cardCapture = null; - Http::fake([ - 'api-m.paypal.com/v1/oauth2/token' => Http::response(['access_token' => 'A21AA', 'expires_in' => 32400]), - 'api-m.paypal.com/v2/checkout/orders' => Http::sequence()->push($order('CARDORDER1'))->push($order('PAYPALORDER1')), - 'api-m.paypal.com/v2/checkout/orders/*/capture' => function (HttpRequest $request) use (&$cardCapture) { - if (str_contains($request->url(), 'PAYPALORDER1')) { - // The customer approves the card form's payment while PayPal takes the popup's - $cardCapture = postJson(storefrontUrl('paypal/capture'), ['token' => 'CARDORDER1']); - } - - return Http::response(paidRestOrder()); - }, - ]); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'creditcard', 'paypal_buttons' => true])->assertJsonPath('data.token', 'CARDORDER1'); - shopCheckout($token, ['payment_method' => 'paypal', 'paypal_buttons' => true])->assertJsonPath('data.token', 'PAYPALORDER1'); - - postJson(storefrontUrl('paypal/capture'), ['token' => 'PAYPALORDER1'])->assertJsonPath('data.status', 'paid'); - - $cardCapture->assertJsonPath('data.status', 'unconfirmed'); - // Approved again, the card payment finds the basket paid - postJson(storefrontUrl('paypal/capture'), ['token' => 'CARDORDER1'])->assertJsonPath('data.status', 'failed'); - expect(capturesSent())->toBe(1); - }); - - it('captures nothing for a malformed token', function () { - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - - postJson(storefrontUrl('paypal/capture'), ['token' => '../' . SHOP_REST_TOKEN]) - ->assertOk() - ->assertJsonPath('data.status', 'failed'); - - expect(capturesSent())->toBe(0); - }); -}); - -describe('invoices', function () { - it('attaches the invoice of a bank transfer order to the customer email only, and keeps it', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - writeShopSettings($this->bertaRoot, invoiceSettings()); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token)->assertOk(); - - $invoice = File::get(shopFile('invoices/invoice-2026-1.pdf')); - expect($invoice)->toStartWith('%PDF-'); - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller - && $mail->hasAttachment(Attachment::fromData(fn () => $invoice, 'invoice-2026-1.pdf')->withMime('application/pdf')) - && str_contains(shopMailText($mail), 'Your invoice is attached.') - && shopMailHexHashWords($mail) === []); - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller - && $mail->attachments() === [] - && ! str_contains(shopMailText($mail), 'Your invoice is attached.')); - }); - - it('attaches the invoice of a PayPal order once PayPal takes the payment', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - writeShopSettings($this->bertaRoot, [...restSettings(), ...invoiceSettings()]); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - - $invoice = File::get(shopFile('invoices/invoice-2026-1.pdf')); - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller - && $mail->hasAttachment(Attachment::fromData(fn () => $invoice, 'invoice-2026-1.pdf')->withMime('application/pdf'))); - }); - - it('makes no invoice when invoices are off or the shop has no seller details', function (array $shop) { - Mail::fake(); - writeShopSettings($this->bertaRoot, $shop); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token)->assertOk(); - - expect(shopFiles())->toBe([]); - Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller - && $mail->attachments() === [] - && ! str_contains(shopMailText($mail), 'Your invoice is attached.')); - })->with([ - 'invoices off' => [['sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]], - 'no seller details' => [['invoices' => 'yes', 'sellerDetails' => ' ']], - ]); - - it('keeps the invoice of an order when the shop sends no emails', function () { - Mail::fake(); - travelTo(Carbon::parse('2026-10-12 10:00:00')); - File::delete($this->bertaRoot . '/engine/hosting'); - writeShopSettings($this->bertaRoot, invoiceSettings()); - $token = fillShopBasket([[SHOP_CHAIR]]); - - shopCheckout($token)->assertOk(); - - expect(shopFiles())->toBe(['invoices/invoice-2026-1.pdf']); - Mail::assertNothingSent(); - }); -}); - -it('lists the products of priced entries for the shop admin', function () { - actingAs(new User); - - getJson('http://shop.test' . route('products', [], false))->assertOk(); - - expect(shopDb()->table('product')->pluck('name')->sort()->values()->all())->toBe(['Chair', 'T-shirt M', 'T-shirt S']); -}); - -it('lists only the paid products of an order for the shop admin', function () { - Mail::fake(); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR]]); - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - addToShopBasket(SHOP_TSHIRT, 'S', $token)->assertOk(); - returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); - actingAs(new User); - - $orders = getJson('http://shop.test' . route('orders', [], false))->assertOk()->json('data')['']['orders']; - - expect($orders)->toHaveCount(1) - ->and(array_column($orders[0]['orders'], 'name'))->toBe(['Chair']); -}); - -it('prices products in whole cents, as the site shows them, so PayPal takes the order', function () { - $blog = $this->bertaRoot . '/storage/blog.shop.xml'; - File::put($blog, str_replace('', '', File::get($blog))); - postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]); - shopDb()->table('product')->where('uniqid', SHOP_CHAIR)->update(['instock' => 3]); - writeShopSettings($this->bertaRoot, restSettings()); - fakeRest(); - $token = fillShopBasket([[SHOP_CHAIR], [SHOP_CHAIR], [SHOP_CHAIR]]); - - shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); - - Http::assertSent(function (HttpRequest $request) { - $purchase = $request['purchase_units'][0] ?? null; - - return $purchase - && $purchase['items'][0]['unit_amount']['value'] === '33.33' - && $purchase['amount']['breakdown']['item_total']['value'] === '99.99' - && $purchase['amount']['value'] === '109.99'; - }); -}); - -it('adds the columns of newer shop versions to an older shop database', function () { - $database = $this->bertaRoot . '/storage/old-shop-db.sqlite'; - $db = new SQLite3($database); - $db->exec('CREATE TABLE client (id INTEGER PRIMARY KEY, created_at datetime NOT NULL, session_id varchar(32) NOT NULL)'); - $db->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, created_at datetime NOT NULL, id_client int NOT NULL, id_product int NOT NULL, paid tinyint DEFAULT 0, price decimal(10,2))'); - $db->close(); - config(['plugin-Shop.database-connections.sqlite.database' => $database]); - writeShopSettings($this->bertaRoot, ['version' => '3']); - - $schema = (new ShopClientsDataService(''))->getConnection()->getSchemaBuilder(); - - expect($schema->hasColumns('client', ['company', 'company_reg_no', 'legal_address', 'status', 'admin_notes', 'payment_method', 'vat_no', 'payment_reference']))->toBeTrue() - ->and($schema->hasColumn('orders', 'weight'))->toBeTrue() - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(9); -}); diff --git a/_api_app/tests/Feature/SiteTemplatesConfigServiceTest.php b/_api_app/tests/Feature/SiteTemplatesConfigServiceTest.php index 4cb179c90..c9a3f3e6b 100644 --- a/_api_app/tests/Feature/SiteTemplatesConfigServiceTest.php +++ b/_api_app/tests/Feature/SiteTemplatesConfigServiceTest.php @@ -13,17 +13,12 @@ expect($templates['messy-0.4.2']['templateConf']['settings']['_']['title'])->toBe('Dažādi'); }); -it('adds the shop\'s section types to a template on a domain with the shop', function (string $host, bool $hasShop) { - if ($hasShop) { - skipWithoutShopPlugin(); - } - app()->instance('request', Request::create("http://{$host}/")); +// With the shop plugin, its tests cover the domain with the shop. +it('leaves the shop\'s section types out on a domain without the shop', function () { + app()->instance('request', Request::create('http://other.test/')); $sectionTypes = (new SiteTemplatesConfigService)->get()['messy-0.4.2']['sectionTypes']; - expect(array_key_exists('shop', $sectionTypes))->toBe($hasShop) - ->and(array_key_exists('shopping_cart', $sectionTypes))->toBe($hasShop); -})->with([ - 'with the shop' => ['shop.test', true], - 'without it' => ['other.test', false], -]); + expect($sectionTypes)->not->toHaveKey('shop') + ->and($sectionTypes)->not->toHaveKey('shopping_cart'); +}); diff --git a/_api_app/tests/Feature/SitesDataServiceTest.php b/_api_app/tests/Feature/SitesDataServiceTest.php deleted file mode 100644 index f2cbee945..000000000 --- a/_api_app/tests/Feature/SitesDataServiceTest.php +++ /dev/null @@ -1,87 +0,0 @@ -bertaRoot = setUpShopTestRoot(); - writeShopSettings($this->bertaRoot, [], 'second'); - File::put($this->bertaRoot . '/storage/-sites/sites.xml', '' - . '<![CDATA[Main]]>' - . '<![CDATA[Second]]>' - . ''); -}); - -afterEach(function () { - if (isset($this->bertaRoot)) { - File::deleteDirectory($this->bertaRoot); - } -}); - -function putShopFile(string $path, string $contents, string $site = ''): void -{ - File::ensureDirectoryExists(dirname(shopFile($path, $site))); - File::put(shopFile($path, $site), $contents); -} - -it('swaps the shop files of two sites along with their contents', function () { - withoutMiddleware(SetupMiddleware::class); - actingAs(new User); - putShopFile('invoices/invoice-2026-1.pdf', '%PDF-main'); - putShopFile('invoices/invoice-2026-1.pdf', '%PDF-second', 'second'); - putShopFile('paypal/EC-1.json', '{}', 'second'); - - putJson(route('site_swap_contents_between_sites'), ['siteSlugFrom' => '', 'siteSlugTo' => 'second'])->assertOk(); - - expect(shopFiles())->toBe(['invoices/invoice-2026-1.pdf', 'paypal/EC-1.json']) - ->and(File::get(shopFile('invoices/invoice-2026-1.pdf')))->toBe('%PDF-second') - ->and(shopFiles('second'))->toBe(['invoices/invoice-2026-1.pdf']) - ->and(File::get(shopFile('invoices/invoice-2026-1.pdf', 'second')))->toBe('%PDF-main'); -}); - -it('starts the order numbers of a site cloned from a site with orders from 1, without its shop files', function () { - travelTo(Carbon::parse('2026-10-14 10:00:00')); - (new ShopOrderNumbersDataService('second'))->next(); - putShopFile('invoices/invoice-2026-1.pdf', '%PDF-second', 'second'); - - $site = (new SitesDataService)->create(Request::create('http://shop.test/'), 'second'); - - expect((new ShopOrderNumbersDataService($site['name']))->next())->toBe('2026-1') - ->and(shopFiles($site['name']))->toBe([]) - ->and((new ShopOrderNumbersDataService('second'))->next())->toBe('2026-2') - ->and(shopFiles('second'))->toBe(['invoices/invoice-2026-1.pdf']); -}); - -it('copies a media folder of a cloned site that shares the shop files folder name', function () { - $mediaFile = $this->bertaRoot . '/storage/-sites/second/media/_shop_files/photo.jpg'; - File::ensureDirectoryExists(dirname($mediaFile)); - File::put($mediaFile, 'jpeg'); - - $site = (new SitesDataService)->create(Request::create('http://shop.test/'), 'second'); - - expect(File::get($this->bertaRoot . "/storage/-sites/{$site['name']}/media/_shop_files/photo.jpg"))->toBe('jpeg'); -}); - -it('leaves the shop files out of the theme preview copy of a site', function () { - symlink(realpath(base_path('../_themes')), $this->bertaRoot . '/_themes'); - // The theme's images are copied into the preview's media folder - File::ensureDirectoryExists($this->bertaRoot . '/storage/media'); - putShopFile('invoices/invoice-2026-1.pdf', '%PDF-main'); - - (new SitesDataService)->createPrieview('kyoto'); - - expect(File::exists($this->bertaRoot . '/storage/preview/settings.xml'))->toBeTrue() - ->and(File::exists($this->bertaRoot . '/storage/preview/_shop_files'))->toBeFalse(); -}); diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php index 3d5aed648..079db8043 100644 --- a/_api_app/tests/Pest.php +++ b/_api_app/tests/Pest.php @@ -1,8 +1,5 @@ extend(TestCase::class)->in('Feature'); +// Plugins (separate repositories in app/Plugins) keep their own tests in their tests folder. +pest()->extend(TestCase::class)->in('Feature', '../app/Plugins/*/tests'); /* |-------------------------------------------------------------------------- @@ -49,111 +47,7 @@ function something() // .. } -/** - * Whether this Berta has the shop plugin. Its Laravel part (`app/Plugins/Shop`) and the site - * engine's part (`_plugin_shop`) are separate repositories, installed together. Checks the - * files rather than the classes: Composer's class map may still list a removed plugin's classes. - */ -function isShopPluginInstalled(): bool -{ - return is_file(__DIR__ . '/../app/Plugins/Shop/ShopController.php') - && is_file(__DIR__ . '/../../_plugin_shop/inc.setting-definition.php'); -} - -/** - * Skips the test without the shop plugin. A shop test file calls it first in its `beforeEach`, - * before any setup that needs the plugin: Pest's `->skip()` only applies after the hooks ran. - */ -function skipWithoutShopPlugin(): void -{ - if (! isShopPluginInstalled()) { - Assert::markTestSkipped('Shop plugin not installed'); - } -} - -/** - * A temporary Berta root for the shop plugin's tests, with its shop database and file cache - * - * @return string The root's path, for the test to delete afterwards - */ -function setUpShopTestRoot(): string -{ - $realRoot = config('app.old_berta_root'); - $bertaRoot = sys_get_temp_dir() . '/berta_shop_' . uniqid(); - File::ensureDirectoryExists($bertaRoot . '/storage'); - File::ensureDirectoryExists($bertaRoot . '/engine'); - File::ensureDirectoryExists($bertaRoot . '/_plugin_shop'); - // Site settings read the engine's definitions, and an anonymous request's auth check logs - // out through the legacy engine - foreach (['engine/_classes', 'engine/lang', 'engine/inc.setting-definition.php', 'engine/inc.font-options.php', 'engine/inc.version.php', '_templates'] as $path) { - symlink("{$realRoot}/{$path}", "{$bertaRoot}/{$path}"); - } - File::copy($realRoot . '/_plugin_shop/inc.setting-definition.php', $bertaRoot . '/_plugin_shop/inc.setting-definition.php'); - File::put($bertaRoot . '/engine/hosting', json_encode(['emailFromAddress' => 'shop@berta.test'])); - - config([ - 'app.old_berta_root' => $bertaRoot, - 'plugin-Shop.key' => 'shop.test', - // Order emails stay in memory instead of reaching the mail server in .env - 'plugin-Shop.mailer.transport' => 'array', - 'plugin-Shop.database-connections.sqlite.database' => $bertaRoot . '/storage/shop-db.sqlite', - 'plugin-Shop.database-connections.site-sqlite-template.database' => $bertaRoot . '/storage/-sites/{site}/shop-db.sqlite', - 'cache.stores.file.path' => $bertaRoot . '/cache', - ]); - Cache::forgetDriver('file'); - - writeShopSettings($bertaRoot); - - return $bertaRoot; -} - -/** - * @param array $shop - * @param array $texts The site's texts, besides its owner's name - */ -function writeShopSettings(string $bertaRoot, array $shop = [], string $site = '', array $texts = []): void -{ - $shop = array_merge([ - 'paymentMethod' => 'both', - 'email' => 'seller@berta.test', - 'currency' => 'EUR', - 'orderEmailSubject' => 'Your order', - 'promoCode' => 'SAVE10', - 'promoCodeDiscount' => '10', - ], $shop); - - $xmlValues = fn (array $values) => implode('', array_map( - fn ($name, $value) => "<{$name}>", - array_keys($values), - $values, - )); - - $siteRoot = $bertaRoot . '/storage' . ($site !== '' ? "/-sites/{$site}" : ''); - File::ensureDirectoryExists($siteRoot); - File::put($siteRoot . '/settings.xml', '' - . '' . $xmlValues($shop) . '' - . '' . $xmlValues(array_merge(['ownerName' => 'Shop Owner'], $texts)) . '' - . ''); -} - -/** - * A file in a site's private shop files, in the test's Berta root - */ -function shopFile(string $path, string $site = ''): string -{ - return config('app.old_berta_root') . '/storage' . ($site !== '' ? "/-sites/{$site}" : '') . "/_shop_files/{$path}"; -} - -/** - * The site's private shop files, relative to their folder - * - * @return list - */ -function shopFiles(string $site = ''): array -{ - $folder = shopFile('', $site); - - return File::isDirectory($folder) - ? array_values(array_map(fn ($file) => $file->getRelativePathname(), File::allFiles($folder))) - : []; +// Plugins share helpers between their own tests in their tests/Helpers.php. +foreach (glob(__DIR__ . '/../app/Plugins/*/tests/Helpers.php') ?: [] as $pluginHelpers) { + require_once $pluginHelpers; }