diff --git a/Makefile b/Makefile index 88df650..3f2a1ff 100644 --- a/Makefile +++ b/Makefile @@ -126,7 +126,8 @@ zip: rsync -a $(RSYNC_EXCLUDES) ./ $(STAGE_DIR)/ cp composer.json $(STAGE_DIR)/ @if [ -f composer.lock ]; then cp composer.lock $(STAGE_DIR)/; fi - cd $(STAGE_DIR) && composer install --no-dev --optimize-autoloader --no-interaction 2>/dev/null; rm -f $(STAGE_DIR)/composer.json $(STAGE_DIR)/composer.lock + (cd $(STAGE_DIR) && composer install --no-dev --optimize-autoloader --no-interaction 2>/dev/null) + rm -f $(STAGE_DIR)/composer.lock $(STAGE_DIR)/vendor/bin/.phpunit.result.cache @if [ "$(KIRIOF_ENV)" != "prd" ] && [ -f .env ]; then \ API_URL=$$(grep '^$(ENV_VAR_NAME)=' .env | head -1 | cut -d= -f2- | xargs); \ if [ -n "$$API_URL" ]; then \ diff --git a/inc/Controllers/CheckoutController.php b/inc/Controllers/CheckoutController.php index 196eb70..f1e5c24 100644 --- a/inc/Controllers/CheckoutController.php +++ b/inc/Controllers/CheckoutController.php @@ -635,14 +635,13 @@ function add_custom_select_options_field_and_script_shipping() } private function kiriof_get_posted_text_field( string $key ): string { - // phpcs:disable WordPress.Security.NonceVerification.Missing -- Read-only checkout POST normalization; WooCommerce verifies checkout/update-order-review requests. + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only checkout POST normalization; WooCommerce verifies checkout/update-order-review requests. if ( ! isset( $_POST[ $key ] ) || is_array( $_POST[ $key ] ) ) { - // phpcs:enable WordPress.Security.NonceVerification.Missing return ''; } + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only checkout POST normalization; WooCommerce verifies checkout/update-order-review requests. $value = sanitize_text_field( wp_unslash( $_POST[ $key ] ) ); - // phpcs:enable WordPress.Security.NonceVerification.Missing return $value; } diff --git a/inc/Controllers/SettingController.php b/inc/Controllers/SettingController.php index a28695f..c7da36c 100644 --- a/inc/Controllers/SettingController.php +++ b/inc/Controllers/SettingController.php @@ -364,9 +364,6 @@ function storeCourierWhitelist() { 'origin_whitelist_expedition_name'=> $whitelist_names, )); - // Bust the couriers API cache so the updated whitelist is reflected immediately. - ( new \KiriminAjaOfficial\Services\KiriminajaApiService() )->invalidateCouriersCache(); - wp_send_json_success(['status' => 200, 'message' => 'Saved']); }catch (Throwable $e){ wp_send_json_error(['status'=>400,'message'=>$e->getMessage()]); diff --git a/inc/Controllers/ShippingDiscountCouponController.php b/inc/Controllers/ShippingDiscountCouponController.php index 57143d2..dcdfa71 100644 --- a/inc/Controllers/ShippingDiscountCouponController.php +++ b/inc/Controllers/ShippingDiscountCouponController.php @@ -947,11 +947,11 @@ public function flushCouriersCache() { } $service = new KiriminajaApiService(); - $service->invalidateCouriersCache(); + $service->invalidateCouriersCache( false ); $fresh = $service->get_couriers(); - if ( 200 !== $fresh->status ) { - wp_send_json_error( array( 'message' => __( 'Flushed, but could not re-fetch from API. Will retry on next page load.', 'kiriminaja-official' ) ) ); + if ( 200 !== $fresh->status || 'courier_cache_fallback' === $fresh->customCode ) { + wp_send_json_error( array( 'message' => __( 'Could not refresh couriers from the API. The last successful courier list remains available.', 'kiriminaja-official' ) ) ); } $count = is_array( $fresh->data ) ? count( $fresh->data ) : 0; diff --git a/inc/Controllers/ShippingProcessController.php b/inc/Controllers/ShippingProcessController.php index 7d924aa..aeb522f 100644 --- a/inc/Controllers/ShippingProcessController.php +++ b/inc/Controllers/ShippingProcessController.php @@ -119,6 +119,7 @@ private function markTransactionsPrinted( array $orderIds ) // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholder list is generated from sanitized order IDs above. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Print status is updated immediately after successful label fetch. $wpdb->query( + // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic IN placeholders match the sanitized order IDs in $query_args. $wpdb->prepare( "UPDATE {$wpdb->prefix}kiriminaja_transactions SET is_printed = %d, printed_at = %s diff --git a/inc/Services/KiriminajaApiService.php b/inc/Services/KiriminajaApiService.php index bf692fb..b6f444a 100644 --- a/inc/Services/KiriminajaApiService.php +++ b/inc/Services/KiriminajaApiService.php @@ -42,6 +42,7 @@ public function getTracking($order_id) return self::success($repo['data']); } private const KIRIOF_COURIERS_CACHE_KEY = 'kiriof_couriers_list_v2'; + private const KIRIOF_COURIERS_LAST_SUCCESS_CACHE_KEY = 'kiriof_couriers_last_success_cache'; private const KIRIOF_COURIERS_CACHE_TTL = DAY_IN_SECONDS; public function get_couriers(){ @@ -52,6 +53,17 @@ public function get_couriers(){ $repo = (new \KiriminAjaOfficial\Repositories\KiriminajaApiRepository())->get_couriers(); if ( empty( $repo['status'] ) || ! is_object( $repo['data'] ?? null ) || empty( $repo['data']->status ) ) { + $last_success = get_transient( self::KIRIOF_COURIERS_LAST_SUCCESS_CACHE_KEY ); + if ( false !== $last_success ) { + kiriof_log( + 'warning', + 'Courier lookup used the cached fallback because the live API request failed.', + array( 'source' => 'kiriminaja_api' ) + ); + set_transient( self::KIRIOF_COURIERS_CACHE_KEY, $last_success, self::KIRIOF_COURIERS_CACHE_TTL ); + return self::success( $last_success, 'Using cached courier data.', 'courier_cache_fallback' ); + } + return self::error( array(), $this->extractErrorMessage( $repo, 'Something is wrong' ) ); } @@ -66,11 +78,15 @@ function ( $courier ) use ( $excluded_types ) { ) ); set_transient( self::KIRIOF_COURIERS_CACHE_KEY, $data, self::KIRIOF_COURIERS_CACHE_TTL ); + set_transient( self::KIRIOF_COURIERS_LAST_SUCCESS_CACHE_KEY, $data, WEEK_IN_SECONDS ); return self::success( $data ); } - public function invalidateCouriersCache(): void { + public function invalidateCouriersCache( bool $include_last_success = true ): void { delete_transient( self::KIRIOF_COURIERS_CACHE_KEY ); + if ( $include_last_success ) { + delete_transient( self::KIRIOF_COURIERS_LAST_SUCCESS_CACHE_KEY ); + } } public function invalidateProfileCache(): void { @@ -216,4 +232,4 @@ private function extractErrorMessage($repo, string $fallback): string { return $fallback; } -} \ No newline at end of file +} diff --git a/inc/Services/SettingService.php b/inc/Services/SettingService.php index ce8b923..3ba6e7a 100644 --- a/inc/Services/SettingService.php +++ b/inc/Services/SettingService.php @@ -50,6 +50,7 @@ public function processingSetupKey($setupKey) /** Storing result to DB*/ $arrayRepoDataData = (array) $arrayRepoData['result']; (new \KiriminAjaOfficial\Services\KiriminajaApiService())->invalidateProfileCache(); + (new \KiriminAjaOfficial\Services\KiriminajaApiService())->invalidateCouriersCache(); (new \KiriminAjaOfficial\Repositories\SettingRepository())->storeIntegrationData([ 'api_key' => sanitize_text_field($arrayRepoDataData['api_key']), 'oid_prefix' => sanitize_text_field($arrayRepoDataData['oid_prefix']), @@ -82,6 +83,7 @@ public function disconnectIntegration() { try { (new \KiriminAjaOfficial\Services\KiriminajaApiService())->invalidateProfileCache(); + (new \KiriminAjaOfficial\Services\KiriminajaApiService())->invalidateCouriersCache(); $repo = (new \KiriminAjaOfficial\Repositories\SettingRepository())->disconnectIntegration(); kiriof_log( 'notice', diff --git a/lang/kiriminaja-official-id_ID.po b/lang/kiriminaja-official-id_ID.po index 0841662..12ddca0 100644 --- a/lang/kiriminaja-official-id_ID.po +++ b/lang/kiriminaja-official-id_ID.po @@ -13,6 +13,22 @@ msgstr "" "Plural-Forms: nplurals=1; plural=0;\n" "X-Domain: kiriminaja-official\n" +#: templates/setting/setuped/section-couriers.php +msgid "Could not load couriers. Reload this page and try again." +msgstr "Tidak dapat memuat kurir. Muat ulang halaman ini dan coba lagi." + +#: inc/Controllers/ShippingDiscountCouponController.php +msgid "Could not refresh couriers from the API. The last successful courier list remains available." +msgstr "Tidak dapat memperbarui kurir dari API. Daftar kurir terakhir yang berhasil masih tersedia." + +#: templates/setting/setuped/section-couriers.php +msgid "Could not save courier settings." +msgstr "Tidak dapat menyimpan pengaturan kurir." + +#: templates/setting/setuped/section-couriers.php +msgid "No couriers are available for this account." +msgstr "Tidak ada kurir yang tersedia untuk akun ini." + #. translators: %s: total number of items #: templates/transaction-process/view/index.php:86 #: templates/transaction-process/view/index.php:283 diff --git a/templates/request-pickup/index.php b/templates/request-pickup/index.php index 0256c82..ff8d19c 100644 --- a/templates/request-pickup/index.php +++ b/templates/request-pickup/index.php @@ -56,8 +56,7 @@ private function pageQuery(){ $items_per_page = 20; // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $page = isset($_GET['cpage']) ? absint($_GET['cpage']) : 1; - $offset = ( $page * $items_per_page ) - $items_per_page; + $page = isset($_GET['cpage']) ? max( 1, absint($_GET['cpage']) ) : 1; $whereConditions = []; @@ -72,18 +71,36 @@ private function pageQuery(){ $whereConditions[] = $wpdb->prepare("kiriminaja_payments.created_at LIKE %s", '%' . $wpdb->esc_like($month) . '%'); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if (!empty(sanitize_text_field(wp_unslash($_GET['status'] ?? '')))) { - $status = sanitize_text_field(wp_unslash($_GET['status'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $status = sanitize_text_field(wp_unslash($_GET['status'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( in_array( $status, array( 'unpaid', 'paid' ), true ) ) { $whereConditions[] = $wpdb->prepare("kiriminaja_payments.status = %s", $status); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Each condition is individually prepared above $whereCondition = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : ''; + /** Pagination Query*/ + $count_sql = "SELECT + kiriminaja_payments.id, kiriminaja_payments.pickup_number + FROM {$wpdb->prefix}kiriminaja_payments as kiriminaja_payments + INNER JOIN {$wpdb->prefix}kiriminaja_transactions as kiriminaja_transactions + ON kiriminaja_payments.pickup_number = kiriminaja_transactions.pickup_number + " . $whereCondition . " + GROUP BY kiriminaja_payments.pickup_number"; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query safe: whereCondition contains pre-prepared statements, no additional params needed + $totalQuery = $wpdb->get_results($count_sql); + $total = count($totalQuery); + $total_pages = (int) ceil($total/$items_per_page); + if ( $page > $total_pages && $total_pages > 0 ) { + $page = $total_pages; + } + $offset = ( $page - 1 ) * $items_per_page; + /** Main Query*/ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names use prefix, whereCondition pre-prepared above - $baseQuery = "SELECT - kiriminaja_payments.*, + $baseQuery = "SELECT + kiriminaja_payments.*, SUM(CASE WHEN kiriminaja_transactions.cod_fee = 0 THEN kiriminaja_transactions.shipping_cost - COALESCE(kiriminaja_transactions.discount_amount, 0) + kiriminaja_transactions.insurance_cost ELSE 0 END) AS cost FROM {$wpdb->prefix}kiriminaja_payments as kiriminaja_payments INNER JOIN {$wpdb->prefix}kiriminaja_transactions as kiriminaja_transactions @@ -94,28 +111,14 @@ private function pageQuery(){ LIMIT %d, %d"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - $results = $wpdb->get_results( + $results = $wpdb->get_results( $wpdb->prepare($baseQuery, $offset, $items_per_page) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); - + if (strlen(@$wpdb->last_error ?? '') > 0){ (new \KiriminAjaOfficial\Base\BaseInit())->logThis('last_error',@$wpdb->last_error); } - /** Pagination Query*/ - $count_sql = "SELECT - kiriminaja_payments.id, kiriminaja_payments.pickup_number - FROM {$wpdb->prefix}kiriminaja_payments as kiriminaja_payments - INNER JOIN {$wpdb->prefix}kiriminaja_transactions as kiriminaja_transactions - ON kiriminaja_payments.pickup_number = kiriminaja_transactions.pickup_number - " . $whereCondition . " - GROUP BY kiriminaja_payments.pickup_number"; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query safe: whereCondition contains pre-prepared statements, no additional params needed - $totalQuery = $wpdb->get_results($count_sql); - $total = count($totalQuery); - $total_pages = ceil($total/$items_per_page); - /** Paginate*/ $next_page_link = admin_url( 'admin.php?' ); $prev_page_link = admin_url( 'admin.php?' ); diff --git a/templates/setting/setuped/section-couriers.php b/templates/setting/setuped/section-couriers.php index a7dc2ef..252c56a 100644 --- a/templates/setting/setuped/section-couriers.php +++ b/templates/setting/setuped/section-couriers.php @@ -1,5 +1,5 @@
-
-
@@ -35,8 +33,149 @@ - - jQuery(document).ready(function($){var $list=$('#kiriof-courier-list'),$status=$('.kj-courier-status'),allCouriers=[],whitelistSet={};function render(){var h='';allCouriers.forEach(function(c){var e=whitelistSet.hasOwnProperty(c.code);h+='
'+escHtml(c.name)+'
'+escHtml(c.type||'')+'
'});$list.html(h||'
');updateStatus()}function updateStatus(){var c=Object.keys(whitelistSet).length;$status.text(kiriofEnabledCountText(c,allCouriers.length))}function kiriofEnabledCountText(count,total){return ''.replace('%1$s',count).replace('%2$s',total)}function escHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s||''));return d.innerHTML}function escAttr(s){return(s||'').replace(/"/g,'"').replace(/'/g,''')}function saveW(){var ids=Object.keys(whitelistSet),names=ids.map(function(id){return whitelistSet[id]});return jQuery.ajax({type:'post',url:kiriofAjaxRoute(),data:{action:'kiriof_store_courier_whitelist',data:{whitelist_ids:ids.join(','),whitelist_names:names.join(','),nonce:kiriofAjax.nonce}}})}jQuery.ajax({type:'post',url:kiriofAjaxRoute(),data:{action:'kiriof_get_courier_whitelist',data:{nonce:kiriofAjax.nonce}},complete:function(r){var p=kiriofParseAjaxResponse(r);if(p&&p.status===200&&p.data){allCouriers=p.data.couriers||[];(p.data.whitelist_ids||[]).forEach(function(id){whitelistSet[id]=true});allCouriers.forEach(function(c){if(whitelistSet.hasOwnProperty(c.code))whitelistSet[c.code]=c.name})}render()},error:function(){$list.html('
')}});$list.on('change','.kj-courier-toggle',function(){var $t=jQuery(this),code=$t.data('code'),name=$t.data('name');$t.prop('disabled',true);if($t.is(':checked'))whitelistSet[code]=name;else delete whitelistSet[code];saveW().always(function(){$t.prop('disabled',false)}).done(function(){updateStatus()}).fail(function(){if($t.is(':checked'))delete whitelistSet[code];else whitelistSet[code]=name;$t.prop('checked',!$t.is(':checked'));updateStatus()})});jQuery('.kj-courier-enable-all').on('click',function(){allCouriers.forEach(function(c){whitelistSet[c.code]=c.name});render();saveW()});jQuery('.kj-courier-disable-all').on('click',function(){whitelistSet={};render();saveW()})}); + jQuery(document).ready(function($) { + var $list = $('#kiriof-courier-list'); + var $status = $('.kj-courier-status'); + var allCouriers = []; + var whitelistSet = {}; + + function escHtml(value) { + var element = document.createElement('div'); + element.appendChild(document.createTextNode(value || '')); + return element.innerHTML; + } + + function escAttr(value) { + return (value || '').replace(/"/g, '"').replace(/'/g, '''); + } + + function enabledCountText(count, total) { + + return '' + .replace('%1$s', count) + .replace('%2$s', total); + } + + function updateStatus() { + $status.text(enabledCountText(Object.keys(whitelistSet).length, allCouriers.length)); + } + + function render() { + var html = ''; + allCouriers.forEach(function(courier) { + var enabled = Object.prototype.hasOwnProperty.call(whitelistSet, courier.code); + html += '
'; + html += '
' + escHtml(courier.name) + '
'; + html += '
' + escHtml(courier.type || '') + '
'; + html += '
'; + }); + $list.html(html || '
'); + updateStatus(); + } + + function renderError(message) { + var text = message || ''; + $list.html('

' + escHtml(text) + '

'); + $status.text(''); + } + + function renderSaveError(message) { + $status.css('color', '#b32d2e').text(message || ''); + } + + function saveWhitelist() { + var ids = Object.keys(whitelistSet); + var names = ids.map(function(id) { + return whitelistSet[id]; + }); + return $.ajax({ + type: 'post', + url: kiriofAjaxRoute(), + data: { + action: 'kiriof_store_courier_whitelist', + data: { + whitelist_ids: ids.join(','), + whitelist_names: names.join(','), + nonce: kiriofAjax.nonce + } + } + }); + } + + $.ajax({ + type: 'post', + url: kiriofAjaxRoute(), + data: { + action: 'kiriof_get_courier_whitelist', + data: { nonce: kiriofAjax.nonce } + } + }).done(function(response, textStatus, request) { + var parsed = kiriofParseAjaxResponse(request); + if (!parsed || parsed.status !== 200 || !parsed.data) { + renderError(parsed && parsed.message); + return; + } + + allCouriers = parsed.data.couriers || []; + (parsed.data.whitelist_ids || []).forEach(function(id) { + whitelistSet[id] = true; + }); + allCouriers.forEach(function(courier) { + if (whitelistSet[courier.code]) { + whitelistSet[courier.code] = courier.name; + } + }); + render(); + }).fail(function(request) { + var parsed = kiriofParseAjaxResponse(request); + renderError(parsed && parsed.message); + }); + + $list.on('change', '.kj-courier-toggle', function() { + var $toggle = $(this); + var code = String($toggle.data('code')); + var previous = Object.assign({}, whitelistSet); + if ($toggle.is(':checked')) { + whitelistSet[code] = String($toggle.data('name')); + } else { + delete whitelistSet[code]; + } + updateStatus(); + saveWhitelist().fail(function(request) { + whitelistSet = previous; + render(); + var parsed = kiriofParseAjaxResponse(request); + renderSaveError(parsed && parsed.message); + }); + }); + + $('.kj-courier-enable-all').on('click', function() { + var previous = Object.assign({}, whitelistSet); + allCouriers.forEach(function(courier) { + whitelistSet[courier.code] = courier.name; + }); + render(); + saveWhitelist().fail(function(request) { + whitelistSet = previous; + render(); + var parsed = kiriofParseAjaxResponse(request); + renderSaveError(parsed && parsed.message); + }); + }); + + $('.kj-courier-disable-all').on('click', function() { + var previous = Object.assign({}, whitelistSet); + whitelistSet = {}; + render(); + saveWhitelist().fail(function(request) { + whitelistSet = previous; + render(); + var parsed = kiriofParseAjaxResponse(request); + renderSaveError(parsed && parsed.message); + }); + }); + }); 0 && $kiriof_per_page_get !== $kiriof_per_page ) { - $kiriof_per_page = $kiriof_per_page_get; + $kiriof_per_page = min( $kiriof_per_page_get, 100 ); update_user_meta( $user->ID, 'kiriof_transactions_per_page', $kiriof_per_page ); } @@ -54,6 +55,11 @@ function __construct() $kiriof_results = $pageQuery['results']; $kiriof_total = $pageQuery['total']; $kiriof_total_pages = (int) ceil( $kiriof_total / $kiriof_per_page ); + if ( $kiriof_current_page > $kiriof_total_pages && $kiriof_total_pages > 0 ) { + $kiriof_current_page = $kiriof_total_pages; + $pageQuery = $this->pageQuery( $kiriof_per_page, $kiriof_current_page ); + $kiriof_results = $pageQuery['results']; + } (new \KiriminAjaOfficial\Base\BaseInit())->logThis('$kiriof_results', [$kiriof_results]); /** Month Options */ diff --git a/tests/AdminCourierShipmentResilienceTest.php b/tests/AdminCourierShipmentResilienceTest.php new file mode 100644 index 0000000..031f820 --- /dev/null +++ b/tests/AdminCourierShipmentResilienceTest.php @@ -0,0 +1,91 @@ +assertStringContainsString('KIRIOF_COURIERS_LAST_SUCCESS_CACHE_KEY', $content); + $this->assertStringContainsString( + 'get_transient( self::KIRIOF_COURIERS_LAST_SUCCESS_CACHE_KEY )', + $content + ); + $this->assertStringContainsString( + 'set_transient( self::KIRIOF_COURIERS_LAST_SUCCESS_CACHE_KEY, $data, WEEK_IN_SECONDS )', + $content + ); + $this->assertStringContainsString("'courier_cache_fallback'", $content); + } + + #[Test] + public function whitelist_save_does_not_delete_remote_courier_catalog_cache(): void + { + $content = file_get_contents(PLUGIN_DIR . '/inc/Controllers/SettingController.php'); + $start = strpos($content, 'function storeCourierWhitelist()'); + $end = strpos($content, 'function storeInsuranceData()', $start); + $method = substr($content, $start, $end - $start); + + $this->assertStringNotContainsString( + 'invalidateCouriersCache', + $method, + 'Saving a local whitelist must not force a remote API cache miss' + ); + } + + #[Test] + public function manual_courier_refresh_keeps_fallback_data_until_live_fetch_succeeds(): void + { + $content = file_get_contents(PLUGIN_DIR . '/inc/Controllers/ShippingDiscountCouponController.php'); + + $this->assertStringContainsString('invalidateCouriersCache( false )', $content); + $this->assertStringContainsString("'courier_cache_fallback' === \$fresh->customCode", $content); + $this->assertStringContainsString('The last successful courier list remains available.', $content); + } + + #[Test] + public function courier_screen_displays_ajax_errors_instead_of_an_empty_state(): void + { + $content = file_get_contents(PLUGIN_DIR . '/templates/setting/setuped/section-couriers.php'); + + $this->assertStringContainsString('function renderError(message)', $content); + $this->assertStringContainsString('.fail(function(request)', $content); + $this->assertStringContainsString('Could not load couriers. Reload this page and try again.', $content); + } + + #[Test] + public function transaction_list_bounds_page_size_and_requeries_the_last_valid_page(): void + { + $content = file_get_contents(PLUGIN_DIR . '/templates/transaction-process/index.php'); + + $this->assertStringContainsString('min( $kiriof_per_page, 100 )', $content); + $this->assertStringContainsString('min( $kiriof_per_page_get, 100 )', $content); + $this->assertStringContainsString( + '$kiriof_current_page > $kiriof_total_pages && $kiriof_total_pages > 0', + $content + ); + $this->assertStringContainsString( + '$this->pageQuery( $kiriof_per_page, $kiriof_current_page )', + $content + ); + } + + #[Test] + public function payment_list_clamps_page_and_whitelists_status_before_querying(): void + { + $content = file_get_contents(PLUGIN_DIR . '/templates/request-pickup/index.php'); + + $this->assertStringContainsString("in_array( \$status, array( 'unpaid', 'paid' ), true )", $content); + $this->assertStringContainsString('$page > $total_pages && $total_pages > 0', $content); + $this->assertStringContainsString('$offset = ( $page - 1 ) * $items_per_page;', $content); + $this->assertLessThan( + strpos($content, '/** Main Query*/'), + strpos($content, '$total_pages = (int) ceil'), + 'Payment total and valid page must be resolved before the paginated query runs' + ); + } +} diff --git a/tests/PluginStructureTest.php b/tests/PluginStructureTest.php index 358c2fa..c1f4c9f 100644 --- a/tests/PluginStructureTest.php +++ b/tests/PluginStructureTest.php @@ -204,4 +204,21 @@ public function no_php_files_in_build_differ_from_source(): void "Build files differ from source (run `make zip` to sync):\n" . implode("\n", $violations) ); } + + #[Test] + public function packaged_vendor_keeps_composer_manifest(): void + { + $buildDir = PLUGIN_DIR . '/build/kiriminaja-official'; + if (!is_dir($buildDir)) { + $this->markTestSkipped('Build directory does not exist; run `make zip` first.'); + } + + $this->assertDirectoryExists($buildDir . '/vendor'); + $this->assertFileExists( + $buildDir . '/composer.json', + 'Packaged Composer vendor files need composer.json for Plugin Check attribution' + ); + $this->assertFileDoesNotExist($buildDir . '/composer.lock'); + $this->assertFileDoesNotExist($buildDir . '/vendor/bin/.phpunit.result.cache'); + } }