');
- })->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('
Rock & Roll ');
-});
-
-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', ''
- . ''
- . ''
- . ' ');
- 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', ''
- . ' '
- . ' '
- . ' ');
- 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', ''
- . ' '
- . ' '
- . ' ');
-});
-
-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}>{$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;
}