diff --git a/docker/provision.sh b/docker/provision.sh index 9f5eae2..9cd04e1 100644 --- a/docker/provision.sh +++ b/docker/provision.sh @@ -27,6 +27,11 @@ if [ "${WP_VERSION:-latest}" = "latest" ]; then wp core update-db fi +echo "==> Enabling SCRIPT_DEBUG" +# Version local plugin assets by file mtime (see Plugin::asset_version) so the +# browser never serves stale JS/CSS while developing on an unchanged version. +wp config set SCRIPT_DEBUG true --raw + echo "==> Installing locale $locale" wp language core install "$locale" --activate \ || echo "Warning: could not install core locale $locale" >&2 diff --git a/languages/sendy-nl_NL.mo b/languages/sendy-nl_NL.mo index c4d3304..cce3ea1 100644 Binary files a/languages/sendy-nl_NL.mo and b/languages/sendy-nl_NL.mo differ diff --git a/languages/sendy-nl_NL.po b/languages/sendy-nl_NL.po index c0e29f3..d669ecf 100644 --- a/languages/sendy-nl_NL.po +++ b/languages/sendy-nl_NL.po @@ -18,7 +18,7 @@ msgstr "" #: sendy.php #: lib/Modules/Admin/Settings.php:47 #: lib/Modules/Admin/Settings.php:48 -#: lib/Modules/Orders/Single.php:48 +#: lib/Modules/Orders/Single.php:47 msgid "Sendy" msgstr "Sendy" @@ -37,7 +37,7 @@ msgstr "Een plug-in die je WooCommerce shop verbindt met Sendy" msgid "https://sendy.nl/" msgstr "https://sendy.nl/" -#: lib/Modules/Orders/BulkActions.php:186 +#: lib/Modules/Orders/BulkActions.php:201 #: resources/views/admin/meta_box/single.php:18 msgid "Shop" msgstr "Shop" @@ -46,7 +46,7 @@ msgstr "Shop" msgid "Shipping preference" msgstr "Verzendprofiel" -#: lib/Modules/Orders/BulkActions.php:205 +#: lib/Modules/Orders/BulkActions.php:220 #: resources/views/admin/meta_box/single.php:38 msgid "Amount of packages" msgstr "Aantal pakketten" @@ -210,69 +210,71 @@ msgid "Sendy - Create shipments" msgstr "Sendy - Zendingen aanmaken" #: lib/Modules/Orders/BulkActions.php:39 +#: lib/Modules/Orders/BulkActions.php:118 msgid "Sendy - Print labels" msgstr "Sendy - Labels printen" -#: lib/Modules/Orders/PrintLabels.php:114 +#: lib/Modules/Orders/PrintLabels.php:109 msgid "Sendy: Label printed" msgstr "Sendy: Label geprint" -#: lib/Modules/Orders/BulkActions.php:187 +#: lib/Modules/Orders/BulkActions.php:202 msgid "The shipments will be created with the selected shop" msgstr "De zendingen worden aangemaakt met de geselecteerde shop" -#: lib/Modules/Orders/BulkActions.php:196 +#: lib/Modules/Orders/BulkActions.php:211 msgid "Select preference" msgstr "Selecteer verzendprofiel" -#: lib/Modules/Orders/BulkActions.php:197 +#: lib/Modules/Orders/BulkActions.php:212 msgid "The shipments will be created with the preference you select here" msgstr "De zendingen worden aangemaakt met de geselecteerde verzendvoorkeur" #. translators: %s The ID of the order -#: lib/Modules/Orders/OrdersModule.php:51 +#: lib/Modules/Orders/OrdersModule.php:148 #, php-format msgid "Order #%s already has a shipment created" msgstr "Voor order #%s is al een zending aangemaakt" -#: lib/Modules/Orders/OrdersModule.php:84 +#: lib/Modules/Orders/OrdersModule.php:181 #: lib/Modules/Webhooks.php:216 msgid "Sendy: Shipment created" msgstr "Sendy: Zending aangemaakt" #. translators: %1$s should contain the ID of the order and %2$s the error -#: lib/Modules/Orders/OrdersModule.php:223 -#: lib/Modules/Orders/OrdersModule.php:238 +#: lib/Modules/Orders/OrdersModule.php:90 +#: lib/Modules/Orders/OrdersModule.php:320 +#: lib/Modules/Orders/OrdersModule.php:335 #, php-format msgid "Error while creating shipment for order #%1$s: %2$s" msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%1$s: %2$s" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:229 +#: lib/Modules/Orders/OrdersModule.php:326 #, php-format msgid "Error while creating shipment for order #%s: Authentication failed. Check the settings page to reconnect with Sendy." -msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s:: Authenticatie mislukt. Ga naar de instellingen om opnieuw te verbinden met Sendy." +msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Authenticatie mislukt. Ga naar de instellingen om opnieuw te verbinden met Sendy." #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:241 +#: lib/Modules/Orders/OrdersModule.php:338 #, php-format msgid "Error while creating shipment for order #%s: Too many requests. Please try again later." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Te veel requests. Probeer het later opnieuw." #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:244 +#: lib/Modules/Orders/OrdersModule.php:341 #, php-format msgid "Error while creating shipment for order #%s: Unknown error." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Onbekende fout." -#: lib/Modules/Orders/PrintLabels.php:27 -#: lib/Modules/Orders/Single.php:123 +#: lib/Modules/Orders/OrdersModule.php:122 +#: lib/Modules/Orders/Single.php:122 msgid "Nonce verification failed" msgstr "Nonce verificatie mislukt" #: lib/Modules/Admin/Settings.php:257 -#: lib/Modules/Orders/PrintLabels.php:31 -#: lib/Modules/Orders/Single.php:119 +#: lib/Modules/Orders/OrdersModule.php:124 +#: lib/Modules/Orders/Single.php:118 msgid "You do not have sufficient permissions to access this page." msgstr "Je hebt niet de juiste rechten om de pagina te bekijken." @@ -300,7 +302,7 @@ msgstr "Selecteer voor welke vervoerder je de pick-up punten wilt tonen" #: lib/ShippingMethods/ShippingMethodTrait.php:46 #, php-format msgid "Free shipping from %s" -msgstr "Gratis verzending vanaf" +msgstr "Gratis verzending vanaf %s" #: lib/ShippingMethods/ShippingMethodTrait.php:48 msgid "Keep empty if you don’t want to use Free shipping" @@ -346,16 +348,77 @@ msgstr "Kan geen verbinding maken met Sendy. Probeer het later opnieuw." msgid "Nonce verification failed." msgstr "Nonce verificatie mislukt." -#: lib/Modules/Orders/BulkActions.php:95 +#: lib/Modules/Orders/BulkActions.php:87 msgid "Printing labels requires JavaScript. Enable JavaScript in your browser and try again." msgstr "Voor het printen van labels is JavaScript vereist. Schakel JavaScript in je browser in en probeer het opnieuw." -#: lib/Modules/Orders/PrintLabels.php:38 +#: lib/Modules/Orders/PrintLabels.php:32 msgid "None of the selected orders have any labels" msgstr "Geen van de geselecteerde bestellingen heeft labels" #. translators: %s contains the error message -#: lib/Modules/Orders/PrintLabels.php:54 +#: lib/Modules/Orders/PrintLabels.php:48 #, php-format msgid "Error while fetching labels: %s" msgstr "Fout bij het ophalen van labels: %s" + +#: lib/Modules/Orders/BulkActions.php:120 +msgid "Create shipments and print labels" +msgstr "Zendingen aanmaken en labels printen" + +#: lib/Modules/Orders/CreateShipments.php:30 +msgid "Shipments for this processing method are created in Sendy" +msgstr "Zendingen voor deze werkwijze worden aangemaakt in Sendy" + +#. translators: %s contains the number of orders +#: lib/Modules/Orders/CreateShipments.php:165 +#, php-format +msgid "There is no label to print for %s orders yet. Process the shipments in Sendy first." +msgstr "Er is nog geen label om te printen voor %s orders. Verwerk de zendingen eerst in Sendy." + +#. translators: %s contains the ID of the order +#: lib/Modules/Orders/CreateShipments.php:167 +#, php-format +msgid "There is no label to print for order #%s yet. Process the shipment in Sendy first." +msgstr "Er is nog geen label om te printen voor order #%s. Verwerk de zending eerst in Sendy." + +#. translators: %s contains the number of orders +#: lib/Modules/Orders/CreateShipments.php:171 +#, php-format +msgid "The labels for %s orders are still being generated. Please try again in a moment." +msgstr "De labels voor %s orders worden nog gegenereerd. Probeer het later opnieuw." + +#. translators: %s contains the ID of the order +#: lib/Modules/Orders/CreateShipments.php:173 +#, php-format +msgid "The labels for order #%s are still being generated. Please try again in a moment." +msgstr "De labels voor order #%s worden nog gegenereerd. Probeer het later opnieuw." + +#. translators: %1$s contains the ID of the order, %2$s the error message +#: lib/Modules/Orders/CreateShipments.php:205 +#, php-format +msgid "Error while checking the shipment for order #%1$s: %2$s" +msgstr "Er ging iets mis bij het controleren van de zending voor order #%1$s: %2$s" + +#. translators: %1$s contains the ID of the order, %2$s the status of the shipment +#: lib/Modules/Orders/CreateShipments.php:228 +#, php-format +msgid "The labels for order #%1$s cannot be printed (shipment status: %2$s)" +msgstr "De labels voor order #%1$s kunnen niet worden geprint (status van de zending: %2$s)" + +#: lib/Modules/Orders/BulkActions.php:119 +#: lib/Modules/Orders/RowActions.php:35 +msgid "Sendy - Print label" +msgstr "Sendy - Label printen" + +#: lib/Modules/Orders/RowActions.php:34 +msgid "Sendy - Create shipment and print label" +msgstr "Sendy - Zending aanmaken en label printen" + +#: lib/Modules/Orders/BulkActions.php:121 +msgid "Create shipment and print label" +msgstr "Zending aanmaken en label printen" + +#: lib/Modules/Orders/CreateShipments.php:118 +msgid "The plugin is not connected to Sendy. Check the settings page to reconnect." +msgstr "De plugin is niet verbonden met Sendy. Ga naar de instellingen om opnieuw te verbinden." diff --git a/languages/sendy.pot b/languages/sendy.pot index 86ac442..77dac89 100644 --- a/languages/sendy.pot +++ b/languages/sendy.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-07-15T08:25:45+00:00\n" +"POT-Creation-Date: 2026-08-07T15:00:08+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: sendy\n" @@ -19,7 +19,7 @@ msgstr "" #: sendy.php #: lib/Modules/Admin/Settings.php:47 #: lib/Modules/Admin/Settings.php:48 -#: lib/Modules/Orders/Single.php:48 +#: lib/Modules/Orders/Single.php:47 msgid "Sendy" msgstr "" @@ -95,8 +95,8 @@ msgid "After the shipment is delivered" msgstr "" #: lib/Modules/Admin/Settings.php:257 -#: lib/Modules/Orders/PrintLabels.php:31 -#: lib/Modules/Orders/Single.php:119 +#: lib/Modules/Orders/OrdersModule.php:124 +#: lib/Modules/Orders/Single.php:118 msgid "You do not have sufficient permissions to access this page." msgstr "" @@ -123,35 +123,93 @@ msgid "Sendy - Create shipments" msgstr "" #: lib/Modules/Orders/BulkActions.php:39 +#: lib/Modules/Orders/BulkActions.php:118 msgid "Sendy - Print labels" msgstr "" -#: lib/Modules/Orders/BulkActions.php:95 +#: lib/Modules/Orders/BulkActions.php:87 msgid "Printing labels requires JavaScript. Enable JavaScript in your browser and try again." msgstr "" -#: lib/Modules/Orders/BulkActions.php:186 +#: lib/Modules/Orders/BulkActions.php:119 +#: lib/Modules/Orders/RowActions.php:35 +msgid "Sendy - Print label" +msgstr "" + +#: lib/Modules/Orders/BulkActions.php:120 +msgid "Create shipments and print labels" +msgstr "" + +#: lib/Modules/Orders/BulkActions.php:121 +msgid "Create shipment and print label" +msgstr "" + +#: lib/Modules/Orders/BulkActions.php:201 #: resources/views/admin/meta_box/single.php:18 msgid "Shop" msgstr "" -#: lib/Modules/Orders/BulkActions.php:187 +#: lib/Modules/Orders/BulkActions.php:202 msgid "The shipments will be created with the selected shop" msgstr "" -#: lib/Modules/Orders/BulkActions.php:196 +#: lib/Modules/Orders/BulkActions.php:211 msgid "Select preference" msgstr "" -#: lib/Modules/Orders/BulkActions.php:197 +#: lib/Modules/Orders/BulkActions.php:212 msgid "The shipments will be created with the preference you select here" msgstr "" -#: lib/Modules/Orders/BulkActions.php:205 +#: lib/Modules/Orders/BulkActions.php:220 #: resources/views/admin/meta_box/single.php:38 msgid "Amount of packages" msgstr "" +#: lib/Modules/Orders/CreateShipments.php:30 +msgid "Shipments for this processing method are created in Sendy" +msgstr "" + +#: lib/Modules/Orders/CreateShipments.php:118 +msgid "The plugin is not connected to Sendy. Check the settings page to reconnect." +msgstr "" + +#. translators: %s contains the number of orders +#: lib/Modules/Orders/CreateShipments.php:165 +#, php-format +msgid "There is no label to print for %s orders yet. Process the shipments in Sendy first." +msgstr "" + +#. translators: %s contains the ID of the order +#: lib/Modules/Orders/CreateShipments.php:167 +#, php-format +msgid "There is no label to print for order #%s yet. Process the shipment in Sendy first." +msgstr "" + +#. translators: %s contains the number of orders +#: lib/Modules/Orders/CreateShipments.php:171 +#, php-format +msgid "The labels for %s orders are still being generated. Please try again in a moment." +msgstr "" + +#. translators: %s contains the ID of the order +#: lib/Modules/Orders/CreateShipments.php:173 +#, php-format +msgid "The labels for order #%s are still being generated. Please try again in a moment." +msgstr "" + +#. translators: %1$s contains the ID of the order, %2$s the error message +#: lib/Modules/Orders/CreateShipments.php:205 +#, php-format +msgid "Error while checking the shipment for order #%1$s: %2$s" +msgstr "" + +#. translators: %1$s contains the ID of the order, %2$s the status of the shipment +#: lib/Modules/Orders/CreateShipments.php:228 +#, php-format +msgid "The labels for order #%1$s cannot be printed (shipment status: %2$s)" +msgstr "" + #: lib/Modules/Orders/OrderList.php:34 #: resources/views/admin/single/shipping_data.php:11 msgid "Shipping method" @@ -162,61 +220,66 @@ msgstr "" msgid "Track and trace" msgstr "" +#. translators: %1$s should contain the ID of the order and %2$s the error +#: lib/Modules/Orders/OrdersModule.php:90 +#: lib/Modules/Orders/OrdersModule.php:320 +#: lib/Modules/Orders/OrdersModule.php:335 +#, php-format +msgid "Error while creating shipment for order #%1$s: %2$s" +msgstr "" + +#: lib/Modules/Orders/OrdersModule.php:122 +#: lib/Modules/Orders/Single.php:122 +msgid "Nonce verification failed" +msgstr "" + #. translators: %s The ID of the order -#: lib/Modules/Orders/OrdersModule.php:51 +#: lib/Modules/Orders/OrdersModule.php:148 #, php-format msgid "Order #%s already has a shipment created" msgstr "" -#: lib/Modules/Orders/OrdersModule.php:84 +#: lib/Modules/Orders/OrdersModule.php:181 #: lib/Modules/Webhooks.php:216 msgid "Sendy: Shipment created" msgstr "" -#. translators: %1$s should contain the ID of the order and %2$s the error -#: lib/Modules/Orders/OrdersModule.php:223 -#: lib/Modules/Orders/OrdersModule.php:238 -#, php-format -msgid "Error while creating shipment for order #%1$s: %2$s" -msgstr "" - #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:229 +#: lib/Modules/Orders/OrdersModule.php:326 #, php-format msgid "Error while creating shipment for order #%s: Authentication failed. Check the settings page to reconnect with Sendy." msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:241 +#: lib/Modules/Orders/OrdersModule.php:338 #, php-format msgid "Error while creating shipment for order #%s: Too many requests. Please try again later." msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:244 +#: lib/Modules/Orders/OrdersModule.php:341 #, php-format msgid "Error while creating shipment for order #%s: Unknown error." msgstr "" -#: lib/Modules/Orders/PrintLabels.php:27 -#: lib/Modules/Orders/Single.php:123 -msgid "Nonce verification failed" -msgstr "" - -#: lib/Modules/Orders/PrintLabels.php:38 +#: lib/Modules/Orders/PrintLabels.php:32 msgid "None of the selected orders have any labels" msgstr "" #. translators: %s contains the error message -#: lib/Modules/Orders/PrintLabels.php:54 +#: lib/Modules/Orders/PrintLabels.php:48 #, php-format msgid "Error while fetching labels: %s" msgstr "" -#: lib/Modules/Orders/PrintLabels.php:114 +#: lib/Modules/Orders/PrintLabels.php:109 msgid "Sendy: Label printed" msgstr "" +#: lib/Modules/Orders/RowActions.php:34 +msgid "Sendy - Create shipment and print label" +msgstr "" + #: lib/Modules/ShippingMethodsSynchronizer.php:65 msgid "Rest of the world" msgstr "" diff --git a/lib/Modules/Admin/Settings.php b/lib/Modules/Admin/Settings.php index 2c7744c..c28e167 100644 --- a/lib/Modules/Admin/Settings.php +++ b/lib/Modules/Admin/Settings.php @@ -32,7 +32,7 @@ public function enqueue_assets(): void 'sendy-admin-settings', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-settings.js', [], - Plugin::VERSION, + Plugin::asset_version('/resources/js/admin-settings.js'), true, ); } diff --git a/lib/Modules/Checkout.php b/lib/Modules/Checkout.php index b1f5fa2..e4a0b25 100644 --- a/lib/Modules/Checkout.php +++ b/lib/Modules/Checkout.php @@ -32,7 +32,7 @@ public function enqueue_assets(): void if (is_checkout()) { wp_enqueue_script('wp-util'); wp_enqueue_script('sendy-api', 'https://app.sendy.nl/embed/api.js', [], Plugin::VERSION, ['in_footer' => true]); - wp_enqueue_script('sendy-checkout', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/checkout.js', ['jquery', 'sendy-api'], Plugin::VERSION, ['in_footer' => true]); + wp_enqueue_script('sendy-checkout', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/checkout.js', ['jquery', 'sendy-api'], Plugin::asset_version('/resources/js/checkout.js'), ['in_footer' => true]); } } diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index d84eebc..a23eb7c 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -54,27 +54,19 @@ public function handle_bulk_action_create_shipments(string $redirect, string $ac wp_die('Nonce verification failed'); } + $shopId = sanitize_key($_REQUEST['sendy_shop_id'] ?? ''); + $preferenceId = sanitize_key($_REQUEST['sendy_preference_id'] ?? ''); + $amount = sanitize_key($_REQUEST['sendy_amount'] ?? '1'); + foreach ($objectIds as $id) { - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - $this->create_shipment_from_order( - wc_get_order($id), - sanitize_key($_REQUEST['sendy_preference_id'] ?? ''), - sanitize_key($_REQUEST['sendy_shop_id'] ?? ''), - sanitize_key($_REQUEST['sendy_amount'] ?? ''), - ); - - update_option('sendy_previously_used_preference_id', sanitize_key($_REQUEST['sendy_preference_id'] ?? '')); - update_option('sendy_previously_used_amount', sanitize_key($_REQUEST['sendy_amount'] ?? '')); - } else { - $this->create_shipment_with_smart_rules( - wc_get_order($id), - false, - sanitize_key($_REQUEST['sendy_shop_id'] ?? ''), - ); + $order = wc_get_order($id); + + if ($order) { + $this->create_shipment_flashing_errors($order, $shopId, $preferenceId, (int) $amount); } } - update_option('sendy_previously_used_shop_id', sanitize_key($_REQUEST['sendy_shop_id'] ?? '')); + $this->remember_previously_used($shopId, $preferenceId, $amount); return $redirect; } @@ -82,8 +74,7 @@ public function handle_bulk_action_create_shipments(string $redirect, string $ac /** * Handle the print labels bulk action * - * The real work happens through the sendy_print_labels AJAX endpoint; - * print-labels.js intercepts the form submission. Reaching this handler + * print-labels.js intercepts the form submission; reaching this handler * means JavaScript was disabled or bypassed. */ public function handle_bulk_action_print_labels(string $redirect, string $action, array $objectIds): string @@ -110,15 +101,31 @@ public function enqueue_assets(): void 'sendy-print-labels', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/print-labels.js', [], - Plugin::VERSION, + Plugin::asset_version('/resources/js/print-labels.js'), + true, + ); + + wp_register_script( + 'sendy-orders-list-print-flow', + SENDY_WC_PLUGIN_DIR_URL . '/resources/js/orders-list-print-flow.js', + ['jquery', 'thickbox', 'sendy-print-labels'], + Plugin::asset_version('/resources/js/orders-list-print-flow.js'), true, ); + wp_localize_script('sendy-orders-list-print-flow', 'sendyPrintFlow', [ + 'modalTitle' => __('Sendy - Print labels', 'sendy'), + 'modalTitleSingular' => __('Sendy - Print label', 'sendy'), + 'modalButtonText' => __('Create shipments and print labels', 'sendy'), + 'modalButtonTextSingular' => __('Create shipment and print label', 'sendy'), + 'printExistingOnly' => $this->print_existing_labels_only(), + ]); + wp_enqueue_script( 'sendy-admin-order-bulk', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-order-bulk.js', - ['jquery', 'jquery-blockui', 'sendy-print-labels'], - Plugin::VERSION, + ['jquery', 'jquery-blockui', 'sendy-orders-list-print-flow'], + Plugin::asset_version('/resources/js/admin-order-bulk.js'), true, ); @@ -126,7 +133,14 @@ public function enqueue_assets(): void 'sendy-admin-order-bulk', SENDY_WC_PLUGIN_DIR_URL . '/resources/css/modal.css', [], - Plugin::VERSION, + Plugin::asset_version('/resources/css/modal.css'), + ); + + wp_enqueue_style( + 'sendy-order-actions', + SENDY_WC_PLUGIN_DIR_URL . '/resources/css/order-actions.css', + [], + Plugin::asset_version('/resources/css/order-actions.css'), ); } } diff --git a/lib/Modules/Orders/CreateShipments.php b/lib/Modules/Orders/CreateShipments.php new file mode 100644 index 0000000..6e3e61b --- /dev/null +++ b/lib/Modules/Orders/CreateShipments.php @@ -0,0 +1,220 @@ +verify_ajax_request('sendy_bulk_modal', true); + + if ($this->print_existing_labels_only()) { + // Refuse rather than trust the client: a cached older copy of the + // script could otherwise create shipments the portal creates itself. + $message = __('Shipments for this processing method are created in Sendy', 'sendy'); + + sendy_flash_admin_notice('error', $message); + wp_send_json(['message' => $message], 400); + } + + $shopId = sanitize_key($_POST['shop_id'] ?? ''); + $preferenceId = sanitize_key($_POST['preference_id'] ?? ''); + $amount = sanitize_key($_POST['amount'] ?? '1'); + + $created = []; + + try { + foreach ($this->requested_order_ids() as $orderId) { + $order = wc_get_order($orderId); + + if (! $order || $order->meta_exists('_sendy_shipment_id')) { + continue; + } + + $this->create_shipment_flashing_errors($order, $shopId, $preferenceId, (int) $amount); + + if ($order->meta_exists('_sendy_shipment_id')) { + $created[] = $orderId; + } + } + } catch (\RuntimeException $exception) { + $this->send_not_connected_error(); + } + + if ($created !== []) { + $this->remember_previously_used($shopId, $preferenceId, $amount); + } + + wp_send_json(['created' => $created]); + } + + /** + * Report per order whether its labels can be printed: none, pending, + * ready or failed + */ + public function handle_shipments_status(): void + { + $this->verify_ajax_request('sendy_print_labels', true); + + $statuses = []; + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- boolean flag, nothing to sanitize + $flashUnprintable = ! empty($_POST['flash_unprintable']); + + try { + foreach ($this->requested_order_ids() as $orderId) { + $statuses[$orderId] = $this->shipment_status(wc_get_order($orderId)); + } + } catch (\RuntimeException $exception) { + $this->send_not_connected_error(); + } + + if ($flashUnprintable) { + $this->flash_unprintable_notice($this->unprintable_orders($statuses)); + } + + wp_send_json(['statuses' => $statuses]); + } + + /** + * @return int[] + */ + private function requested_order_ids(): array + { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id + return array_map('intval', (array) ($_POST['order_ids'] ?? [])); + } + + /** + * Report a broken connection as JSON + * + * An access token registers these endpoints, but the other token options + * can still be missing; building the API connection then throws. + */ + private function send_not_connected_error(): void + { + $message = __('The plugin is not connected to Sendy. Check the settings page to reconnect.', 'sendy'); + + sendy_flash_admin_notice('error', $message); + wp_send_json(['message' => $message], 403); + } + + /** + * The orders that have no label to print + * + * Failed shipments have flashed their own explanation, and orders without + * a shipment only matter when this flow is not the one creating them. + * + * @param array $statuses + * @return int[] + */ + private function unprintable_orders(array $statuses): array + { + $unprintable = $this->print_existing_labels_only() ? ['none', 'pending'] : ['pending']; + + return array_keys(array_filter($statuses, function (string $status) use ($unprintable): bool { + return in_array($status, $unprintable, true); + })); + } + + /** + * Explain in one notice which orders have no label to print + * + * Printing a whole page of orders can leave many of them unprintable, so + * they are counted rather than named. + * + * @param int[] $orderIds + */ + private function flash_unprintable_notice(array $orderIds): void + { + if ($orderIds === []) { + return; + } + + $count = count($orderIds); + + if ($this->print_existing_labels_only()) { + $message = $count > 1 + // translators: %s contains the number of orders + ? __('There is no label to print for %s orders yet. Process the shipments in Sendy first.', 'sendy') + // translators: %s contains the ID of the order + : __('There is no label to print for order #%s yet. Process the shipment in Sendy first.', 'sendy'); + } else { + $message = $count > 1 + // translators: %s contains the number of orders + ? __('The labels for %s orders are still being generated. Please try again in a moment.', 'sendy') + // translators: %s contains the ID of the order + : __('The labels for order #%s are still being generated. Please try again in a moment.', 'sendy'); + } + + sendy_flash_admin_notice('warning', sprintf($message, $count > 1 ? $count : reset($orderIds))); + } + + /** + * Determine the print status of a single order's shipment + * + * Persists the packages once the shipment is generated, so later checks + * skip the API. + * + * @param \WC_Order|false $order + */ + private function shipment_status($order): string + { + if (! $order || ! $order->meta_exists('_sendy_shipment_id')) { + return 'none'; + } + + $packages = $order->get_meta('_sendy_packages'); + + if (is_array($packages) && $packages !== []) { + return 'ready'; + } + + try { + $shipment = (new Shipments())->get($order->get_meta('_sendy_shipment_id')); + } catch (SendyException $exception) { + sendy_flash_admin_notice('error', sprintf( + // translators: %1$s contains the ID of the order, %2$s the error message + __('Error while checking the shipment for order #%1$s: %2$s', 'sendy'), + $order->get_id(), + $exception->getMessage(), + )); + + return 'failed'; + } + + $status = $shipment['status'] ?? ''; + + if ($status === 'generated') { + $order->update_meta_data('_sendy_packages', $shipment['packages'] ?? []); + $order->save(); + + return 'ready'; + } + + if (in_array($status, ['new', 'pending'], true)) { + return 'pending'; + } + + sendy_flash_admin_notice('error', sprintf( + // translators: %1$s contains the ID of the order, %2$s the status of the shipment + __('The labels for order #%1$s cannot be printed (shipment status: %2$s)', 'sendy'), + $order->get_id(), + $status !== '' ? $status : 'unknown', + )); + + return 'failed'; + } +} diff --git a/lib/Modules/Orders/OrdersModule.php b/lib/Modules/Orders/OrdersModule.php index f6526f2..db4a846 100644 --- a/lib/Modules/Orders/OrdersModule.php +++ b/lib/Modules/Orders/OrdersModule.php @@ -4,7 +4,9 @@ use GuzzleHttp\Exception\GuzzleException; use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; use Sendy\WooCommerce\ApiClientFactory; +use Sendy\WooCommerce\Enums\ProcessingMethod; use Sendy\WooCommerce\Repositories\Shops; abstract class OrdersModule @@ -37,14 +39,99 @@ protected function is_pickup_point_delivery(\WC_Order $order): bool } /** - * Create the order in the Sendy API + * Whether the orders list can only print labels that already exist + * + * With the Sendy processing method the portal creates and generates the + * shipments itself, so the orders list never has a new label to wait for. + */ + protected function print_existing_labels_only(): bool + { + return get_option('sendy_processing_method') !== ProcessingMethod::WooCommerce; + } + + /** + * Create a shipment for the order through the configured processing method * + * @param string $shopId The UUID of the selected shop * @param string $preferenceId The UUID of the selected shipping preference + * @param int $amount The amount of packages the shipment should contain + * @throws GuzzleException + */ + protected function create_shipment(\WC_Order $order, string $shopId, string $preferenceId, int $amount): void + { + if ($this->print_existing_labels_only()) { + $this->create_shipment_with_smart_rules($order, false, $shopId); + } else { + $this->create_shipment_from_order($order, $shopId, $preferenceId, $amount); + } + } + + /** + * Create a shipment and flash any failure as an admin notice + * + * The creation methods flash API errors themselves; this also catches what + * they throw outside that, so one failing order does not abort the batch. + */ + protected function create_shipment_flashing_errors(\WC_Order $order, string $shopId, string $preferenceId, int $amount): void + { + try { + $this->create_shipment($order, $shopId, $preferenceId, $amount); + } catch (SendyException $exception) { + // translators: %1$s contains the ID of the order, %2$s the error message + sendy_flash_admin_notice('error', sprintf( + __('Error while creating shipment for order #%1$s: %2$s', 'sendy'), + $order->get_id(), + $exception->getMessage(), + )); + } + } + + /** + * Remember the create-shipments modal values as the defaults for next time + */ + protected function remember_previously_used(string $shopId, string $preferenceId, string $amount): void + { + update_option('sendy_previously_used_shop_id', $shopId); + + // Only the WooCommerce processing method uses a preference and amount. + if (! $this->print_existing_labels_only()) { + update_option('sendy_previously_used_preference_id', $preferenceId); + update_option('sendy_previously_used_amount', $amount); + } + } + + /** + * Send a 403 JSON response when the nonce or the capabilities do not check out + * + * @param bool $flashErrors Also flash the failure as an admin notice, for + * callers whose JS reloads the page on an error + */ + protected function verify_ajax_request(string $nonceAction, bool $flashErrors = false): void + { + if (! check_ajax_referer($nonceAction, 'nonce', false)) { + $message = __('Nonce verification failed', 'sendy'); + } elseif (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { + $message = __('You do not have sufficient permissions to access this page.', 'sendy'); + } else { + return; + } + + if ($flashErrors) { + sendy_flash_admin_notice('error', $message); + } + + wp_send_json(['message' => $message], 403); + } + + /** + * Create the order in the Sendy API + * * @param string $shopId The UUID of the selected shop + * @param string $preferenceId The UUID of the selected shipping preference * @param int $amount The amount of packages the shipment should contain * @throws GuzzleException */ - protected function create_shipment_from_order(\WC_Order $order, string $preferenceId, string $shopId, int $amount): void + protected function create_shipment_from_order(\WC_Order $order, string $shopId, string $preferenceId, int $amount): void { if ($order->meta_exists('_sendy_shipment_id')) { // translators: %s The ID of the order diff --git a/lib/Modules/Orders/PrintLabels.php b/lib/Modules/Orders/PrintLabels.php index dafe561..e7395b2 100644 --- a/lib/Modules/Orders/PrintLabels.php +++ b/lib/Modules/Orders/PrintLabels.php @@ -23,19 +23,7 @@ public function __construct() */ public function handle_print_labels(): void { - if (! check_ajax_referer('sendy_print_labels', 'nonce', false)) { - $message = __('Nonce verification failed', 'sendy'); - - sendy_flash_admin_notice('error', $message); - wp_send_json(['message' => $message], 403); - } - - if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { - $message = __('You do not have sufficient permissions to access this page.', 'sendy'); - - sendy_flash_admin_notice('error', $message); - wp_send_json(['message' => $message], 403); - } + $this->verify_ajax_request('sendy_print_labels', true); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id $orders = $this->orders_with_shipment(array_map('intval', (array) ($_POST['order_ids'] ?? []))); diff --git a/lib/Modules/Orders/RowActions.php b/lib/Modules/Orders/RowActions.php new file mode 100644 index 0000000..149777f --- /dev/null +++ b/lib/Modules/Orders/RowActions.php @@ -0,0 +1,38 @@ +> $actions + * @param \WC_Order $order + * @return array> + */ + public function add_print_label_action(array $actions, $order): array + { + $createsShipmentFirst = ! $order->meta_exists('_sendy_shipment_id') && ! $this->print_existing_labels_only(); + + $actions['sendy_print_label'] = [ + 'url' => '#sendy-print-label-' . $order->get_id(), + 'name' => $createsShipmentFirst + ? __('Sendy - Create shipment and print label', 'sendy') + : __('Sendy - Print label', 'sendy'), + 'action' => 'sendy_print_label', + ]; + + return $actions; + } +} diff --git a/lib/Modules/Orders/Single.php b/lib/Modules/Orders/Single.php index 3361b96..998e591 100644 --- a/lib/Modules/Orders/Single.php +++ b/lib/Modules/Orders/Single.php @@ -4,7 +4,6 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Sendy\Api\Exceptions\SendyException; -use Sendy\WooCommerce\Enums\ProcessingMethod; use Sendy\WooCommerce\Plugin; use Sendy\WooCommerce\Repositories\Preferences; use Sendy\WooCommerce\Repositories\Shops; @@ -93,7 +92,7 @@ public function enqueue_assets(): void 'sendy-print-labels', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/print-labels.js', [], - Plugin::VERSION, + Plugin::asset_version('/resources/js/print-labels.js'), true, ); @@ -101,7 +100,7 @@ public function enqueue_assets(): void 'sendy-admin-order-single', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-order-single.js', ['sendy-print-labels'], - Plugin::VERSION, + Plugin::asset_version('/resources/js/admin-order-single.js'), true, ); } @@ -123,29 +122,25 @@ public function handle_create_shipment_from_form(): void throw new \Exception(esc_html__('Nonce verification failed', 'sendy')); } - if (! empty($_REQUEST['order_id'])) { - $order = wc_get_order(sanitize_key($_REQUEST['order_id'])); - - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - $this->create_shipment_from_order( - $order, - sanitize_key($_REQUEST['preference_id'] ?? ''), - sanitize_key($_REQUEST['shop_id'] ?? ''), - sanitize_key($_REQUEST['amount'] ?? ''), - ); - } else { - $this->create_shipment_with_smart_rules( - $order, - false, - sanitize_key($_REQUEST['shop_id'] ?? ''), - ); - } - - wp_send_json_success(); + if (empty($_REQUEST['order_id'])) { + return; } + + $order = wc_get_order(sanitize_key($_REQUEST['order_id'])); + + $this->create_shipment( + $order, + sanitize_key($_REQUEST['shop_id'] ?? ''), + sanitize_key($_REQUEST['preference_id'] ?? ''), + (int) sanitize_key($_REQUEST['amount'] ?? '1'), + ); } catch (\Exception $e) { wp_send_json_error(['message' => $e->getMessage()]); } + + // Outside the try: wp_send_json_success() ends in wp_die(), which + // throws in tests and would be caught above as a failure. + wp_send_json_success(); } /** diff --git a/lib/Plugin.php b/lib/Plugin.php index aa0237b..dc7fa50 100644 --- a/lib/Plugin.php +++ b/lib/Plugin.php @@ -7,7 +7,9 @@ use Sendy\WooCommerce\Modules\Checkout; use Sendy\WooCommerce\Modules\OAuth; use Sendy\WooCommerce\Modules\Orders\BulkActions; +use Sendy\WooCommerce\Modules\Orders\CreateShipments; use Sendy\WooCommerce\Modules\Orders\PrintLabels; +use Sendy\WooCommerce\Modules\Orders\RowActions; use Sendy\WooCommerce\Modules\Orders\ProcessInBackground; use Sendy\WooCommerce\Modules\Orders\OrderList; use Sendy\WooCommerce\Modules\Orders\Single; @@ -19,7 +21,7 @@ class Plugin { - public const VERSION = '3.4.7'; + public const VERSION = '3.5.0'; public const SETTINGS_ID = 'sendy'; @@ -38,6 +40,24 @@ public static function instance(): Plugin return self::$instance ??= new self(); } + /** + * Version string for cache-busting a local plugin asset + * + * Releases invalidate browser caches through the plugin version; with + * SCRIPT_DEBUG enabled the file's modification time is used instead, so + * development edits are never served stale. + */ + public static function asset_version(string $path): string + { + $file = dirname(SENDY_WC_PLUGIN_FILE) . $path; + + if (defined('SCRIPT_DEBUG') && SCRIPT_DEBUG && file_exists($file)) { + return (string) filemtime($file); + } + + return self::VERSION; + } + public function initialize_plugin(): void { $this->set_default_values_for_settings(); @@ -119,6 +139,8 @@ public function initialize_modules(): void $this->modules['orders_list'] = new OrderList(); $this->modules['orders_single'] = new Single(); $this->modules['orders_print_labels'] = new PrintLabels(); + $this->modules['orders_create_shipments'] = new CreateShipments(); + $this->modules['orders_row_actions'] = new RowActions(); $this->modules['checkout'] = new Checkout(); $this->modules['webhooks'] = new Webhooks(); $this->modules['orders_sendy'] = new ProcessInBackground(); diff --git a/lib/Utils/BlocksIntegration.php b/lib/Utils/BlocksIntegration.php index fe8d7d5..5aa6c3c 100644 --- a/lib/Utils/BlocksIntegration.php +++ b/lib/Utils/BlocksIntegration.php @@ -51,7 +51,7 @@ private function register_block_frontend_scripts() 'sendy-checkout-block-frontend-styling', SENDY_WC_PLUGIN_DIR_URL . '/resources/css/frontend.css', [], - Plugin::VERSION, + Plugin::asset_version('/resources/css/frontend.css'), ); wp_register_script( diff --git a/readme.txt b/readme.txt index a1d77bc..c0683f3 100644 --- a/readme.txt +++ b/readme.txt @@ -2,8 +2,8 @@ Plugin Name: Sendy Plugin URI: https://app.sendy.nl/ Description: A WooCommerce plugin that connects your site to the Sendy platform -Version: 3.4.7 -Stable tag: 3.4.7 +Version: 3.5.0 +Stable tag: 3.5.0 License: MIT Author: Sendy Author URI: https://sendy.nl/ @@ -52,7 +52,10 @@ Hierop zijn onze [algemene voorwaarden](https://sendy.nl/algemene-voorwaarden/) == Changelog == -= Unreleased = += 3.5.0 = +* Add print buttons to the orders list to create shipments and print labels in one click +* Print labels directly using the Sendy print app +* Set the minimum required WordPress version to 6.2 * Clean up plugin options on uninstall, so a reinstall starts with fresh credentials = 3.4.7 = diff --git a/resources/css/order-actions.css b/resources/css/order-actions.css new file mode 100644 index 0000000..b1d6814 --- /dev/null +++ b/resources/css/order-actions.css @@ -0,0 +1,26 @@ +.column-wc_actions a.sendy_print_label::after { + font-family: Dashicons; + content: "\f193"; +} + +/* Busy state while the create-and-print flow runs (up to a minute). */ +.column-wc_actions a.sendy_print_label.disabled::after { + content: "\f463"; + opacity: 0.6; +} + +@media (prefers-reduced-motion: no-preference) { + .column-wc_actions a.sendy_print_label.disabled::after { + animation: sendy-action-busy 1.5s linear infinite; + } +} + +@keyframes sendy-action-busy { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/resources/js/admin-order-bulk.js b/resources/js/admin-order-bulk.js index 82ab04d..6782efd 100644 --- a/resources/js/admin-order-bulk.js +++ b/resources/js/admin-order-bulk.js @@ -24,8 +24,9 @@ }, /** - * Handle the print labels bulk action client-side so the labels can - * be sent to the print app + * Handle the print labels bulk action client-side so shipments can + * be created for orders that have none and the labels can be sent + * to the print app * * @param {Event} event */ @@ -58,94 +59,57 @@ overlayCSS: { background: '#fff', opacity: 0.6 }, } ); - window.sendyPrintLabels( orderIds ).finally( () => form.unblock() ); + window + .sendyOrdersListPrintFlow( orderIds ) + .finally( () => form.unblock() ); }, /** + * Ask for the shop/preference/amount through the shared + * create-shipments modal and submit the bulk action form with the + * chosen values. * * @param {Event} event */ toggleCreateShipmentsModal: function ( event ) { event.preventDefault(); - let value = $( this ).val(), - bulkActionsForm = $( this ).parents( - '#wc-orders-filter, #posts-filter' - ), - title = $( ':selected', this ).text(); - - if ( value === 'sendy_create_shipments' ) { - tb_show( - '', - '/?TB_inline=true&width=420&height=320&inlineId=sendy-create-shipments-modal' - ); - - let thickboxWindow = $( '#TB_window' ); - - thickboxWindow.find( '#TB_ajaxWindowTitle' ).text( title ); - - thickboxWindow - .find( '#sendy-create-shipments-button' ) - .on( 'click', function ( event ) { - event.preventDefault(); - - let sendyFieldsContainer = bulkActionsForm - .append( '
' ) - .hide(); - - let preferenceId = thickboxWindow - .find( '#sendy_preference_id' ) - .find( ':selected' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_preference_id', - value: preferenceId, - } ) - ); - - if ( thickboxWindow.find( '#sendy_shop_id' ) ) { - let shopId = thickboxWindow - .find( '#sendy_shop_id' ) - .find( ':selected' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_shop_id', - value: shopId, - } ) + if ( $( this ).val() !== 'sendy_create_shipments' ) { + return; + } + + const bulkActionsForm = $( this ).parents( + '#wc-orders-filter, #posts-filter' + ); + const title = $( ':selected', this ).text(); + + window + .sendyOpenCreateShipmentsModal( { title } ) + .then( function ( fields ) { + if ( ! fields ) { + return; + } + + const container = $( '
' ) + .hide() + .appendTo( bulkActionsForm ); + + $.each( + { + sendy_shop_id: fields.shopId, + sendy_preference_id: fields.preferenceId, + sendy_amount: fields.amount, + sendy_bulk_modal_nonce: fields.nonce, + }, + function ( name, value ) { + container.append( + $( '', { type: 'hidden', name, value } ) ); } + ); - let nonce = thickboxWindow - .find( '#sendy_bulk_modal_nonce' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_bulk_modal_nonce', - value: nonce, - } ) - ); - - let amount = thickboxWindow - .find( '#sendy_amount' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_amount', - value: amount, - } ) - ); - - $( this ).prop( 'disabled', true ); - - bulkActionsForm.submit(); - } ); - } + bulkActionsForm.submit(); + } ); }, disableSubmitButton: function () { diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js new file mode 100644 index 0000000..d59952d --- /dev/null +++ b/resources/js/orders-list-print-flow.js @@ -0,0 +1,359 @@ +/** + * Create-and-print flow for the orders list. + * + * Checks which orders already have a Sendy shipment, creates shipments for + * the ones that do not (asking for shop/preference/amount through the same + * modal as the "Create shipments" bulk action), waits until the Sendy portal + * has generated the labels, and then prints them through sendyPrintLabels() + * from print-labels.js. Used by both the per-order button in the actions + * column and the "Sendy - Print labels" bulk action. + */ +( function () { + 'use strict'; + + const POLL_INTERVAL = 3000; + const POLL_TIMEOUT = 60000; + + const sleep = ( milliseconds ) => + new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) ); + + // The server flashes a notice explaining a failed request; the flow + // reloads to show it. + class AjaxError extends Error {} + + /** + * POST to admin-ajax for the given orders and decode the JSON response. + * + * @param {Object} params Form fields, including action. + * @param {Array} orderIds + * @return {Promise} + * @throws {AjaxError} + */ + const ajaxPost = async ( params, orderIds ) => { + const body = new URLSearchParams( params ); + for ( const orderId of orderIds ) { + body.append( 'order_ids[]', orderId ); + } + + const response = await fetch( ajaxurl, { method: 'POST', body } ); + const data = await response.json().catch( () => null ); + + if ( ! response.ok ) { + throw new AjaxError( + data?.message ?? `The ${ params.action } request failed` + ); + } + + return data; + }; + + /** + * Fetch the shipment status per order: none|pending|ready|failed. + * + * @param {Array} orderIds + * @param {boolean} flagUnprintable When true, asks the server + * to flash a notice for the + * orders it cannot print. + * @return {Promise>} + */ + const fetchStatuses = async ( orderIds, flagUnprintable = false ) => { + const params = { + action: 'sendy_shipments_status', + nonce: document.getElementById( 'sendy-print-labels-nonce' ).value, + }; + if ( flagUnprintable ) { + params.flash_unprintable = '1'; + } + + return ( await ajaxPost( params, orderIds ) ).statuses; + }; + + /** + * Create shipments for the given orders. + * + * @param {Array} orderIds + * @param {Object} fields The values collected from the modal + * @return {Promise<{created: number[]}>} + */ + const createShipments = ( orderIds, fields ) => + ajaxPost( + { + action: 'sendy_create_shipments', + nonce: fields.nonce, + shop_id: fields.shopId, + preference_id: fields.preferenceId, + amount: fields.amount, + }, + orderIds + ); + + /** + * Ask for the shop/preference/amount through the create-shipments + * thickbox modal. Resolves with the chosen values, or null when the + * modal is dismissed. This is the modal's only owner: admin-order-bulk.js + * calls it too for the "Create shipments" bulk action. + * + * @param {Object} options + * @param {string} options.title The thickbox window title. + * @param {string} [options.buttonText] Replaces the confirm button text + * until the modal closes. + * @return {Promise} + */ + window.sendyOpenCreateShipmentsModal = ( { title, buttonText } ) => + new Promise( ( resolve ) => { + if ( ! document.getElementById( 'sendy-create-shipments-modal' ) ) { + // The modal is replaced by a connection error notice when + // the Sendy API is unreachable. + resolve( null ); + return; + } + + tb_show( + '', + '/?TB_inline=true&width=420&height=320&inlineId=sendy-create-shipments-modal' + ); + + const thickboxWindow = jQuery( '#TB_window' ); + const button = thickboxWindow.find( + '#sendy-create-shipments-button' + ); + const originalButtonText = button.text(); + + thickboxWindow.find( '#TB_ajaxWindowTitle' ).text( title ); + + if ( buttonText ) { + button.text( buttonText ); + } + + // Thickbox moves the inline content back into the page when the + // modal closes; undo our changes for the next opening, and treat + // a dismissal as a cancel (a no-op when the confirm handler + // already resolved). + jQuery( 'body' ).one( 'thickbox:removed', function () { + button + .text( originalButtonText ) + .prop( 'disabled', false ) + .off( 'click.sendyModal' ); + resolve( null ); + } ); + + button + .off( 'click.sendyModal' ) + .on( 'click.sendyModal', function ( event ) { + event.preventDefault(); + + jQuery( this ).prop( 'disabled', true ); + + resolve( { + shopId: + thickboxWindow + .find( '#sendy_shop_id :selected' ) + .val() ?? '', + preferenceId: + thickboxWindow + .find( '#sendy_preference_id :selected' ) + .val() ?? '', + amount: + thickboxWindow.find( '#sendy_amount' ).val() ?? '', + nonce: thickboxWindow + .find( '#sendy_bulk_modal_nonce' ) + .val(), + } ); + + tb_remove(); + } ); + } ); + + /** + * Poll until the labels for the given orders are available and return + * the ids of the orders whose labels can be printed. Orders that end up + * without a shipment or with a failed shipment are dropped; orders still + * pending when the timeout passes are dropped too. + * + * @param {Array} orderIds + * @return {Promise} + */ + const waitUntilReady = async ( orderIds ) => { + const deadline = Date.now() + POLL_TIMEOUT; + let pending = orderIds; + const ready = []; + + while ( pending.length ) { + // On the last poll before the deadline, have the server flash a + // notice for the orders that are still pending afterwards. + const isLastPoll = Date.now() + POLL_INTERVAL > deadline; + const statuses = await fetchStatuses( pending, isLastPoll ); + + pending = []; + + for ( const [ orderId, status ] of Object.entries( statuses ) ) { + if ( status === 'ready' ) { + ready.push( orderId ); + } else if ( status === 'pending' ) { + pending.push( orderId ); + } + } + + if ( ! pending.length || isLastPoll ) { + break; + } + + await sleep( POLL_INTERVAL ); + } + + return ready; + }; + + /** + * The ids from a status map that have the wanted status. + * + * @param {Object} statuses + * @param {string} wanted + * @return {string[]} + */ + const idsWithStatus = ( statuses, wanted ) => + Object.keys( statuses ).filter( + ( orderId ) => statuses[ orderId ] === wanted + ); + + /** + * Print the labels of the orders that have one, if any. Asking for the + * labels of nothing fails the request and flashes a notice. + * + * @param {string[]} ready + */ + const printReady = async ( ready ) => { + if ( ready.length ) { + await window.sendyPrintLabels( ready ); + } + }; + + /** + * Print the labels that already exist and explain the orders that have + * none, without creating shipments or waiting for labels. Used for the + * Sendy processing method, where the portal generates the labels itself. + * + * @param {Array} orderIds + */ + const printExistingLabels = async ( orderIds ) => { + const statuses = await fetchStatuses( orderIds, true ); + const ready = idsWithStatus( statuses, 'ready' ); + + await printReady( ready ); + + if ( ready.length !== orderIds.length ) { + // Show the notices for the orders without a label. + window.location.reload(); + } + }; + + /** + * Print the labels of the given orders, creating shipments first for the + * orders that have none where the processing method allows it. + * + * @param {Array} orderIds + */ + const runPrintFlow = async ( orderIds ) => { + if ( sendyPrintFlow.printExistingOnly ) { + return printExistingLabels( orderIds ); + } + + const statuses = await fetchStatuses( orderIds ); + + const withoutShipment = idsWithStatus( statuses, 'none' ); + const ready = idsWithStatus( statuses, 'ready' ); + // Failed orders have flashed a notice; re-checking would re-flash it. + const pending = idsWithStatus( statuses, 'pending' ); + + let createdShipments = false; + + if ( withoutShipment.length ) { + const single = orderIds.length === 1; + const fields = await window.sendyOpenCreateShipmentsModal( { + title: single + ? sendyPrintFlow.modalTitleSingular + : sendyPrintFlow.modalTitle, + buttonText: single + ? sendyPrintFlow.modalButtonTextSingular + : sendyPrintFlow.modalButtonText, + } ); + + if ( ! fields ) { + return; + } + + const result = await createShipments( withoutShipment, fields ); + createdShipments = result.created.length > 0; + + if ( + ! result.created.length && + withoutShipment.length === orderIds.length + ) { + // Nothing printable; reload to show the flash notices. + window.location.reload(); + return; + } + + pending.push( ...result.created.map( String ) ); + } + + if ( pending.length ) { + ready.push( ...( await waitUntilReady( pending ) ) ); + } + + await printReady( ready ); + + if ( createdShipments || ready.length !== orderIds.length ) { + // Show the new shipment data and any notices for the orders that + // could not be printed. + window.location.reload(); + } + }; + + /** + * Print the labels of the given orders, creating shipments first where + * the processing method allows it. The returned promise never rejects, + * so callers can chain finally() without handling errors themselves. + * + * @param {Array} orderIds + * @return {Promise} + */ + window.sendyOrdersListPrintFlow = ( orderIds ) => + runPrintFlow( orderIds.map( String ) ).catch( ( error ) => { + console.error( 'Sendy: printing labels failed', error ); + + if ( error instanceof AjaxError ) { + window.location.reload(); + } + } ); + + // The per-order print buttons in the actions column. The href carries + // the id of the order the button was rendered for. + jQuery( document ).on( + 'click', + '.wc-action-button-sendy_print_label', + function ( event ) { + event.preventDefault(); + + const button = jQuery( this ); + + if ( button.hasClass( 'disabled' ) ) { + return; + } + + const orderId = button + .attr( 'href' ) + .replace( '#sendy-print-label-', '' ); + + button.addClass( 'disabled' ).attr( 'aria-disabled', 'true' ); + + window + .sendyOrdersListPrintFlow( [ orderId ] ) + .finally( () => + button + .removeClass( 'disabled' ) + .removeAttr( 'aria-disabled' ) + ); + } + ); +} )(); diff --git a/sendy.php b/sendy.php index a61d23b..d1feae2 100644 --- a/sendy.php +++ b/sendy.php @@ -4,13 +4,13 @@ * Plugin Name: Sendy * Plugin URI: https://app.sendy.nl/ * Description: A WooCommerce plugin that connects your site to the Sendy platform - * Version: 3.4.7 + * Version: 3.5.0 * Author: Sendy * Author URI: https://sendy.nl/ * License: MIT * Text Domain: sendy * Domain Path: /languages - * Requires at least: 5.2 + * Requires at least: 6.2 * Tested up to: 7.0 * Requires PHP: 7.4 * Requires Plugins: woocommerce diff --git a/tests/AssetVersionTest.php b/tests/AssetVersionTest.php new file mode 100644 index 0000000..44ea76c --- /dev/null +++ b/tests/AssetVersionTest.php @@ -0,0 +1,29 @@ +assertSame(Plugin::VERSION, Plugin::asset_version('/resources/js/does-not-exist.js')); + } + + public function test_existing_files_are_versioned_by_mtime_only_with_script_debug(): void + { + $path = '/resources/js/print-labels.js'; + + $expected = defined('SCRIPT_DEBUG') && SCRIPT_DEBUG + ? (string) filemtime(dirname(SENDY_WC_PLUGIN_FILE) . $path) + : Plugin::VERSION; + + $this->assertSame($expected, Plugin::asset_version($path)); + } +} diff --git a/tests/BulkActionsTest.php b/tests/BulkActionsTest.php new file mode 100644 index 0000000..1b21c0f --- /dev/null +++ b/tests/BulkActionsTest.php @@ -0,0 +1,66 @@ +fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'new-shipment', 'packages' => []], + ]))); + + $redirect = $this->handle([99, 1]); + + $this->assertSame('redirect', $redirect); + $this->assertSame('new-shipment', $order->get_meta('_sendy_shipment_id')); + } + + public function test_a_failing_order_does_not_abort_the_rest_of_the_batch(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + new Sendy_Fake_Order(1); + new Sendy_Fake_Order(2); + + // Without the sendy_shops transient every order fetches the shop + // list, a call that create_shipment() does not guard itself. + $transport = $this->fake_transport( + new Response(500, [], json_encode(['message' => 'Whoops'])), + new Response(500, [], json_encode(['message' => 'Whoops'])), + ); + + $this->handle([1, 2]); + + $this->assertCount(2, $transport->requests, 'The second order must still be attempted'); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(2, $messages); + $this->assertStringContainsString('order #1', $messages[0]['message']); + $this->assertStringContainsString('order #2', $messages[1]['message']); + } + + private function handle(array $orderIds): string + { + $_REQUEST['sendy_bulk_modal_nonce'] = wp_create_nonce('sendy_bulk_modal'); + $_REQUEST['sendy_shop_id'] = 'shop-1'; + $_REQUEST['sendy_preference_id'] = 'pref-1'; + $_REQUEST['sendy_amount'] = '2'; + + return (new BulkActions())->handle_bulk_action_create_shipments('redirect', 'sendy_create_shipments', $orderIds); + } +} diff --git a/tests/CreateShipmentsTest.php b/tests/CreateShipmentsTest.php new file mode 100644 index 0000000..df2f952 --- /dev/null +++ b/tests/CreateShipmentsTest.php @@ -0,0 +1,156 @@ + 'existing-shipment']); + + $packages = [['package_number' => '3S1', 'tracking_url' => 'https://example.com']]; + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'new-shipment', 'packages' => $packages], + ]))); + + $response = $this->dispatch(['order_ids' => [1, 2]]); + + $this->assertSame(['created' => [1]], $response, 'Only the order without a shipment is reported as created'); + + $this->assertSame('new-shipment', $orderWithoutShipment->get_meta('_sendy_shipment_id')); + $this->assertSame($packages, $orderWithoutShipment->get_meta('_sendy_packages')); + $this->assertTrue($orderWithoutShipment->was_saved()); + + $this->assertCount(1, $transport->requests, 'Orders that already have a shipment must not trigger an API call'); + $this->assertStringContainsString('/shipments/preference', $transport->lastRequest->getUrl()); + + // The shop and the preference are both UUID strings, so swapping them + // anywhere along the call chain would still type check. + $payload = json_decode($transport->lastRequest->getBody(), true); + $this->assertSame('shop-1', $payload['shop_id']); + $this->assertSame('pref-1', $payload['preference_id']); + $this->assertSame(2, $payload['amount']); + + $this->assertSame('shop-1', get_option('sendy_previously_used_shop_id')); + $this->assertSame('pref-1', get_option('sendy_previously_used_preference_id')); + $this->assertSame('2', get_option('sendy_previously_used_amount')); + } + + public function test_the_sendy_processing_method_is_refused(): void + { + // The portal creates and processes the shipments for this method, so + // the print flow never asks this endpoint to create one. Refusing + // rather than trusting the client keeps a browser running a cached + // older copy of the script from adding shipments anyway. + update_option('sendy_processing_method', ProcessingMethod::Sendy); + set_transient('sendy_shops', ['shop-1' => 'Shop']); + + $order = new Sendy_Fake_Order(1); + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'smart-shipment'], + ]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertArrayNotHasKey('created', $response); + $this->assertStringContainsString('created in Sendy', $response['message']); + $this->assertSame([], $transport->requests, 'No shipment may be created'); + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); + + // The print flow reloads on an error response, so the refusal must + // also be flashed to be seen. + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('created in Sendy', $messages[0]['message']); + } + + public function test_a_broken_connection_is_reported_as_an_error_response(): void + { + $order = new Sendy_Fake_Order(1); + + // The endpoint only exists when an access token is stored, but the + // other token options can be missing after a failed authentication. + update_option('sendy_access_token', 'access-token', false); + delete_option('sendy_refresh_token'); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('The plugin is not connected to Sendy. Check the settings page to reconnect.', $response['message']); + $this->assertArrayNotHasKey('created', $response); + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); + } + + public function test_orders_whose_creation_fails_are_not_reported_as_created(): void + { + $order = new Sendy_Fake_Order(1); + + $this->fake_transport(new Response(422, [], json_encode([ + 'message' => 'The given data was invalid', + 'errors' => ['postal_code' => ['The postal code is invalid']], + ]))); + + $response = $this->dispatch(['order_ids' => [1, 99]]); + + $this->assertSame([], $response['created'], 'Neither the failed nor the unresolvable order was created'); + + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + } + + public function test_an_invalid_nonce_is_rejected(): void + { + $response = $this->dispatch(['order_ids' => [1], 'nonce' => 'invalid']); + + $this->assertSame('Nonce verification failed', $response['message']); + } + + public function test_a_user_without_the_required_capabilities_is_rejected(): void + { + wp_set_current_user(self::factory()->user->create(['role' => 'editor'])); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('You do not have sufficient permissions to access this page.', $response['message']); + } + + /** + * Fire the AJAX endpoint and return the decoded JSON response. + * + * @param array $post + * @return array + */ + private function dispatch(array $post): array + { + return $this->dispatch_ajax('sendy_create_shipments', array_merge([ + 'nonce' => wp_create_nonce('sendy_bulk_modal'), + 'shop_id' => 'shop-1', + 'preference_id' => 'pref-1', + 'amount' => '2', + ], $post)); + } +} diff --git a/tests/PrintFlowAssetsTest.php b/tests/PrintFlowAssetsTest.php new file mode 100644 index 0000000..92749cd --- /dev/null +++ b/tests/PrintFlowAssetsTest.php @@ -0,0 +1,53 @@ +localizedData(); + + $this->assertStringContainsString('var sendyPrintFlow = ', $data); + $this->assertStringContainsString('"printExistingOnly":""', $data); + } + + public function test_the_sendy_processing_method_only_prints_existing_labels(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + $this->assertStringContainsString( + '"printExistingOnly":"1"', + $this->localizedData(), + ); + } + + private function localizedData(): string + { + $module = new BulkActions(); + $module->enqueue_assets(); + + $data = wp_scripts()->get_data('sendy-orders-list-print-flow', 'data'); + + $this->assertIsString($data, 'The print flow script must receive localized data'); + + return $data; + } +} diff --git a/tests/PrintLabelsTest.php b/tests/PrintLabelsTest.php index bc8402f..9103868 100644 --- a/tests/PrintLabelsTest.php +++ b/tests/PrintLabelsTest.php @@ -14,7 +14,7 @@ * print app needs, and marks orders as completed server-side after a * successful fetch when the setting asks for it. */ -class PrintLabelsTest extends WP_Ajax_UnitTestCase +class PrintLabelsTest extends Sendy_Ajax_TestCase { private PrintLabels $module; @@ -22,23 +22,7 @@ public function setUp(): void { parent::setUp(); - Sendy_Fake_Order::reset(); - $this->module = new PrintLabels(); - - $user = self::factory()->user->create(['role' => 'administrator']); - wp_set_current_user($user); - wp_get_current_user()->add_cap('manage_woocommerce'); - wp_get_current_user()->add_cap('edit_shop_orders'); - - delete_option('sendy_flash_admin_messages'); - } - - public function tearDown(): void - { - $this->set_api_connection(null); - - parent::tearDown(); } public function test_only_orders_with_a_shipment_contribute_their_shipment_id(): void @@ -101,7 +85,11 @@ public function test_successful_fetch_returns_the_api_response_unwrapped_with_a_ $orderWithShipment = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); $orderWithoutShipment = new Sendy_Fake_Order(2); - $transport = $this->fake_transport(200, ['x-sendy-token' => ['print-app-token']], ['labels' => 'BASE64PDF']); + $transport = $this->fake_transport(new Response( + 200, + ['x-sendy-token' => ['print-app-token']], + json_encode(['labels' => 'BASE64PDF']), + )); $response = $this->dispatch(['order_ids' => [1, 2]]); @@ -122,7 +110,7 @@ public function test_no_reload_is_requested_when_nothing_changed_server_side(): $order = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); - $this->fake_transport(200, [], ['labels' => 'BASE64PDF']); + $this->fake_transport(new Response(200, [], json_encode(['labels' => 'BASE64PDF']))); $response = $this->dispatch(['order_ids' => [1]]); @@ -136,7 +124,7 @@ public function test_api_failure_yields_a_502_with_a_flash_notice(): void $order = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); - $this->fake_transport(500, [], ['message' => 'Whoops']); + $this->fake_transport(new Response(500, [], json_encode(['message' => 'Whoops']))); $response = $this->dispatch(['order_ids' => [1]]); @@ -187,50 +175,8 @@ public function test_a_user_without_the_required_capabilities_is_rejected(): voi */ private function dispatch(array $post): array { - $_POST = array_merge([ - 'action' => 'sendy_print_labels', + return $this->dispatch_ajax('sendy_print_labels', array_merge([ 'nonce' => wp_create_nonce('sendy_print_labels'), - ], $post); - - - try { - $this->_handleAjax('sendy_print_labels'); - $this->fail('The AJAX handler was expected to send a JSON response and die'); - } catch (WPAjaxDieContinueException $exception) { - // wp_send_json() ends in an empty wp_die(), which the AJAX test - // case converts into this exception after buffering the output. - } - - return json_decode($this->_last_response, true); - } - - /** - * Point ApiClientFactory's memoized connection at a canned HTTP response. - */ - private function fake_transport(int $statusCode, array $headers, array $body): Sendy_Fake_Transport - { - $transport = new Sendy_Fake_Transport(new Response($statusCode, $headers, json_encode($body))); - - // buildConnectionUsingTokens() refuses to run with empty token options. - update_option('sendy_access_token', 'access-token', false); - update_option('sendy_refresh_token', 'refresh-token', false); - update_option('sendy_token_expires', time() + 3600, false); - - $this->set_api_connection( - (new Connection()) - ->setTransport($transport) - ->setAccessToken('access-token') - ->setRefreshToken('refresh-token') - ->setTokenExpires(time() + 3600) - ); - - return $transport; - } - - private function set_api_connection(?Connection $connection): void - { - $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); - $property->setAccessible(true); - $property->setValue(null, $connection); + ], $post)); } } diff --git a/tests/RowActionsTest.php b/tests/RowActionsTest.php new file mode 100644 index 0000000..6d9ca5a --- /dev/null +++ b/tests/RowActionsTest.php @@ -0,0 +1,64 @@ + 'shipment-1']); + + $actions = (new RowActions())->add_print_label_action(['complete' => ['action' => 'complete']], $order); + + $this->assertArrayHasKey('complete', $actions, 'Existing actions must be preserved'); + $this->assertSame([ + 'url' => '#sendy-print-label-1', + 'name' => 'Sendy - Print label', + 'action' => 'sendy_print_label', + ], $actions['sendy_print_label']); + } + + public function test_orders_without_a_shipment_get_a_create_and_print_action(): void + { + $order = new Sendy_Fake_Order(1); + + $actions = (new RowActions())->add_print_label_action([], $order); + + $this->assertSame([ + 'url' => '#sendy-print-label-1', + 'name' => 'Sendy - Create shipment and print label', + 'action' => 'sendy_print_label', + ], $actions['sendy_print_label']); + } + + public function test_the_sendy_processing_method_only_offers_to_print(): void + { + // The portal creates and processes the shipments for this method, so + // the button must not promise a create-and-print it will not do. + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + $actions = (new RowActions())->add_print_label_action([], new Sendy_Fake_Order(1)); + + $this->assertSame('Sendy - Print label', $actions['sendy_print_label']['name']); + } + + public function test_the_filter_is_registered(): void + { + $module = new RowActions(); + + $this->assertSame(10, has_filter('woocommerce_admin_order_actions', [$module, 'add_print_label_action'])); + } +} diff --git a/tests/Sendy_Ajax_TestCase.php b/tests/Sendy_Ajax_TestCase.php new file mode 100644 index 0000000..0e1a1b1 --- /dev/null +++ b/tests/Sendy_Ajax_TestCase.php @@ -0,0 +1,59 @@ +user->create(['role' => 'administrator']); + wp_set_current_user($user); + wp_get_current_user()->add_cap('manage_woocommerce'); + wp_get_current_user()->add_cap('edit_shop_orders'); + + update_option('sendy_processing_method', ProcessingMethod::WooCommerce); + delete_option('sendy_flash_admin_messages'); + } + + public function tearDown(): void + { + $this->set_api_connection(null); + + parent::tearDown(); + } + + /** + * Fire the AJAX endpoint and return the decoded JSON response. + * + * @param array $post + * @return array + */ + protected function dispatch_ajax(string $action, array $post): array + { + $_POST = array_merge(['action' => $action], $post); + + try { + $this->_handleAjax($action); + $this->fail('The AJAX handler was expected to send a JSON response and die'); + } catch (WPAjaxDieContinueException $exception) { + // wp_send_json() ends in an empty wp_die(), which the AJAX test + // case converts into this exception after buffering the output. + } + + return json_decode($this->_last_response, true); + } +} diff --git a/tests/ShipmentsStatusTest.php b/tests/ShipmentsStatusTest.php new file mode 100644 index 0000000..127483f --- /dev/null +++ b/tests/ShipmentsStatusTest.php @@ -0,0 +1,275 @@ +dispatch(['order_ids' => [1, 2]]); + + $this->assertSame(['1' => 'none', '2' => 'none'], $this->stringKeys($response['statuses'])); + } + + public function test_orders_with_packages_meta_are_ready_without_touching_the_api(): void + { + new Sendy_Fake_Order(1, [ + '_sendy_shipment_id' => 'shipment-1', + '_sendy_packages' => [['package_number' => '3S1', 'tracking_url' => 'https://example.com']], + ]); + + $transport = $this->fake_transport(new Response(200, [], json_encode([]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'ready'], $this->stringKeys($response['statuses'])); + $this->assertNull($transport->lastRequest, 'The API must not be called when the packages meta is present'); + } + + public function test_a_generated_shipment_is_ready_and_its_packages_are_persisted(): void + { + $order = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $packages = [['package_number' => '3S1', 'tracking_url' => 'https://example.com']]; + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'shipment-1', 'status' => 'generated', 'packages' => $packages], + ]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'ready'], $this->stringKeys($response['statuses'])); + $this->assertSame($packages, $order->get_meta('_sendy_packages')); + $this->assertTrue($order->was_saved()); + $this->assertStringContainsString('/shipments/shipment-1', $transport->lastRequest->getUrl()); + } + + public function test_new_and_pending_shipments_are_reported_as_pending(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + new Sendy_Fake_Order(2, ['_sendy_shipment_id' => 'shipment-2']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'new', 'packages' => []]])), + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-2', 'status' => 'pending', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1, 2]]); + + $this->assertSame(['1' => 'pending', '2' => 'pending'], $this->stringKeys($response['statuses'])); + } + + public function test_a_pending_shipment_flashes_no_notice_without_the_flash_unprintable_flag(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'pending', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'pending'], $this->stringKeys($response['statuses'])); + $this->assertFalse(get_option('sendy_flash_admin_messages')); + } + + public function test_a_pending_shipment_flashes_a_warning_notice_with_the_flash_unprintable_flag(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'pending', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1], 'flash_unprintable' => '1']); + + $this->assertSame(['1' => 'pending'], $this->stringKeys($response['statuses'])); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('warning', $messages[0]['type']); + $this->assertStringContainsString('still being generated', $messages[0]['message']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + } + + public function test_the_sendy_processing_method_explains_that_the_shipment_needs_processing(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'new', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1], 'flash_unprintable' => '1']); + + $this->assertSame(['1' => 'pending'], $this->stringKeys($response['statuses'])); + + // Waiting is pointless in this mode: the portal generates the label. + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(1, $messages); + $this->assertSame('warning', $messages[0]['type']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + $this->assertStringContainsString('Process the shipment in Sendy', $messages[0]['message']); + $this->assertStringNotContainsString('try again in a moment', $messages[0]['message']); + } + + public function test_several_unprintable_orders_are_counted_in_one_notice(): void + { + // A whole page of orders can be unprintable; naming each one would bury + // the page in notices. + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + new Sendy_Fake_Order(2); + new Sendy_Fake_Order(3, [ + '_sendy_shipment_id' => 'shipment-3', + '_sendy_packages' => [['package_number' => '3S1']], + ]); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'new', 'packages' => []]])), + ); + + $this->dispatch(['order_ids' => [1, 2, 3], 'flash_unprintable' => '1']); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(1, $messages); + $this->assertStringContainsString('for 2 orders', $messages[0]['message'], 'The printable order is not counted'); + $this->assertStringContainsString('Process the shipments in Sendy', $messages[0]['message']); + } + + public function test_several_pending_orders_are_counted_in_one_notice(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + new Sendy_Fake_Order(2, ['_sendy_shipment_id' => 'shipment-2']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'pending', 'packages' => []]])), + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-2', 'status' => 'pending', 'packages' => []]])), + ); + + $this->dispatch(['order_ids' => [1, 2], 'flash_unprintable' => '1']); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(1, $messages); + $this->assertStringContainsString('labels for 2 orders are still being generated', $messages[0]['message']); + } + + public function test_a_failed_shipment_is_reported_as_failed_with_a_flash_notice(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'shipment-1', 'status' => 'failed', 'packages' => []], + ]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'failed'], $this->stringKeys($response['statuses'])); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + $this->assertStringContainsString('failed', $messages[0]['message']); + } + + public function test_an_api_error_is_reported_as_failed_with_a_flash_notice(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport(new Response(500, [], json_encode(['message' => 'Whoops']))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'failed'], $this->stringKeys($response['statuses'])); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + } + + public function test_a_broken_connection_is_reported_as_an_error_response(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + // The endpoint only exists when an access token is stored, but the + // other token options can be missing after a failed authentication. + update_option('sendy_access_token', 'access-token', false); + delete_option('sendy_refresh_token'); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('The plugin is not connected to Sendy. Check the settings page to reconnect.', $response['message']); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + } + + public function test_an_invalid_nonce_is_rejected(): void + { + $response = $this->dispatch(['order_ids' => [1], 'nonce' => 'invalid']); + + $this->assertSame('Nonce verification failed', $response['message']); + } + + public function test_a_user_without_the_required_capabilities_is_rejected(): void + { + wp_set_current_user(self::factory()->user->create(['role' => 'editor'])); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('You do not have sufficient permissions to access this page.', $response['message']); + } + + /** + * Fire the AJAX endpoint and return the decoded JSON response. + * + * @param array $post + * @return array + */ + private function dispatch(array $post): array + { + return $this->dispatch_ajax('sendy_shipments_status', array_merge([ + 'nonce' => wp_create_nonce('sendy_print_labels'), + ], $post)); + } + + /** + * json_decode() turns numeric JSON object keys into PHP int keys; convert + * them back so assertions can use one canonical shape. + * + * @param array $statuses + * @return array + */ + private function stringKeys(array $statuses): array + { + $result = []; + + foreach ($statuses as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } +} diff --git a/tests/SingleCreateShipmentTest.php b/tests/SingleCreateShipmentTest.php new file mode 100644 index 0000000..5290864 --- /dev/null +++ b/tests/SingleCreateShipmentTest.php @@ -0,0 +1,95 @@ +fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'new-shipment', 'packages' => []], + ]))); + + $this->assertSame(['success' => true], $this->dispatch(['order_id' => '1'])); + + $this->assertSame('new-shipment', $order->get_meta('_sendy_shipment_id')); + $this->assertStringContainsString('/shipments/preference', $transport->lastRequest->getUrl()); + + // The shop and the preference are both UUID strings, so swapping them + // anywhere along the call chain would still type check. + $payload = json_decode($transport->lastRequest->getBody(), true); + $this->assertSame('shop-1', $payload['shop_id']); + $this->assertSame('pref-1', $payload['preference_id']); + $this->assertSame(3, $payload['amount']); + } + + public function test_the_shipment_is_created_with_smart_rules_for_the_sendy_processing_method(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + set_transient('sendy_shops', ['shop-1' => 'Shop']); + + $order = new Sendy_Fake_Order(1); + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'smart-shipment'], + ]))); + + $this->assertSame(['success' => true], $this->dispatch(['order_id' => '1'])); + + $this->assertSame('smart-shipment', $order->get_meta('_sendy_shipment_id')); + $this->assertStringContainsString('/shipments/smart-rule', $transport->lastRequest->getUrl()); + $this->assertSame('shop-1', json_decode($transport->lastRequest->getBody(), true)['shop_id']); + } + + public function test_a_failing_creation_reports_the_api_error(): void + { + $order = new Sendy_Fake_Order(1); + + $this->fake_transport(new Response(422, [], json_encode([ + 'message' => 'The given data was invalid', + 'errors' => ['postal_code' => ['The postal code is invalid']], + ]))); + + $this->dispatch(['order_id' => '1']); + + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('The postal code is invalid', $messages[0]['message']); + } + + /** + * Fire the AJAX endpoint and return the decoded JSON response. + * + * @param array $request + * @return array + */ + private function dispatch(array $request): array + { + return $this->dispatch_ajax('sendy_order_single_save_form', array_merge([ + 'nonce' => wp_create_nonce('sendy_create_shipment'), + 'shop_id' => 'shop-1', + 'preference_id' => 'pref-1', + 'amount' => '3', + ], $request)); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8bc77ac..cbcf600 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,6 +10,16 @@ require_once dirname(__DIR__) . '/vendor/autoload.php'; +// The plugin is not loaded as a WordPress plugin here, so Plugin::define_constants() +// never runs; define the ones the tested code reads. +if (! defined('SENDY_WC_PLUGIN_FILE')) { + define('SENDY_WC_PLUGIN_FILE', dirname(__DIR__) . '/sendy.php'); +} + +if (! defined('SENDY_WC_PLUGIN_DIR_URL')) { + define('SENDY_WC_PLUGIN_DIR_URL', 'https://example.com/wp-content/plugins/sendy'); +} + $_tests_dir = getenv('WP_TESTS_DIR'); if (! $_tests_dir) { @@ -33,3 +43,4 @@ // in its procedural helpers and the test doubles the tests rely on. require_once dirname(__DIR__) . '/lib/helpers.php'; require_once __DIR__ . '/doubles.php'; +require_once __DIR__ . '/Sendy_Ajax_TestCase.php'; diff --git a/tests/doubles.php b/tests/doubles.php index 2c9b3ee..47afef7 100644 --- a/tests/doubles.php +++ b/tests/doubles.php @@ -7,9 +7,11 @@ * minimal wc_get_order() backed by a registry of fake orders stands in for it. */ +use Sendy\Api\Connection; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; use Sendy\Api\Http\Transport\TransportInterface; +use Sendy\WooCommerce\ApiClientFactory; if (! function_exists('wc_get_order')) { function wc_get_order($order_id) @@ -18,7 +20,18 @@ function wc_get_order($order_id) } } -class Sendy_Fake_Order +if (! class_exists('WC_Order')) { + /** + * Minimal WC_Order stub for type hinting in the test environment. + * WooCommerce is not loaded in the test environment, so we provide + * a base class that the fake order can extend to satisfy type hints. + */ + class WC_Order + { + } +} + +class Sendy_Fake_Order extends WC_Order { /** @var array */ private static array $orders = []; @@ -68,6 +81,81 @@ public function get_meta(string $key) return $this->meta[$key] ?? ''; } + public function update_meta_data(string $key, $value): void + { + $this->meta[$key] = $value; + } + + public function get_order_number(): string + { + return (string) $this->id; + } + + public function get_date_created(): DateTimeImmutable + { + return new DateTimeImmutable('2026-01-01T10:00:00+00:00'); + } + + public function get_shipping_first_name(): string + { + return 'John'; + } + + public function get_shipping_last_name(): string + { + return 'Doe'; + } + + public function get_shipping_company(): string + { + return ''; + } + + public function get_shipping_country(): string + { + return 'NL'; + } + + public function get_shipping_address_1(): string + { + return 'Teststraat 12'; + } + + public function get_shipping_address_2(): string + { + return ''; + } + + public function get_shipping_postcode(): string + { + return '1234 AB'; + } + + public function get_shipping_city(): string + { + return 'Amsterdam'; + } + + public function get_shipping_phone(): string + { + return ''; + } + + public function get_billing_email(): string + { + return 'john@example.com'; + } + + public function get_items(): array + { + return []; + } + + public function get_shipping_methods(): array + { + return [new Sendy_Fake_Shipping_Method()]; + } + public function set_status(string $status, string $note = ''): void { $this->status = $status; @@ -95,22 +183,41 @@ public function was_saved(): bool } } +class Sendy_Fake_Shipping_Method +{ + public function get_instance_id(): int + { + return 1; + } +} + class Sendy_Fake_Transport implements TransportInterface { - private Response $response; + /** @var Response[] */ + private array $responses; public ?Request $lastRequest = null; - public function __construct(Response $response) + /** @var Request[] */ + public array $requests = []; + + public function __construct(Response ...$responses) { - $this->response = $response; + $this->responses = $responses; } public function send(Request $request): Response { $this->lastRequest = $request; + $this->requests[] = $request; + + if ($this->responses === []) { + throw new LogicException('The fake transport received a request but no response was queued.'); + } - return $this->response; + // The last queued response repeats, so polling tests do not need to + // queue one response per call. + return count($this->responses) > 1 ? array_shift($this->responses) : $this->responses[0]; } public function getUserAgent(): string @@ -118,3 +225,38 @@ public function getUserAgent(): string return 'PHPUnit'; } } + +/** + * Points ApiClientFactory's memoized connection at canned HTTP responses. + * + * Call set_api_connection(null) in tearDown() so the connection cannot leak + * into other tests. + */ +trait Sendy_Fakes_Api_Connection +{ + protected function fake_transport(Response ...$responses): Sendy_Fake_Transport + { + $transport = new Sendy_Fake_Transport(...$responses); + + update_option('sendy_access_token', 'access-token', false); + update_option('sendy_refresh_token', 'refresh-token', false); + update_option('sendy_token_expires', time() + 3600, false); + + $this->set_api_connection( + (new Connection()) + ->setTransport($transport) + ->setAccessToken('access-token') + ->setRefreshToken('refresh-token') + ->setTokenExpires(time() + 3600) + ); + + return $transport; + } + + protected function set_api_connection(?Connection $connection): void + { + $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); + $property->setAccessible(true); + $property->setValue(null, $connection); + } +}