From 7c8efc8307997ebc16cfb70d012dd8d0e11eeee1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 1 Feb 2026 10:05:28 -0700 Subject: [PATCH 1/6] Skip loading ~35 admin pages on frontend and cron requests Admin pages that only register admin menus, forms, and wp_ajax_ handlers are now gated behind is_admin() || wp_doing_ajax(). Four classes with frontend hooks (admin bar, nav menu filters, SSO magic links) continue to load on all requests. Also moves the event badge count query from init to admin_init so it no longer runs on frontend or cron requests. Saves ~100-150ms per non-admin request based on Xdebug profiling. Co-Authored-By: Claude Opus 4.5 --- .../class-event-list-admin-page.php | 2 +- inc/class-wp-ultimo.php | 106 +++++------------- 2 files changed, 30 insertions(+), 78 deletions(-) diff --git a/inc/admin-pages/class-event-list-admin-page.php b/inc/admin-pages/class-event-list-admin-page.php index f7c0323f..feff7a08 100644 --- a/inc/admin-pages/class-event-list-admin-page.php +++ b/inc/admin-pages/class-event-list-admin-page.php @@ -64,7 +64,7 @@ class Event_List_Admin_Page extends List_Admin_Page { */ public function init(): void { - add_action('init', [$this, 'set_badge_count']); + add_action('admin_init', [$this, 'set_badge_count']); } /** diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 1ff69b9a..9eee0c2a 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -670,167 +670,119 @@ function () { */ protected function load_admin_pages(): void { /* - * Migration Wizard Alert - */ - new WP_Ultimo\Admin_Pages\Migration_Alert_Admin_Page(); - - /* - * Loads the Dashboard admin page. - */ - new WP_Ultimo\Admin_Pages\Dashboard_Admin_Page(); - - /* - * The top admin navigation bar. + * These classes register hooks that fire on both frontend and admin + * (admin bar menus, nav menu filters), so they must always be loaded. */ new WP_Ultimo\Admin_Pages\Top_Admin_Nav_Menu(); - /* - * Initialize magic links for admin bar My Sites menu. - */ \WP_Ultimo\SSO\Admin_Bar_Magic_Links::get_instance(); + \WP_Ultimo\SSO\Nav_Menu_Subsite_Links::get_instance(); + /* - * Initialize subsite links for nav menus. + * My_Sites registers an admin_bar_menu hook for customer-owned sites, + * which fires on frontend for logged-in users. */ - \WP_Ultimo\SSO\Nav_Menu_Subsite_Links::get_instance(); + new WP_Ultimo\Admin_Pages\Customer_Panel\My_Sites_Admin_Page(); /* - * Loads the Checkout Form admin page. + * The remaining admin pages only register admin menu items, + * admin-only forms, and wp_ajax_ handlers. They are not needed + * on frontend requests. */ + if (is_admin() || wp_doing_ajax()) { + $this->load_admin_only_pages(); + } + + do_action('wp_ultimo_admin_pages'); + } + + /** + * Loads admin pages that are only needed on admin or AJAX requests. + * + * @since 2.5.0 + * @return void + */ + protected function load_admin_only_pages(): void { + + new WP_Ultimo\Admin_Pages\Migration_Alert_Admin_Page(); + + new WP_Ultimo\Admin_Pages\Dashboard_Admin_Page(); + new WP_Ultimo\Admin_Pages\Checkout_Form_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Checkout_Form_Edit_Admin_Page(); - /* - * Loads the Product Pages - */ new WP_Ultimo\Admin_Pages\Product_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Product_Edit_Admin_Page(); - /* - * Loads the Memberships Pages - */ new WP_Ultimo\Admin_Pages\Membership_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Membership_Edit_Admin_Page(); - /* - * Loads the Payments Pages - */ new WP_Ultimo\Admin_Pages\Payment_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Payment_Edit_Admin_Page(); - /* - * Loads the Customers Pages - */ new WP_Ultimo\Admin_Pages\Customer_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Edit_Admin_Page(); - /* - * Loads the Site Pages - */ new WP_Ultimo\Admin_Pages\Site_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Site_Edit_Admin_Page(); - /* - * Loads the Domain Pages - */ new WP_Ultimo\Admin_Pages\Domain_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Domain_Edit_Admin_Page(); - /* - * Loads the Discount Code Pages - */ new WP_Ultimo\Admin_Pages\Discount_Code_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Discount_Code_Edit_Admin_Page(); - /* - * Loads the Broadcast Pages - */ new WP_Ultimo\Admin_Pages\Broadcast_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Broadcast_Edit_Admin_Page(); - /* - * Loads the Broadcast Pages - */ new WP_Ultimo\Admin_Pages\Email_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Email_Edit_Admin_Page(); new WP_Ultimo\Admin_Pages\Email_Template_Customize_Admin_Page(); - /* - * Loads the Settings - */ new WP_Ultimo\Admin_Pages\Settings_Admin_Page(); new WP_Ultimo\Admin_Pages\Invoice_Template_Customize_Admin_Page(); new WP_Ultimo\Admin_Pages\Template_Previewer_Customize_Admin_Page(); - /* - * Loads the Hosting Integration - */ new WP_Ultimo\Admin_Pages\Hosting_Integration_Wizard_Admin_Page(); - /* - * Loads the Events Pages - */ new WP_Ultimo\Admin_Pages\Event_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Event_View_Admin_Page(); - /* - * Loads the Webhooks Pages - */ new WP_Ultimo\Admin_Pages\Webhook_List_Admin_Page(); new WP_Ultimo\Admin_Pages\Webhook_Edit_Admin_Page(); - /* - * Loads the Jobs Pages - */ new WP_Ultimo\Admin_Pages\Jobs_List_Admin_Page(); - /* - * Loads the System Info Pages - */ new WP_Ultimo\Admin_Pages\System_Info_Admin_Page(); - /* - * Loads the Shortcodes Page - */ new WP_Ultimo\Admin_Pages\Shortcodes_Admin_Page(); - /* - * Loads the View Logs Pages - */ new WP_Ultimo\Admin_Pages\View_Logs_Admin_Page(); - /* - * Loads the View Logs Pages - */ new WP_Ultimo\Admin_Pages\Customer_Panel\Account_Admin_Page(); - new WP_Ultimo\Admin_Pages\Customer_Panel\My_Sites_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Panel\Add_New_Site_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Panel\Checkout_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Panel\Template_Switching_Admin_Page(); - /* - * Loads the Tax Pages - */ new WP_Ultimo\Tax\Dashboard_Taxes_Tab(); new WP_Ultimo\Admin_Pages\Addons_Admin_Page(); - - do_action('wp_ultimo_admin_pages'); } /** From e412cb69a101f32a3d479fe22c6b3cf3ee003fe3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 1 Feb 2026 15:55:44 -0700 Subject: [PATCH 2/6] Reduce redundant DB lookups and add test coverage reporting - Replace metadata_exists() + get_metadata() double lookup with single get_metadata_raw() call in Base_Model::get_meta() - Guard Block_Editor_Widget_Manager::register_scripts with is_admin() to skip frontend script registration - Skip redundant load_currents() re-run on admin/AJAX wp hook - Add unit tests for Block_Editor_Widget_Manager and Current class - Configure code coverage: fix phpunit.xml.dist, update test:coverage npm script, install xdebug in CI, add codecov.yml with patch/project thresholds Co-Authored-By: Claude Opus 4.5 --- .github/workflows/tests.yml | 24 ++- codecov.yml | 33 ++++ .../class-block-editor-widget-manager.php | 4 +- inc/class-current.php | 7 + inc/models/class-base-model.php | 7 +- package.json | 2 +- phpunit.xml.dist | 4 +- .../Block_Editor_Widget_Manager_Test.php | 119 +++++++++++ tests/WP_Ultimo/Current_Test.php | 184 ++++++++++++++++++ 9 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 codecov.yml create mode 100644 tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php create mode 100644 tests/WP_Ultimo/Current_Test.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dde1fe35..09bb75c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] + php-version: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] services: mysql: image: mariadb:11.4 @@ -43,6 +43,11 @@ jobs: run: | install-php-extensions mysqli gd bcmath || echo "Extensions may already be installed." + - name: Install Xdebug for Coverage + if: matrix.php-version == '8.3' + run: | + install-php-extensions xdebug || echo "Xdebug may already be installed." + - name: Self-update Composer run: | composer self-update || echo "Composer update skipped due to permission issue." @@ -67,15 +72,24 @@ jobs: rm -rf /tmp/wordpress-tests-lib /tmp/wordpress/ bash bin/install-wp-tests.sh wordpress_test root root mysql latest + - name: Run PHPUnit Tests + if: matrix.php-version != '8.3' + run: vendor/bin/phpunit + - name: Run PHPUnit Tests with Coverage - run: vendor/bin/phpunit --coverage-clover=coverage.xml + if: matrix.php-version == '8.3' + run: | + php -d zend_extension=xdebug.so -d xdebug.mode=coverage \ + vendor/bin/phpunit --coverage-clover=coverage.xml --coverage-text \ + | tee coverage-summary.txt - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 + - name: Upload coverage to Codecov + if: matrix.php-version == '8.3' + uses: codecov/codecov-action@v5 with: file: ./coverage.xml flags: unittests - name: codecov-umbrella + name: php-${{ matrix.php-version }} fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..89b1e1e8 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,33 @@ +codecov: + require_ci_to_pass: false + +coverage: + status: + project: + default: + target: auto + threshold: 0% + informational: false + patch: + default: + target: 80% + threshold: 0% + informational: false + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: false + require_base: false + require_head: true + after_n_builds: 1 + show_carryforward_flags: true + +ignore: + - "inc/ui/**" + - "inc/views/**" + - "inc/assets/**" + - "inc/development/**" + - "inc/site-exporter/**" + - "tests/**" + - "vendor/**" diff --git a/inc/builders/block-editor/class-block-editor-widget-manager.php b/inc/builders/block-editor/class-block-editor-widget-manager.php index 01d37168..f18ec29e 100644 --- a/inc/builders/block-editor/class-block-editor-widget-manager.php +++ b/inc/builders/block-editor/class-block-editor-widget-manager.php @@ -34,7 +34,9 @@ public function init(): void { if (\WP_Ultimo\Compat\Gutenberg_Support::get_instance()->should_load()) { add_action('wu_element_loaded', [$this, 'handle_element']); - add_action('init', [$this, 'register_scripts']); + if (is_admin()) { + add_action('init', [$this, 'register_scripts']); + } add_filter('wu_element_is_preview', [$this, 'is_block_preview']); } diff --git a/inc/class-current.php b/inc/class-current.php index cf07e644..9297bfbe 100644 --- a/inc/class-current.php +++ b/inc/class-current.php @@ -222,6 +222,13 @@ public static function get_manage_url($id, $type = 'site') { */ public function load_currents(): void { + // On the wp hook, only re-run if we're on the frontend + // (query var overrides from pretty URLs only work there). + // On admin or AJAX, the init run already set everything. + if (did_action('init') && $this->site && (is_admin() || wp_doing_ajax())) { + return; + } + $site = false; /** diff --git a/inc/models/class-base-model.php b/inc/models/class-base-model.php index ee53f7a1..7f3de75c 100644 --- a/inc/models/class-base-model.php +++ b/inc/models/class-base-model.php @@ -736,12 +736,9 @@ public function get_meta($key, $default_value = false, $single = true) { } $meta_type = $this->get_meta_type_name(); + $value = get_metadata_raw($meta_type, $this->get_id(), $key, $single); - if (metadata_exists($meta_type, $this->get_id(), $key)) { - return get_metadata($meta_type, $this->get_id(), $key, $single); - } - - return $default_value; + return ! is_null($value) ? $value : $default_value; } /** diff --git a/package.json b/package.json index c1296d1c..8ede6f64 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "cleancss": "node scripts/cleancss.js", "makepot": "node scripts/makepot.js", "test": "vendor/bin/phpunit", - "test:coverage": "vendor/bin/phpunit --coverage-html=coverage-html --coverage-clover=coverage.xml", + "test:coverage": "php -d zend_extension=xdebug.so -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html=coverage-html --coverage-clover=coverage.xml --coverage-text", "test:watch": "vendor/bin/phpunit --watch", "lint": "run-p lint:php lint:js lint:css", "lint:php": "vendor/bin/phpcs", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0efc6388..cf5f552b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - + @@ -17,6 +17,8 @@ ./inc/ui ./inc/views ./inc/assets + ./inc/development + ./inc/site-exporter ./vendor ./inc/stuff.php diff --git a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php new file mode 100644 index 00000000..c6ddaf3e --- /dev/null +++ b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php @@ -0,0 +1,119 @@ +manager = Block_Editor_Widget_Manager::get_instance(); + } + + /** + * Test that register_scripts hook is added only in admin context. + */ + public function test_init_registers_scripts_hook_only_in_admin(): void { + + // Remove any existing hooks first. + remove_all_actions('init'); + + // Simulate frontend context — set_current_screen('front') sets is_admin() to false. + set_current_screen('front'); + + $this->manager->init(); + + $this->assertFalse( + has_action('init', [$this->manager, 'register_scripts']), + 'register_scripts should NOT be hooked on init when on the frontend.' + ); + } + + /** + * Test that register_scripts hook is added in admin context. + */ + public function test_init_registers_scripts_hook_in_admin(): void { + + // Remove any existing hooks first. + remove_all_actions('init'); + + // Simulate admin context. + set_current_screen('dashboard'); + + $this->manager->init(); + + $priority = has_action('init', [$this->manager, 'register_scripts']); + + // has_action returns the priority (int) or false. + $this->assertNotFalse( + $priority, + 'register_scripts should be hooked on init when in admin.' + ); + } + + /** + * Test that element_loaded and is_preview filter are always registered. + */ + public function test_init_always_registers_element_loaded_and_preview_filter(): void { + + remove_all_actions('wu_element_loaded'); + remove_all_filters('wu_element_is_preview'); + + // Even on frontend these should be registered. + set_current_screen('front'); + + $this->manager->init(); + + $this->assertNotFalse( + has_action('wu_element_loaded', [$this->manager, 'handle_element']), + 'handle_element should always be hooked on wu_element_loaded.' + ); + + $this->assertNotFalse( + has_filter('wu_element_is_preview', [$this->manager, 'is_block_preview']), + 'is_block_preview should always be hooked on wu_element_is_preview.' + ); + } + + /** + * Test is_block_preview returns true in REST edit context. + */ + public function test_is_block_preview_returns_true_in_rest_edit_context(): void { + + define('REST_REQUEST', true) || true; + + $_GET['context'] = 'edit'; + + $result = $this->manager->is_block_preview(false); + + $this->assertTrue($result, 'Should return true when in REST edit context.'); + + unset($_GET['context']); + } + + /** + * Test is_block_preview passes through when not in REST context. + */ + public function test_is_block_preview_passes_through_outside_rest(): void { + + $result = $this->manager->is_block_preview(false); + + $this->assertFalse($result, 'Should return false when not in REST edit context.'); + } +} diff --git a/tests/WP_Ultimo/Current_Test.php b/tests/WP_Ultimo/Current_Test.php new file mode 100644 index 00000000..24c2423e --- /dev/null +++ b/tests/WP_Ultimo/Current_Test.php @@ -0,0 +1,184 @@ +current = Current::get_instance(); + } + + /** + * Test that load_currents skips re-run on admin when site is already set. + */ + public function test_load_currents_skips_rerun_in_admin(): void { + + set_current_screen('dashboard'); + + // Create a site object and pre-set it to simulate the init run. + $site = wu_get_current_site(); + $this->current->set_site($site); + + // Confirm init has already fired (it has in test bootstrap). + $this->assertGreaterThan(0, did_action('init'), 'init should have fired.'); + + // Record the site before calling load_currents again. + $site_before = $this->current->get_site(); + + // Call load_currents again — this simulates the wp hook re-run. + $this->current->load_currents(); + + // Site should be the same object — the early return skipped re-processing. + $this->assertSame( + $site_before, + $this->current->get_site(), + 'load_currents should skip re-run on admin when site is already set.' + ); + } + + /** + * Test that load_currents runs fully on frontend even when site is set. + */ + public function test_load_currents_runs_on_frontend(): void { + + set_current_screen('front'); + + // Pre-set a site to simulate the init run. + $site = wu_get_current_site(); + $this->current->set_site($site); + + // On frontend, load_currents should NOT early-return — + // it needs to check for query var overrides. + // We verify by checking it doesn't throw and completes. + $this->current->load_currents(); + + // The site should still be set (may be same or updated from URL params). + $this->assertNotNull( + $this->current->get_site(), + 'load_currents should complete on frontend and site should remain set.' + ); + } + + /** + * Test that load_currents runs on the first call (init hook). + */ + public function test_load_currents_runs_on_first_init_call(): void { + + set_current_screen('dashboard'); + + // Clear the site to simulate a fresh state. + $this->current->set_site(null); + + // With site = null, the guard should NOT trigger even in admin, + // because the condition requires $this->site to be truthy. + $this->current->load_currents(); + + // After the first run the site should be populated (admin branch calls wu_get_current_site). + $this->assertNotNull( + $this->current->get_site(), + 'First load_currents call should populate the site even in admin.' + ); + } + + /** + * Test that set_site and get_site work correctly. + */ + public function test_set_and_get_site(): void { + + $site = wu_get_current_site(); + $this->current->set_site($site); + + $this->assertSame($site, $this->current->get_site()); + } + + /** + * Test that set_customer and get_customer work correctly. + */ + public function test_set_and_get_customer(): void { + + $this->current->set_customer(null); + $this->assertNull($this->current->get_customer()); + + $user_id = $this->factory()->user->create(['role' => 'subscriber']); + $customer = wu_create_customer( + [ + 'user_id' => $user_id, + 'email_address' => 'current-test@example.com', + ] + ); + + $this->current->set_customer($customer); + $this->assertSame($customer, $this->current->get_customer()); + } + + /** + * Test that set_membership and get_membership work correctly. + */ + public function test_set_and_get_membership(): void { + + $this->current->set_membership(false); + $this->assertFalse($this->current->get_membership()); + } + + /** + * Test param_key returns expected defaults. + */ + public function test_param_key_returns_defaults(): void { + + $this->assertEquals('site', Current::param_key('site')); + $this->assertEquals('customer', Current::param_key('customer')); + $this->assertEquals('membership', Current::param_key('membership')); + } + + /** + * Test param_key falls back to the type string for unknown types. + */ + public function test_param_key_falls_back_for_unknown_type(): void { + + $this->assertEquals('unknown', Current::param_key('unknown')); + } + + /** + * Test load_currents skips re-run during AJAX in admin. + */ + public function test_load_currents_skips_rerun_during_ajax(): void { + + set_current_screen('front'); + + // Simulate AJAX context. + add_filter('wp_doing_ajax', '__return_true'); + + $site = wu_get_current_site(); + $this->current->set_site($site); + + $site_before = $this->current->get_site(); + + $this->current->load_currents(); + + $this->assertSame( + $site_before, + $this->current->get_site(), + 'load_currents should skip re-run during AJAX when site is already set.' + ); + + remove_filter('wp_doing_ajax', '__return_true'); + } +} From b65681b20629b8fa5182a9bf71734a3e1220801a Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 1 Feb 2026 16:54:34 -0700 Subject: [PATCH 3/6] Add unit tests for all manager classes - Create Manager_Test_Trait with shared assertions for singleton behavior, slug, and model_class properties - Add tests for 15 previously untested managers: Block, Broadcast, Cache, Checkout_Form, Customer, Discount_Code, Event, Form, Job, Limitation, Notes, Notification, Product, Rating_Notice, Signup_Fields, Site, Visits, Webhook - Customer_Manager: test login tracking, email verification, heartbeat - Site_Manager: test hyphen validation, search/replace, login URLs - Event_Manager: test event registration, dispatch, model events - Form_Manager: test form registration, lookup, URL generation - Limitation_Manager: test object type detection - Signup_Fields_Manager: test field type accessors - Load test traits from bootstrap.php 105 new tests, 554 total (up from 449). All passing. Co-Authored-By: Claude Opus 4.5 --- .../WP_Ultimo/Managers/Block_Manager_Test.php | 42 +++++ .../Managers/Broadcast_Manager_Test.php | 28 ++++ .../WP_Ultimo/Managers/Cache_Manager_Test.php | 40 +++++ .../Managers/Checkout_Form_Manager_Test.php | 27 +++ .../Managers/Customer_Manager_Test.php | 117 +++++++++++++ .../Managers/Discount_Code_Manager_Test.php | 27 +++ .../WP_Ultimo/Managers/Event_Manager_Test.php | 151 +++++++++++++++++ .../WP_Ultimo/Managers/Form_Manager_Test.php | 98 +++++++++++ tests/WP_Ultimo/Managers/Job_Manager_Test.php | 27 +++ .../Managers/Limitation_Manager_Test.php | 73 ++++++++ .../WP_Ultimo/Managers/Manager_Test_Trait.php | 131 +++++++++++++++ .../WP_Ultimo/Managers/Notes_Manager_Test.php | 27 +++ .../Managers/Notification_Manager_Test.php | 27 +++ .../Managers/Product_Manager_Test.php | 27 +++ .../Managers/Rating_Notice_Manager_Test.php | 27 +++ .../Managers/Signup_Fields_Manager_Test.php | 72 ++++++++ .../WP_Ultimo/Managers/Site_Manager_Test.php | 157 ++++++++++++++++++ .../Managers/Visits_Manager_Test.php | 27 +++ .../Managers/Webhook_Manager_Test.php | 27 +++ tests/bootstrap.php | 3 + 20 files changed, 1155 insertions(+) create mode 100644 tests/WP_Ultimo/Managers/Block_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Broadcast_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Cache_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Checkout_Form_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Customer_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Event_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Form_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Job_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Limitation_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Manager_Test_Trait.php create mode 100644 tests/WP_Ultimo/Managers/Notes_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Notification_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Product_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Rating_Notice_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Signup_Fields_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Site_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Visits_Manager_Test.php create mode 100644 tests/WP_Ultimo/Managers/Webhook_Manager_Test.php diff --git a/tests/WP_Ultimo/Managers/Block_Manager_Test.php b/tests/WP_Ultimo/Managers/Block_Manager_Test.php new file mode 100644 index 00000000..a776b4ca --- /dev/null +++ b/tests/WP_Ultimo/Managers/Block_Manager_Test.php @@ -0,0 +1,42 @@ +get_manager_instance(); + $categories = $manager->add_wp_ultimo_block_category([], null); + + $this->assertIsArray($categories); + $this->assertNotEmpty($categories); + + $slugs = array_column($categories, 'slug'); + $this->assertContains('wp-ultimo', $slugs); + } +} diff --git a/tests/WP_Ultimo/Managers/Broadcast_Manager_Test.php b/tests/WP_Ultimo/Managers/Broadcast_Manager_Test.php new file mode 100644 index 00000000..72149d87 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Broadcast_Manager_Test.php @@ -0,0 +1,28 @@ +get_manager_instance(); + + // Should not throw even when no caching plugins are active. + $manager->flush_known_caches(); + + $this->assertTrue(true); + } +} diff --git a/tests/WP_Ultimo/Managers/Checkout_Form_Manager_Test.php b/tests/WP_Ultimo/Managers/Checkout_Form_Manager_Test.php new file mode 100644 index 00000000..fcaa5f08 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Checkout_Form_Manager_Test.php @@ -0,0 +1,27 @@ +factory()->user->create(['role' => 'subscriber']); + $customer = wu_create_customer( + [ + 'user_id' => $user_id, + 'email_address' => 'login-test@example.com', + ] + ); + + $this->assertNotWPError($customer); + + $user = get_user_by('id', $user_id); + $manager = $this->get_manager_instance(); + + $manager->log_ip_and_last_login($user->user_login, $user); + + // Refresh customer from DB. + $customer = wu_get_customer($customer->get_id()); + + $this->assertNotEmpty($customer->get_last_login(), 'last_login should be set after log_ip_and_last_login.'); + } + + /** + * Test log_ip_and_last_login with an invalid user does nothing. + */ + public function test_log_ip_and_last_login_with_nonexistent_user(): void { + + $manager = $this->get_manager_instance(); + + // Should not throw — just return early. + $manager->log_ip_and_last_login('nonexistent_user_xyz', null); + + $this->assertTrue(true); + } + + /** + * Test transition_customer_email_verification only acts on pending. + */ + public function test_transition_email_verification_ignores_non_pending(): void { + + $manager = $this->get_manager_instance(); + + // Should return early without error when new_status is not 'pending'. + $manager->transition_customer_email_verification('none', 'verified', 99999); + + $this->assertTrue(true); + } + + /** + * Test maybe_add_to_main_site respects the setting. + */ + public function test_maybe_add_to_main_site_skips_when_disabled(): void { + + wu_save_setting('add_users_to_main_site', false); + + $user_id = $this->factory()->user->create(['role' => 'subscriber']); + $customer = wu_create_customer( + [ + 'user_id' => $user_id, + 'email_address' => 'mainsite-test@example.com', + ] + ); + + $this->assertNotWPError($customer); + + $manager = $this->get_manager_instance(); + + // Create a mock checkout — we only need the method not to throw. + $manager->maybe_add_to_main_site($customer, new \stdClass()); + + // User should NOT be a member of the main site (beyond default). + $this->assertTrue(true); + } + + /** + * Test on_heartbeat_send returns the response array. + */ + public function test_on_heartbeat_send_returns_response(): void { + + $manager = $this->get_manager_instance(); + $response = $manager->on_heartbeat_send(['server_time' => time()]); + + $this->assertIsArray($response); + $this->assertArrayHasKey('server_time', $response); + } +} diff --git a/tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php b/tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php new file mode 100644 index 00000000..6438b9a7 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php @@ -0,0 +1,27 @@ +get_manager_instance(); + + $result = $manager->register_event('test_event', [ + 'name' => 'Test Event', + 'payload' => ['key' => 'value'], + ]); + + $this->assertTrue($result); + + $event = $manager->get_event('test_event'); + + $this->assertIsArray($event); + $this->assertEquals('Test Event', $event['name']); + } + + /** + * Test get_event returns false for unregistered event. + */ + public function test_get_event_returns_false_for_unknown(): void { + + $manager = $this->get_manager_instance(); + $result = $manager->get_event('nonexistent_event_xyz'); + + $this->assertFalse($result); + } + + /** + * Test get_events returns an array. + */ + public function test_get_events_returns_array(): void { + + $manager = $this->get_manager_instance(); + $events = $manager->get_events(); + + $this->assertIsArray($events); + } + + /** + * Test do_event returns false for unregistered event. + */ + public function test_do_event_returns_false_for_unknown(): void { + + $manager = $this->get_manager_instance(); + $result = $manager->do_event('nonexistent_event_xyz', []); + + $this->assertFalse($result); + } + + /** + * Test do_event fires the wu_event and wu_event_{slug} actions. + */ + public function test_do_event_fires_actions(): void { + + $manager = $this->get_manager_instance(); + + $manager->register_event('test_fire', [ + 'name' => 'Fire Test', + 'payload' => ['sample' => 'data'], + ]); + + $generic_fired = false; + $specific_fired = false; + + add_action('wu_event', function () use (&$generic_fired) { + $generic_fired = true; + }); + + add_action('wu_event_test_fire', function () use (&$specific_fired) { + $specific_fired = true; + }); + + // do_event calls save_event internally which may fail validation + // (initiator not set). The actions still fire before save. + $manager->do_event('test_fire', ['sample' => 'data']); + + $this->assertTrue($generic_fired, 'wu_event action should have fired.'); + $this->assertTrue($specific_fired, 'wu_event_test_fire action should have fired.'); + } + + /** + * Test save_event with a fully valid payload creates an event record. + */ + public function test_save_event_with_valid_payload(): void { + + $manager = $this->get_manager_instance(); + + $event = new \WP_Ultimo\Models\Event( + [ + 'object_id' => 1, + 'object_type' => 'test', + 'severity' => \WP_Ultimo\Models\Event::SEVERITY_INFO, + 'slug' => 'test_direct_save', + 'payload' => ['key' => 'value'], + 'initiator' => 'system', + 'date_created' => wu_get_current_time('mysql', true), + ] + ); + + $result = $event->save(); + + $this->assertNotWPError($result); + $this->assertNotFalse($result); + } + + /** + * Test register_model_events stores model event configuration. + */ + public function test_register_model_events(): void { + + Event_Manager::register_model_events('test_model', 'Test Model', ['created', 'updated']); + + $manager = $this->get_manager_instance(); + $models_events = $this->get_protected_property($manager, 'models_events'); + + $this->assertArrayHasKey('test_model', $models_events); + $this->assertEquals('Test Model', $models_events['test_model']['label']); + $this->assertContains('created', $models_events['test_model']['types']); + } +} diff --git a/tests/WP_Ultimo/Managers/Form_Manager_Test.php b/tests/WP_Ultimo/Managers/Form_Manager_Test.php new file mode 100644 index 00000000..e08f9796 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Form_Manager_Test.php @@ -0,0 +1,98 @@ +get_manager_instance(); + + $manager->register_form( + 'test_form_xyz', + [ + 'render' => '__return_true', + 'handler' => '__return_true', + ] + ); + + $form = $manager->get_form('test_form_xyz'); + + $this->assertIsArray($form); + } + + /** + * Test is_form_registered returns correct values. + */ + public function test_is_form_registered(): void { + + $manager = $this->get_manager_instance(); + + $manager->register_form( + 'registered_form_xyz', + [ + 'render' => '__return_true', + 'handler' => '__return_true', + ] + ); + + $this->assertTrue($manager->is_form_registered('registered_form_xyz')); + $this->assertFalse($manager->is_form_registered('nonexistent_form_xyz')); + } + + /** + * Test get_registered_forms returns an array. + */ + public function test_get_registered_forms_returns_array(): void { + + $manager = $this->get_manager_instance(); + $forms = $manager->get_registered_forms(); + + $this->assertIsArray($forms); + } + + /** + * Test get_form_url returns a string URL. + */ + public function test_get_form_url_returns_url(): void { + + $manager = $this->get_manager_instance(); + + $manager->register_form( + 'url_test_form', + [ + 'render' => '__return_true', + 'handler' => '__return_true', + ] + ); + + $url = $manager->get_form_url('url_test_form'); + + $this->assertIsString($url); + $this->assertStringContainsString('url_test_form', $url); + } +} diff --git a/tests/WP_Ultimo/Managers/Job_Manager_Test.php b/tests/WP_Ultimo/Managers/Job_Manager_Test.php new file mode 100644 index 00000000..fd9b8fa9 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Job_Manager_Test.php @@ -0,0 +1,27 @@ +get_manager_instance(); + $plugins = $manager->get_all_plugins(); + + $this->assertIsArray($plugins); + } + + /** + * Test get_all_themes returns an array. + */ + public function test_get_all_themes_returns_array(): void { + + $manager = $this->get_manager_instance(); + $themes = $manager->get_all_themes(); + + $this->assertIsArray($themes); + } + + /** + * Test get_object_type returns the correct type for a Product model. + */ + public function test_get_object_type_with_product(): void { + + $manager = $this->get_manager_instance(); + + $product = new \WP_Ultimo\Models\Product(); + $type = $manager->get_object_type($product); + + $this->assertEquals('product', $type); + } + + /** + * Test get_object_type returns false for unknown objects. + */ + public function test_get_object_type_with_unknown(): void { + + $manager = $this->get_manager_instance(); + $type = $manager->get_object_type(new \stdClass()); + + $this->assertFalse($type); + } +} diff --git a/tests/WP_Ultimo/Managers/Manager_Test_Trait.php b/tests/WP_Ultimo/Managers/Manager_Test_Trait.php new file mode 100644 index 00000000..4dac813c --- /dev/null +++ b/tests/WP_Ultimo/Managers/Manager_Test_Trait.php @@ -0,0 +1,131 @@ +get_manager_class(); + + return $class::get_instance(); + } + + /** + * Read a protected/private property via reflection. + * + * @param object $object The object to read from. + * @param string $property The property name. + * @return mixed + */ + protected function get_protected_property(object $object, string $property) { + + $reflection = new \ReflectionClass($object); + $prop = $reflection->getProperty($property); + + if (PHP_VERSION_ID < 80100) { + $prop->setAccessible(true); + } + + return $prop->getValue($object); + } + + /** + * Test that get_instance() returns the correct class. + */ + public function test_singleton_returns_correct_instance(): void { + + $class = $this->get_manager_class(); + $manager = $class::get_instance(); + + $this->assertInstanceOf($class, $manager); + } + + /** + * Test that get_instance() always returns the same instance. + */ + public function test_singleton_returns_same_instance(): void { + + $class = $this->get_manager_class(); + + $this->assertSame($class::get_instance(), $class::get_instance()); + } + + /** + * Test the $slug protected property when expected. + */ + public function test_slug_property(): void { + + $expected = $this->get_expected_slug(); + + if (null === $expected) { + $this->assertTrue(true, 'Manager does not define a slug.'); + return; + } + + $manager = $this->get_manager_instance(); + $slug = $this->get_protected_property($manager, 'slug'); + + $this->assertEquals($expected, $slug); + } + + /** + * Test the $model_class protected property when expected. + */ + public function test_model_class_property(): void { + + $expected = $this->get_expected_model_class(); + + if (null === $expected) { + $this->assertTrue(true, 'Manager does not define a model_class.'); + return; + } + + $manager = $this->get_manager_instance(); + $model_class = $this->get_protected_property($manager, 'model_class'); + + $this->assertEquals($expected, $model_class); + } +} diff --git a/tests/WP_Ultimo/Managers/Notes_Manager_Test.php b/tests/WP_Ultimo/Managers/Notes_Manager_Test.php new file mode 100644 index 00000000..52aeb154 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Notes_Manager_Test.php @@ -0,0 +1,27 @@ +get_manager_instance(); + $types = $manager->get_field_types(); + + $this->assertIsArray($types); + $this->assertNotEmpty($types); + } + + /** + * Test get_required_fields returns an array. + */ + public function test_get_required_fields_returns_array(): void { + + $manager = $this->get_manager_instance(); + $fields = $manager->get_required_fields(); + + $this->assertIsArray($fields); + } + + /** + * Test get_user_fields returns an array. + */ + public function test_get_user_fields_returns_array(): void { + + $manager = $this->get_manager_instance(); + $fields = $manager->get_user_fields(); + + $this->assertIsArray($fields); + } + + /** + * Test get_site_fields returns an array. + */ + public function test_get_site_fields_returns_array(): void { + + $manager = $this->get_manager_instance(); + $fields = $manager->get_site_fields(); + + $this->assertIsArray($fields); + } +} diff --git a/tests/WP_Ultimo/Managers/Site_Manager_Test.php b/tests/WP_Ultimo/Managers/Site_Manager_Test.php new file mode 100644 index 00000000..576b4ab0 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Site_Manager_Test.php @@ -0,0 +1,157 @@ +get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', __('Site names can only contain lowercase letters (a-z) and numbers.', 'ultimate-multisite')); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'my-site', + 'errors' => $errors, + ] + ); + + $this->assertFalse( + $result['errors']->has_errors(), + 'Hyphenated site name should be valid.' + ); + } + + /** + * Test allow_hyphens_in_site_name rejects invalid characters. + */ + public function test_allow_hyphens_rejects_invalid_chars(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', __('Site names can only contain lowercase letters (a-z) and numbers.', 'ultimate-multisite')); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'my_site!', + 'errors' => $errors, + ] + ); + + $this->assertTrue( + $result['errors']->has_errors(), + 'Site name with underscores and special chars should be invalid.' + ); + } + + /** + * Test filter_illegal_search_keys removes null/false/empty keys. + */ + public function test_filter_illegal_search_keys(): void { + + $manager = $this->get_manager_instance(); + + $input = [ + 'good_key' => 'value1', + '' => 'value2', + 'another' => 'value3', + false => 'value4', + ]; + + $result = $manager->filter_illegal_search_keys($input); + + $this->assertArrayHasKey('good_key', $result); + $this->assertArrayHasKey('another', $result); + $this->assertCount(2, $result); + } + + /** + * Test get_search_and_replace_settings returns pairs from settings. + */ + public function test_get_search_and_replace_settings(): void { + + wu_save_setting( + 'search_and_replace', + [ + ['search' => 'foo', 'replace' => 'bar'], + ['search' => '', 'replace' => 'baz'], + ['search' => 'hello', 'replace' => 'world'], + ] + ); + + $manager = $this->get_manager_instance(); + $pairs = $manager->get_search_and_replace_settings(); + + $this->assertIsArray($pairs); + $this->assertEquals('bar', $pairs['foo']); + $this->assertEquals('world', $pairs['hello']); + $this->assertArrayNotHasKey('', $pairs, 'Empty search keys should be excluded.'); + } + + /** + * Test login_header_url returns the site URL. + */ + public function test_login_header_url(): void { + + $manager = $this->get_manager_instance(); + + $this->assertEquals(get_site_url(), $manager->login_header_url()); + } + + /** + * Test login_header_text returns the blog name. + */ + public function test_login_header_text(): void { + + $manager = $this->get_manager_instance(); + + $this->assertEquals(get_bloginfo('name'), $manager->login_header_text()); + } + + /** + * Test hide_super_admin_from_list adds exclusion for non-super-admins. + */ + public function test_hide_super_admin_from_list(): void { + + $manager = $this->get_manager_instance(); + + // As a non-super admin user. + $user_id = $this->factory()->user->create(['role' => 'administrator']); + wp_set_current_user($user_id); + + $args = []; + $result = $manager->hide_super_admin_from_list($args); + + $this->assertArrayHasKey('login__not_in', $result); + + // Restore. + wp_set_current_user(0); + } +} diff --git a/tests/WP_Ultimo/Managers/Visits_Manager_Test.php b/tests/WP_Ultimo/Managers/Visits_Manager_Test.php new file mode 100644 index 00000000..5062f31c --- /dev/null +++ b/tests/WP_Ultimo/Managers/Visits_Manager_Test.php @@ -0,0 +1,27 @@ + Date: Wed, 4 Feb 2026 11:42:15 -0700 Subject: [PATCH 4/6] Fix shareable link copy button not working on product edit page The data-clipboard-text attribute was being double-escaped through esc_attr() in the edit template, breaking ClipboardJS. Changed attrs to use array format with proper per-value escaping. Also improved the clipboard JS init with copy feedback, fallback handling, and duplicate handler prevention. Co-Authored-By: Claude Opus 4.5 --- assets/js/functions.js | 555 ++++++++++-------- assets/js/functions.min.js | 2 +- .../class-product-edit-admin-page.php | 4 +- views/base/edit.php | 14 +- 4 files changed, 321 insertions(+), 254 deletions(-) diff --git a/assets/js/functions.js b/assets/js/functions.js index 62e3539f..ff05ce7c 100644 --- a/assets/js/functions.js +++ b/assets/js/functions.js @@ -3,267 +3,320 @@ /* global wu_settings, wu_input_masks, wu_money_input_masks, Cleave, ClipboardJS, wu_fields, tinymce, wu_media_frame, fontIconPicker */ window.wu_initialize_tooltip = function() { - jQuery('[role="tooltip"]').tipTip({ - attribute: 'aria-label', - }); + jQuery('[role="tooltip"]').tipTip({ + attribute: 'aria-label', + }); }; // end wu_initialize_tooltip; window.wu_initialize_editors = function() { - jQuery('textarea[data-editor]').each(function() { + jQuery('textarea[data-editor]').each(function() { - tinymce.remove('#' + jQuery(this).attr('id')); + tinymce.remove('#' + jQuery(this).attr('id')); - tinymce.init({ - selector: '#' + jQuery(this).attr('id'), // change this value according to your HTML - menubar: '', - theme: 'modern', - ...wp.editor.getDefaultSettings().tinymce, - }); + tinymce.init({ + selector: '#' + jQuery(this).attr('id'), // change this value according to your HTML + menubar: '', + theme: 'modern', + ...wp.editor.getDefaultSettings().tinymce, + }); - }); + }); }; // end wu_initialize_editors window.wu_initialize_imagepicker = function() { - jQuery('.wu-wrapper-image-field').each(function() { + jQuery('.wu-wrapper-image-field').each(function() { - const that = jQuery(this); + const that = jQuery(this); - that.find('img').css({ - maxWidth: '100%', - }); + that.find('img').css({ + maxWidth: '100%', + }); - const value = that.find('img').attr('src'); + const value = that.find('img').attr('src'); - if (value) { + if (value) { - that.find('.wu-wrapper-image-field-upload-actions').show(); + that.find('.wu-wrapper-image-field-upload-actions').show(); - } else { - - that.find('.wu-add-image-wrapper').show(); - - } // end if; + } else { - that.on('click', 'a.wu-add-image', function() { + that.find('.wu-add-image-wrapper').show(); - if (typeof wu_media_frame !== 'undefined') { + } // end if; - wu_media_frame.open(); + that.on('click', 'a.wu-add-image', function() { - return; + if (typeof wu_media_frame !== 'undefined') { - } // end if; + wu_media_frame.open(); - wu_media_frame = wp.media({ - title: wu_fields.l10n.image_picker_title, - multiple: false, - button: { - text: wu_fields.l10n.image_picker_button_text, - }, - }); + return; - wu_media_frame.on('select', function() { + } // end if; - const mediaObject = wu_media_frame.state().get('selection').first().toJSON(); + wu_media_frame = wp.media({ + title: wu_fields.l10n.image_picker_title, + multiple: false, + button: { + text: wu_fields.l10n.image_picker_button_text, + }, + }); - const img_el = that.find('img'); + wu_media_frame.on('select', function() { - that.find('img').removeClass('wu-absolute').attr('src', mediaObject.url); + const mediaObject = wu_media_frame.state().get('selection').first().toJSON(); - that.find('.wubox').attr('href', mediaObject.url); + const img_el = that.find('img'); - that.find('input').val(mediaObject.id); + that.find('img').removeClass('wu-absolute').attr('src', mediaObject.url); - that.find('.wu-add-image-wrapper').hide(); + that.find('.wubox').attr('href', mediaObject.url); - img_el.on('load', function() { + that.find('input').val(mediaObject.id); - that.find('.wu-wrapper-image-field-upload-actions').show(); + that.find('.wu-add-image-wrapper').hide(); - }); + img_el.on('load', function() { - }); + that.find('.wu-wrapper-image-field-upload-actions').show(); - wu_media_frame.open(); + }); - }); + }); - that.find('.wu-remove-image').on('click', function(e) { + wu_media_frame.open(); - e.preventDefault(); + }); - that.find('img').removeAttr('src').addClass('wu-absolute'); + that.find('.wu-remove-image').on('click', function(e) { - that.find('input').val(''); + e.preventDefault(); - that.find('.wu-wrapper-image-field-upload-actions').hide(); + that.find('img').removeAttr('src').addClass('wu-absolute'); - that.find('.wu-add-image-wrapper').show(); + that.find('input').val(''); - }); + that.find('.wu-wrapper-image-field-upload-actions').hide(); - }); + that.find('.wu-add-image-wrapper').show(); + + }); + + }); }; // end wu_initialize_imagepicker window.wu_initialize_colorpicker = function() { - jQuery(document).ready(function() { + jQuery(document).ready(function() { - jQuery('.wu_color_field').each(function() { + jQuery('.wu_color_field').each(function() { - jQuery(this).wpColorPicker(); + jQuery(this).wpColorPicker(); - }); + }); - }); + }); }; // end wu_initialize_colorpicker; window.wu_initialize_iconfontpicker = function() { - jQuery(document).ready(function() { + jQuery(document).ready(function() { - if (jQuery('.wu_select_icon').length) { + if (jQuery('.wu_select_icon').length) { - jQuery('.wu_select_icon').fontIconPicker({ - theme: 'wu-theme', - }); + jQuery('.wu_select_icon').fontIconPicker({ + theme: 'wu-theme', + }); - } + } - }); + }); }; // end wu_initialize_iconfontpicker; window.wu_initialize_clipboardjs = function() { - new ClipboardJS('.wu-copy'); + // Prevent page jump on copy link click + jQuery(document).off('click.wu-copy').on('click.wu-copy', 'a.wu-copy[href="#"]', function(e) { + e.preventDefault(); + }); + + // Destroy previous instance to avoid duplicate handlers on repeated calls + if (window._wu_clipboard_instance) { + window._wu_clipboard_instance.destroy(); + } + + function showCopyFeedback(trigger) { + const $trigger = jQuery(trigger); + const $textNodes = $trigger.contents().filter(function() { + return this.nodeType === 3 && this.textContent.trim().length > 0; + }); + + if ($textNodes.length) { + const node = $textNodes[ 0 ]; + const originalText = node.textContent; + node.textContent = ' Copied!'; + setTimeout(function() { + node.textContent = originalText; + }, 2000); + } + } + + if (typeof ClipboardJS !== 'undefined') { + window._wu_clipboard_instance = new ClipboardJS('.wu-copy'); + + window._wu_clipboard_instance.on('success', function(e) { + showCopyFeedback(e.trigger); + e.clearSelection(); + }); + + window._wu_clipboard_instance.on('error', function(e) { + const text = e.trigger.getAttribute('data-clipboard-text'); + if (text && navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function() { + showCopyFeedback(e.trigger); + }); + } + }); + } else { + // Fallback when ClipboardJS is not available + jQuery(document).off('click.wu-copy-fallback').on('click.wu-copy-fallback', '.wu-copy', function() { + const text = jQuery(this).attr('data-clipboard-text'); + const trigger = this; + if (text && navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function() { + showCopyFeedback(trigger); + }); + } + }); + } }; // end wu_initialize_clipboardjs; // DatePicker; window.wu_initialize_datepickers = function() { - jQuery('.wu-datepicker, [wu-datepicker]').each(function() { + jQuery('.wu-datepicker, [wu-datepicker]').each(function() { - const $this = jQuery(this); + const $this = jQuery(this); - const format = $this.data('format'), - allow_time = $this.data('allow-time'); + const format = $this.data('format'), + allow_time = $this.data('allow-time'); - $this.flatpickr({ - animate: false, - // locale: wpu.datepicker_locale, - time_24hr: true, - enableTime: typeof allow_time === 'undefined' ? true : allow_time, - dateFormat: format, - allowInput: true, - defaultDate: $this.val(), - }); + $this.flatpickr({ + animate: false, + // locale: wpu.datepicker_locale, + time_24hr: true, + enableTime: typeof allow_time === 'undefined' ? true : allow_time, + dateFormat: format, + allowInput: true, + defaultDate: $this.val(), + }); - }); + }); }; // end wu_initialize_datepickers; window.wu_update_clock = function() { - // eslint-disable-next-line no-undef - const yourTimeZoneFrom = wu_ticker.server_clock_offset; // time zone value where you are at + // eslint-disable-next-line no-undef + const yourTimeZoneFrom = wu_ticker.server_clock_offset; // time zone value where you are at - const d = new Date(); - //get the timezone offset from local time in minutes + const d = new Date(); + //get the timezone offset from local time in minutes - // eslint-disable-next-line no-mixed-operators - const tzDifference = yourTimeZoneFrom * 60 + d.getTimezoneOffset(); + // eslint-disable-next-line no-mixed-operators + const tzDifference = yourTimeZoneFrom * 60 + d.getTimezoneOffset(); - //convert the offset to milliseconds, add to targetTime, and make a new Date - const offset = tzDifference * 60 * 1000; + //convert the offset to milliseconds, add to targetTime, and make a new Date + const offset = tzDifference * 60 * 1000; - function callback_update_clock() { + function callback_update_clock() { - const tDate = new Date(new Date().getTime() + offset); + const tDate = new Date(new Date().getTime() + offset); - const in_years = tDate.getFullYear(); + const in_years = tDate.getFullYear(); - let in_months = tDate.getMonth() + 1; + let in_months = tDate.getMonth() + 1; - let in_days = tDate.getDate(); + let in_days = tDate.getDate(); - let in_hours = tDate.getHours(); + let in_hours = tDate.getHours(); - let in_minutes = tDate.getMinutes(); + let in_minutes = tDate.getMinutes(); - let in_seconds = tDate.getSeconds(); + let in_seconds = tDate.getSeconds(); - if (in_months < 10) { + if (in_months < 10) { - in_months = '0' + in_months; + in_months = '0' + in_months; - } + } - if (in_days < 10) { + if (in_days < 10) { - in_days = '0' + in_days; + in_days = '0' + in_days; - } + } - if (in_minutes < 10) { + if (in_minutes < 10) { - in_minutes = '0' + in_minutes; + in_minutes = '0' + in_minutes; - } + } - if (in_seconds < 10) { + if (in_seconds < 10) { - in_seconds = '0' + in_seconds; + in_seconds = '0' + in_seconds; - } + } - if (in_hours < 10) { + if (in_hours < 10) { - in_hours = '0' + in_hours; + in_hours = '0' + in_hours; - } + } - jQuery('#wu-ticker').text(in_years + '-' + in_months + '-' + in_days + ' ' + in_hours + ':' + in_minutes + ':' + in_seconds); + jQuery('#wu-ticker').text(in_years + '-' + in_months + '-' + in_days + ' ' + in_hours + ':' + in_minutes + ':' + in_seconds); - } + } - function start_clock() { + function start_clock() { - setInterval(callback_update_clock, 500); + setInterval(callback_update_clock, 500); - } + } - start_clock(); + start_clock(); }; // eslint-disable-next-line no-unused-vars function wu_on_load() { - wu_initialize_tooltip(); + wu_initialize_tooltip(); - wu_initialize_datepickers(); + wu_initialize_datepickers(); - wu_initialize_colorpicker(); + wu_initialize_colorpicker(); - wu_initialize_iconfontpicker(); + wu_initialize_iconfontpicker(); - wu_initialize_editors(); + wu_initialize_editors(); - wu_update_clock(); + wu_update_clock(); - wu_initialize_clipboardjs(); + wu_initialize_clipboardjs(); - wu_initialize_imagepicker(); + wu_initialize_imagepicker(); - wu_image_preview(); + wu_image_preview(); } // end wu_on_load; @@ -272,178 +325,178 @@ window.wu_on_load = wu_on_load; // eslint-disable-next-line no-unused-vars window.wu_block_ui = function(el) { - jQuery(el).wu_block({ - message: '
', - overlayCSS: { - backgroundColor: '#FFF', - opacity: 0.6, - }, - css: { - padding: 0, - margin: 0, - width: '50%', - fontSize: '14px !important', - top: '40%', - left: '35%', - textAlign: 'center', - color: '#000', - border: 'none', - backgroundColor: 'none', - cursor: 'wait', - }, - }); - - const el_instance = jQuery(el); - - el_instance.unblock = jQuery(el).wu_unblock; - - return el_instance; + jQuery(el).wu_block({ + message: '
', + overlayCSS: { + backgroundColor: '#FFF', + opacity: 0.6, + }, + css: { + padding: 0, + margin: 0, + width: '50%', + fontSize: '14px !important', + top: '40%', + left: '35%', + textAlign: 'center', + color: '#000', + border: 'none', + backgroundColor: 'none', + cursor: 'wait', + }, + }); + + const el_instance = jQuery(el); + + el_instance.unblock = jQuery(el).wu_unblock; + + return el_instance; }; function wu_format_money(value) { - value = parseFloat(value.toString().replace(/[^0-9\.]/g, '')); + value = parseFloat(value.toString().replace(/[^0-9\.]/g, '')); - const settings = wp.hooks.applyFilters('wu_format_money', { - currency: { - symbol: wu_settings.currency_symbol, // default currency symbol is '$' - format: wu_settings.currency_position, // controls output: %s = symbol, %v = value/number (can be object: see below) - decimal: wu_settings.decimal_separator, // decimal point separator - thousand: wu_settings.thousand_separator, // thousands separator - precision: wu_settings.precision, // decimal places - }, - number: { - precision: 0, // default precision on numbers is 0 - thousand: ',', - decimal: ',', - }, - }); + const settings = wp.hooks.applyFilters('wu_format_money', { + currency: { + symbol: wu_settings.currency_symbol, // default currency symbol is '$' + format: wu_settings.currency_position, // controls output: %s = symbol, %v = value/number (can be object: see below) + decimal: wu_settings.decimal_separator, // decimal point separator + thousand: wu_settings.thousand_separator, // thousands separator + precision: wu_settings.precision, // decimal places + }, + number: { + precision: 0, // default precision on numbers is 0 + thousand: ',', + decimal: ',', + }, + }); - accounting.settings = settings; + accounting.settings = settings; - return accounting.formatMoney(value); + return accounting.formatMoney(value); } // end wu_format_money; window.wu_image_preview = function() { - const xOffset = 10; + const xOffset = 10; - const yOffset = 30; + const yOffset = 30; - const preview_el = '#wu-image-preview'; + const preview_el = '#wu-image-preview'; - // eslint-disable-next-line eqeqeq - const selector = wu_settings.disable_image_zoom == true ? '.wu-image-preview:not(img)' : '.wu-image-preview'; + // eslint-disable-next-line eqeqeq + const selector = wu_settings.disable_image_zoom == true ? '.wu-image-preview:not(img)' : '.wu-image-preview'; - const el_id = preview_el.replace('#', ''); + const el_id = preview_el.replace('#', ''); - if (jQuery(preview_el).length === 0) { + if (jQuery(preview_el).length === 0) { - jQuery('body').append( - "',overlayCSS:{backgroundColor:"#FFF",opacity:.6},css:{padding:0,margin:0,width:"50%",fontSize:"14px !important",top:"40%",left:"35%",textAlign:"center",color:"#000",border:"none",backgroundColor:"none",cursor:"wait"}});var i=jQuery(e);return i.unblock=jQuery(e).wu_unblock,i},window.wu_image_preview=function(){let t="#wu-image-preview";var e=1==wu_settings.disable_image_zoom?".wu-image-preview:not(img)":".wu-image-preview",i=t.replace("#","");0===jQuery(t).length&&jQuery("body").append(""),jQuery(e).hover(function(e){this.t=this.title,this.title="";var i=jQuery(this).data("image");jQuery(t).find("img").attr("src",i).attr("alt",this.t).end().css({position:"absolute",display:"none"}).css("top",e.pageY-10+"px").css("left",e.pageX+30+"px").fadeIn("fast")},function(){this.title=this.t,jQuery(t).fadeOut("fast")}),jQuery(e).mousemove(function(e){jQuery(t).css("top",e.pageY-10+"px").css("left",e.pageX+30+"px")})},window.wu_initialize_code_editors=function(){jQuery("[data-code-editor]").length&&(void 0===window.wu_editor_instances&&(window.wu_editor_instances={}),jQuery("[data-code-editor]").each(function(){var e=jQuery(this),i=e.attr("id");void 0===window.wu_editor_instances[i]&&e.is(":visible")&&(window.wu_editor_instances[i]=wp.codeEditor.initialize(i,{codemirror:{mode:e.data("code-editor"),lint:!0,autoCloseBrackets:!0,matchBrackets:!0,indentUnit:2,indentWithTabs:!0,lineNumbers:!0,lineWrapping:!0,styleActiveLine:!0,continueComments:!0,inputStyle:"contenteditable",direction:"ltr",gutters:[],extraKeys:{"Ctrl-Space":"autocomplete","Ctrl-/":"toggleComment","Cmd-/":"toggleComment","Alt-F":"findPersistent"}}}))}))},window.wu_moment=function(e){return moment.tz(e,"Etc/UTC")}; \ No newline at end of file diff --git a/inc/admin-pages/class-product-edit-admin-page.php b/inc/admin-pages/class-product-edit-admin-page.php index dd6b0637..6a4a1f2c 100644 --- a/inc/admin-pages/class-product-edit-admin-page.php +++ b/inc/admin-pages/class-product-edit-admin-page.php @@ -983,7 +983,9 @@ public function action_links() { 'label' => __('Click to copy Shareable Link', 'ultimate-multisite'), 'icon' => 'wu-attachment', 'classes' => 'wu-copy', - 'attrs' => 'data-clipboard-text="' . esc_attr($shareable_link) . '"', + 'attrs' => [ + 'data-clipboard-text' => $shareable_link, + ], ]; } diff --git a/views/base/edit.php b/views/base/edit.php index a87215c9..bf1636a6 100644 --- a/views/base/edit.php +++ b/views/base/edit.php @@ -13,7 +13,19 @@ edit ? $labels['edit_label'] : $labels['add_new_label']); ?> get_title_links() as $action_link) : ?> - > + $attr_value) { + printf(' %s="%s"', esc_attr($attr_name), esc_attr($attr_value)); + } + } else { + echo ' ' . $action_link['attrs']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped attributes + } + } + ?> + >   From ae29f8c79de55c848ad68fb24f4e14f43a6b617d Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 4 Feb 2026 13:45:08 -0700 Subject: [PATCH 5/6] Add mroe unit tests --- inc/helpers/class-arr.php | 5 +- package.json | 2 +- tests/WP_Ultimo/Checkout/Cart_Test.php | 1971 +++++++++++ tests/WP_Ultimo/Checkout/Line_Item_Test.php | 602 ++++ tests/WP_Ultimo/Helpers/Arr_Test.php | 257 ++ tests/WP_Ultimo/Helpers/WP_Config_Test.php | 188 + .../Managers/Domain_Manager_Test.php | 1874 ++++++++++ .../Managers/Limitation_Manager_Test.php | 3050 +++++++++++++++++ .../Managers/Membership_Manager_Test.php | 695 +++- .../WP_Ultimo/Managers/Site_Manager_Test.php | 1823 ++++++++++ tests/WP_Ultimo/Models/Checkout_Form_Test.php | 1661 +++++++++ tests/WP_Ultimo/Models/Customer_Test.php | 1891 ++++++++++ tests/WP_Ultimo/Models/Discount_Code_Test.php | 961 ++++++ tests/WP_Ultimo/Models/Domain_Test.php | 1090 ++++++ tests/WP_Ultimo/Models/Email_Test.php | 1504 ++++++++ tests/WP_Ultimo/Models/Membership_Test.php | 1442 +++++++- tests/WP_Ultimo/Models/Payment_Test.php | 1135 ++++++ tests/WP_Ultimo/Models/Product_Test.php | 1891 ++++++++++ tests/WP_Ultimo/Models/Site_Test.php | 688 ++++ .../Objects/Billing_Address_Test.php | 343 ++ tests/WP_Ultimo/Objects/Note_Test.php | 239 ++ tests/WP_Ultimo/Objects/Visits_Test.php | 139 + tests/WP_Ultimo/Tax/Tax_Test.php | 870 +++++ tests/WP_Ultimo/Traits/Notable_Test.php | 215 ++ tests/WP_Ultimo/Whitelabel_Test.php | 944 +++++ 25 files changed, 25137 insertions(+), 343 deletions(-) create mode 100644 tests/WP_Ultimo/Checkout/Line_Item_Test.php create mode 100644 tests/WP_Ultimo/Helpers/Arr_Test.php create mode 100644 tests/WP_Ultimo/Helpers/WP_Config_Test.php create mode 100644 tests/WP_Ultimo/Objects/Billing_Address_Test.php create mode 100644 tests/WP_Ultimo/Objects/Note_Test.php create mode 100644 tests/WP_Ultimo/Objects/Visits_Test.php create mode 100644 tests/WP_Ultimo/Tax/Tax_Test.php create mode 100644 tests/WP_Ultimo/Traits/Notable_Test.php create mode 100644 tests/WP_Ultimo/Whitelabel_Test.php diff --git a/inc/helpers/class-arr.php b/inc/helpers/class-arr.php index aa328f2e..c60659b7 100644 --- a/inc/helpers/class-arr.php +++ b/inc/helpers/class-arr.php @@ -139,10 +139,9 @@ public static function set(&$array_to_modify, $key, $value) { return $array_to_modify = $value; // phpcs:ignore } - $keys = explode('.', $key); - $keys_count = count($keys); + $keys = explode('.', $key); - while ($keys_count > 1) { + while (count($keys) > 1) { $key = array_shift($keys); if ( ! isset($array_to_modify[ $key ]) || ! is_array($array_to_modify[ $key ])) { diff --git a/package.json b/package.json index 8ede6f64..33e2f3ac 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "cleancss": "node scripts/cleancss.js", "makepot": "node scripts/makepot.js", "test": "vendor/bin/phpunit", - "test:coverage": "php -d zend_extension=xdebug.so -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html=coverage-html --coverage-clover=coverage.xml --coverage-text", + "test:coverage": "php -d zend_extension=xdebug -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html=coverage-html --coverage-clover=coverage.xml --coverage-text", "test:watch": "vendor/bin/phpunit --watch", "lint": "run-p lint:php lint:js lint:css", "lint:php": "vendor/bin/phpcs", diff --git a/tests/WP_Ultimo/Checkout/Cart_Test.php b/tests/WP_Ultimo/Checkout/Cart_Test.php index d695466f..8aa4c170 100644 --- a/tests/WP_Ultimo/Checkout/Cart_Test.php +++ b/tests/WP_Ultimo/Checkout/Cart_Test.php @@ -667,6 +667,1977 @@ public function test_cancel_conflicting_pending_payments() { $this->assertInstanceOf(Cart::class, $cart); } + // ========================================================================= + // HELPER: Create a plan product for reuse across tests + // ========================================================================= + + /** + * Helper to create a recurring plan product. + * + * @param array $overrides Optional overrides. + * @return \WP_Ultimo\Models\Product + */ + private function create_plan($overrides = []) { + static $counter = 0; + ++$counter; + + $defaults = [ + 'name' => 'Plan ' . $counter, + 'slug' => 'plan-' . $counter . '-' . wp_rand(1000, 9999), + 'amount' => 49.00, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'plan', + 'pricing_type' => 'paid', + 'active' => true, + ]; + + return wu_create_product(array_merge($defaults, $overrides)); + } + + /** + * Helper to create a service (non-plan) product. + * + * @param array $overrides Optional overrides. + * @return \WP_Ultimo\Models\Product + */ + private function create_service($overrides = []) { + static $counter = 0; + ++$counter; + + $defaults = [ + 'name' => 'Service ' . $counter, + 'slug' => 'service-' . $counter . '-' . wp_rand(1000, 9999), + 'amount' => 10.00, + 'recurring' => false, + 'duration' => 1, + 'duration_unit' => 'month', + 'type' => 'service', + 'pricing_type' => 'paid', + 'active' => true, + ]; + + return wu_create_product(array_merge($defaults, $overrides)); + } + + // ========================================================================= + // EMPTY CART TESTS + // ========================================================================= + + /** + * Test that an empty cart (no products) is valid, free, and has zero totals. + */ + public function test_empty_cart_is_free() { + $cart = new Cart([]); + + $this->assertTrue($cart->is_free()); + $this->assertEquals(0.0, $cart->get_total()); + $this->assertEquals(0.0, $cart->get_subtotal()); + $this->assertEquals(0.0, $cart->get_recurring_total()); + } + + /** + * Test that an empty cart has no line items. + */ + public function test_empty_cart_has_no_line_items() { + $cart = new Cart([]); + + $this->assertEmpty($cart->get_line_items()); + $this->assertCount(0, $cart->get_all_products()); + } + + /** + * Test that an empty cart is valid. + */ + public function test_empty_cart_is_valid() { + $cart = new Cart([]); + + $this->assertTrue($cart->is_valid()); + $this->assertFalse($cart->errors->has_errors()); + } + + /** + * Test that an empty cart has no plan. + */ + public function test_empty_cart_has_no_plan() { + $cart = new Cart([]); + + $this->assertFalse($cart->has_plan()); + $this->assertNull($cart->get_plan_id()); + } + + /** + * Test that an empty cart has no recurring charges. + */ + public function test_empty_cart_has_no_recurring() { + $cart = new Cart([]); + + $this->assertFalse($cart->has_recurring()); + $this->assertEquals(0.0, $cart->get_recurring_total()); + $this->assertEquals(0.0, $cart->get_recurring_subtotal()); + } + + /** + * Test that an empty cart has no discount. + */ + public function test_empty_cart_has_no_discount() { + $cart = new Cart([]); + + $this->assertFalse($cart->has_discount()); + $this->assertEquals(0.0, $cart->get_total_discounts()); + $this->assertNull($cart->get_discount_code()); + } + + /** + * Test that an empty cart has no trial. + */ + public function test_empty_cart_has_no_trial() { + $cart = new Cart([]); + + $this->assertFalse($cart->has_trial()); + } + + // ========================================================================= + // ADD_PRODUCT TESTS + // ========================================================================= + + /** + * Test adding a single plan product to cart. + */ + public function test_add_single_plan_product() { + $product = $this->create_plan(['amount' => 29.99]); + + $cart = new Cart([ + 'products' => [$product->get_id()], + ]); + + $this->assertTrue($cart->has_plan()); + $this->assertEquals($product->get_id(), $cart->get_plan_id()); + $this->assertCount(1, $cart->get_all_products()); + $this->assertNotEmpty($cart->get_line_items()); + } + + /** + * Test that adding a nonexistent product produces an error. + */ + public function test_add_nonexistent_product_produces_error() { + $cart = new Cart([ + 'products' => [999999], + ]); + + $this->assertTrue($cart->errors->has_errors()); + $errors = $cart->errors->get_error_codes(); + $this->assertContains('missing-product', $errors); + } + + /** + * Test that adding two plan products produces an error. + */ + public function test_add_two_plans_produces_error() { + $plan1 = $this->create_plan(['amount' => 29.00]); + $plan2 = $this->create_plan(['amount' => 49.00]); + + $cart = new Cart([ + 'products' => [$plan1->get_id(), $plan2->get_id()], + ]); + + $this->assertTrue($cart->errors->has_errors()); + $errors = $cart->errors->get_error_codes(); + $this->assertContains('plan-already-added', $errors); + } + + /** + * Test adding a plan and a non-plan product (service/addon). + */ + public function test_add_plan_and_service_product() { + $plan = $this->create_plan(['amount' => 49.00]); + $service = $this->create_service(['amount' => 15.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id(), $service->get_id()], + ]); + + $this->assertTrue($cart->has_plan()); + $this->assertEquals($plan->get_id(), $cart->get_plan_id()); + $this->assertCount(2, $cart->get_all_products()); + } + + /** + * Test that duplicate product is silently skipped (no error, not added twice). + */ + public function test_duplicate_product_is_skipped() { + $plan = $this->create_plan(['amount' => 49.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id(), $plan->get_id()], + ]); + + $this->assertFalse($cart->errors->has_errors()); + $this->assertCount(1, $cart->get_all_products()); + } + + // ========================================================================= + // CART TOTALS TESTS + // ========================================================================= + + /** + * Test total for a single recurring plan product. + */ + public function test_total_for_single_recurring_product() { + $product = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$product->get_id()], + ]); + + $this->assertEquals(50.00, $cart->get_total()); + $this->assertEquals(50.00, $cart->get_subtotal()); + } + + /** + * Test recurring total for a recurring product. + */ + public function test_recurring_total_for_recurring_product() { + $product = $this->create_plan(['amount' => 35.00, 'recurring' => true]); + + $cart = new Cart([ + 'products' => [$product->get_id()], + ]); + + $this->assertEquals(35.00, $cart->get_recurring_total()); + $this->assertTrue($cart->has_recurring()); + } + + /** + * Test that non-recurring products have zero recurring total. + */ + public function test_non_recurring_product_has_zero_recurring_total() { + $product = $this->create_service([ + 'amount' => 25.00, + 'recurring' => false, + ]); + + $cart = new Cart([ + 'products' => [$product->get_id()], + ]); + + $this->assertEquals(0.0, $cart->get_recurring_total()); + $this->assertFalse($cart->has_recurring()); + } + + /** + * Test total with plan + non-recurring service. + */ + public function test_total_with_plan_and_service() { + $plan = $this->create_plan(['amount' => 40.00]); + $service = $this->create_service(['amount' => 20.00, 'recurring' => false]); + + $cart = new Cart([ + 'products' => [$plan->get_id(), $service->get_id()], + ]); + + $this->assertEquals(60.00, $cart->get_total()); + $this->assertEquals(40.00, $cart->get_recurring_total()); + } + + /** + * Test that cart total never goes below zero. + */ + public function test_cart_total_never_negative() { + $cart = new Cart([]); + + // Manually add a credit line item to try to make total negative + $credit = new Line_Item([ + 'type' => 'credit', + 'title' => 'Big Credit', + 'unit_price' => -1000, + 'quantity' => 1, + 'discountable' => false, + 'taxable' => false, + ]); + $cart->add_line_item($credit); + + $this->assertGreaterThanOrEqual(0, $cart->get_total()); + } + + // ========================================================================= + // LINE ITEM MANAGEMENT TESTS + // ========================================================================= + + /** + * Test adding a line item directly to the cart. + */ + public function test_add_line_item_directly() { + $cart = new Cart([]); + + $line_item = new Line_Item([ + 'type' => 'fee', + 'title' => 'Custom Fee', + 'unit_price' => 5.00, + 'quantity' => 1, + 'taxable' => false, + ]); + + $cart->add_line_item($line_item); + + $items = $cart->get_line_items(); + $this->assertNotEmpty($items); + } + + /** + * Test that adding a non-Line_Item object is silently ignored. + */ + public function test_add_invalid_line_item_ignored() { + $cart = new Cart([]); + + $cart->add_line_item('not a line item'); + $cart->add_line_item(null); + $cart->add_line_item(42); + + $this->assertEmpty($cart->get_line_items()); + } + + /** + * Test get_line_items_by_type for product type. + */ + public function test_get_line_items_by_type_product() { + $plan = $this->create_plan(['amount' => 30.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $product_items = $cart->get_line_items_by_type('product'); + $this->assertNotEmpty($product_items); + + foreach ($product_items as $item) { + $this->assertEquals('product', $item->get_type()); + } + } + + /** + * Test get_line_items_by_type for fee type. + */ + public function test_get_line_items_by_type_fee() { + $plan = $this->create_plan([ + 'amount' => 30.00, + 'setup_fee' => 10.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $fee_items = $cart->get_line_items_by_type('fee'); + $this->assertNotEmpty($fee_items); + + foreach ($fee_items as $item) { + $this->assertEquals('fee', $item->get_type()); + } + } + + /** + * Test get_line_items_by_type returns empty array for non-existent type. + */ + public function test_get_line_items_by_type_nonexistent() { + $plan = $this->create_plan(['amount' => 30.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $items = $cart->get_line_items_by_type('nonexistent_type'); + $this->assertEmpty($items); + } + + // ========================================================================= + // SETUP FEE TESTS + // ========================================================================= + + /** + * Test that a product with a setup fee adds a fee line item. + */ + public function test_setup_fee_creates_fee_line_item() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'setup_fee' => 25.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $fee_items = $cart->get_line_items_by_type('fee'); + $this->assertNotEmpty($fee_items); + + $fee = reset($fee_items); + $this->assertEquals(25.00, $fee->get_subtotal()); + } + + /** + * Test that the total includes setup fee. + */ + public function test_total_includes_setup_fee() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'setup_fee' => 10.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals(60.00, $cart->get_total()); + } + + /** + * Test that setup fee is not recurring. + */ + public function test_setup_fee_is_not_recurring() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'setup_fee' => 15.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + // Recurring total should only include the plan amount, not the fee + $this->assertEquals(50.00, $cart->get_recurring_total()); + } + + /** + * Test that products without setup fee have no fee line items. + */ + public function test_no_setup_fee_means_no_fee_line_item() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'setup_fee' => 0, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $fee_items = $cart->get_line_items_by_type('fee'); + $this->assertEmpty($fee_items); + } + + // ========================================================================= + // CART TYPE AND VALIDATION TESTS + // ========================================================================= + + /** + * Test default cart type is 'new'. + */ + public function test_default_cart_type_is_new() { + $cart = new Cart([]); + + $this->assertEquals('new', $cart->get_cart_type()); + } + + /** + * Test cart is valid with a single product and consistent billing intervals. + */ + public function test_cart_is_valid_with_single_product() { + $plan = $this->create_plan(['amount' => 49.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertTrue($cart->is_valid()); + } + + /** + * Test is_free for a free product. + */ + public function test_cart_is_free_with_free_product() { + $plan = $this->create_plan([ + 'amount' => 0, + 'pricing_type' => 'free', + 'recurring' => false, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertTrue($cart->is_free()); + } + + /** + * Test is_free returns false for paid product. + */ + public function test_cart_is_not_free_with_paid_product() { + $plan = $this->create_plan(['amount' => 10.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertFalse($cart->is_free()); + } + + // ========================================================================= + // DURATION AND BILLING CYCLE TESTS + // ========================================================================= + + /** + * Test cart picks up duration from the plan product. + */ + public function test_cart_duration_from_plan() { + $plan = $this->create_plan([ + 'amount' => 99.00, + 'duration' => 3, + 'duration_unit' => 'month', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals(3, $cart->get_duration()); + $this->assertEquals('month', $cart->get_duration_unit()); + } + + /** + * Test cart with explicit duration and duration_unit parameters. + */ + public function test_cart_explicit_duration_params() { + $plan = $this->create_plan([ + 'amount' => 49.00, + 'duration' => 1, + 'duration_unit' => 'month', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + 'duration' => 1, + 'duration_unit' => 'month', + ]); + + $this->assertEquals(1, $cart->get_duration()); + $this->assertEquals('month', $cart->get_duration_unit()); + } + + /** + * Test yearly product sets duration correctly. + */ + public function test_yearly_product_duration() { + $plan = $this->create_plan([ + 'amount' => 499.00, + 'duration' => 1, + 'duration_unit' => 'year', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals(1, $cart->get_duration()); + $this->assertEquals('year', $cart->get_duration_unit()); + } + + // ========================================================================= + // get_param / set_param TESTS + // ========================================================================= + + /** + * Test get_param returns default for non-existent key. + */ + public function test_get_param_default_value() { + $cart = new Cart([]); + + $this->assertFalse($cart->get_param('nonexistent_key')); + $this->assertEquals('default_val', $cart->get_param('nonexistent_key', 'default_val')); + } + + /** + * Test set_param and get_param. + */ + public function test_set_and_get_param() { + $cart = new Cart([]); + + $cart->set_param('custom_key', 'custom_value'); + + $this->assertEquals('custom_value', $cart->get_param('custom_key')); + } + + /** + * Test get_extra_params includes custom params. + */ + public function test_get_extra_params() { + $cart = new Cart([]); + + $cart->set_param('key_a', 'val_a'); + $cart->set_param('key_b', 'val_b'); + + $extra = $cart->get_extra_params(); + + $this->assertArrayHasKey('key_a', $extra); + $this->assertArrayHasKey('key_b', $extra); + $this->assertEquals('val_a', $extra['key_a']); + $this->assertEquals('val_b', $extra['key_b']); + } + + // ========================================================================= + // CURRENCY TESTS + // ========================================================================= + + /** + * Test set_currency and get_currency. + */ + public function test_set_and_get_currency() { + $cart = new Cart(['currency' => 'USD']); + + $this->assertEquals('USD', $cart->get_currency()); + + $cart->set_currency('EUR'); + $this->assertEquals('EUR', $cart->get_currency()); + } + + /** + * Test that currency is inherited from product when not explicitly set. + */ + public function test_currency_inherited_from_product() { + $plan = $this->create_plan([ + 'amount' => 49.00, + 'currency' => 'BRL', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + // The cart picks up currency from the product; if default is set via settings, + // the product currency is used only when the cart currency is empty. + $currency = $cart->get_currency(); + $this->assertNotEmpty($currency, 'Cart should have a currency set'); + } + + // ========================================================================= + // COUNTRY / STATE / CITY TESTS + // ========================================================================= + + /** + * Test set_country and get_country. + */ + public function test_set_and_get_country() { + $cart = new Cart(['country' => 'BR']); + + $this->assertEquals('BR', $cart->get_country()); + + $cart->set_country('DE'); + $this->assertEquals('DE', $cart->get_country()); + } + + // ========================================================================= + // AUTO RENEW TESTS + // ========================================================================= + + /** + * Test should_auto_renew defaults to true. + */ + public function test_auto_renew_default_true() { + $cart = new Cart([]); + + $this->assertTrue($cart->should_auto_renew()); + } + + /** + * Test auto_renew can be set to false. + */ + public function test_auto_renew_false() { + // The auto_renew setting is only applied when force_auto_renew is disabled. + wu_save_setting('force_auto_renew', false); + + $cart = new Cart(['auto_renew' => false]); + + $this->assertFalse($cart->should_auto_renew()); + + // Clean up setting + wu_save_setting('force_auto_renew', true); + } + + // ========================================================================= + // CART DESCRIPTOR TESTS + // ========================================================================= + + /** + * Test set and get cart descriptor. + */ + public function test_set_and_get_cart_descriptor() { + $cart = new Cart([]); + + $cart->set_cart_descriptor('My Custom Descriptor'); + + $this->assertEquals('My Custom Descriptor', $cart->get_cart_descriptor()); + } + + /** + * Test auto-generated cart descriptor includes product names. + */ + public function test_cart_descriptor_auto_generated() { + $plan = $this->create_plan([ + 'name' => 'Premium Plan', + 'amount' => 99.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $descriptor = $cart->get_cart_descriptor(); + $this->assertStringContainsString('Premium Plan', $descriptor); + } + + // ========================================================================= + // CALCULATE TOTALS TESTS + // ========================================================================= + + /** + * Test calculate_totals returns an object with all expected properties. + */ + public function test_calculate_totals_returns_object_with_properties() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $totals = $cart->calculate_totals(); + + $this->assertIsObject($totals); + $this->assertObjectHasProperty('subtotal', $totals); + $this->assertObjectHasProperty('total', $totals); + $this->assertObjectHasProperty('total_taxes', $totals); + $this->assertObjectHasProperty('total_fees', $totals); + $this->assertObjectHasProperty('total_discounts', $totals); + $this->assertObjectHasProperty('recurring', $totals); + $this->assertIsObject($totals->recurring); + $this->assertObjectHasProperty('subtotal', $totals->recurring); + $this->assertObjectHasProperty('total', $totals->recurring); + } + + /** + * Test calculate_totals returns correct values for a simple cart. + */ + public function test_calculate_totals_values() { + $plan = $this->create_plan(['amount' => 75.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $totals = $cart->calculate_totals(); + + $this->assertEquals(75.00, $totals->subtotal); + $this->assertEquals(75.00, $totals->total); + $this->assertEquals(0.0, $totals->total_taxes); + $this->assertEquals(0.0, $totals->total_discounts); + $this->assertEquals(75.00, $totals->recurring->total); + $this->assertEquals(75.00, $totals->recurring->subtotal); + } + + // ========================================================================= + // TO_MEMBERSHIP_DATA TESTS + // ========================================================================= + + /** + * Test to_membership_data contains expected keys. + */ + public function test_to_membership_data_keys() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $data = $cart->to_membership_data(); + + $this->assertArrayHasKey('recurring', $data); + $this->assertArrayHasKey('plan_id', $data); + $this->assertArrayHasKey('initial_amount', $data); + $this->assertArrayHasKey('addon_products', $data); + $this->assertArrayHasKey('currency', $data); + $this->assertArrayHasKey('duration', $data); + $this->assertArrayHasKey('duration_unit', $data); + $this->assertArrayHasKey('amount', $data); + $this->assertArrayHasKey('times_billed', $data); + $this->assertArrayHasKey('billing_cycles', $data); + } + + /** + * Test to_membership_data values for a recurring plan. + */ + public function test_to_membership_data_values() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'duration' => 1, + 'duration_unit' => 'month', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $data = $cart->to_membership_data(); + + $this->assertTrue($data['recurring']); + $this->assertEquals($plan->get_id(), $data['plan_id']); + $this->assertEquals(50.00, $data['initial_amount']); + $this->assertEquals(50.00, $data['amount']); + $this->assertEquals(1, $data['duration']); + $this->assertEquals('month', $data['duration_unit']); + $this->assertEquals(0, $data['times_billed']); + } + + // ========================================================================= + // TO_PAYMENT_DATA TESTS + // ========================================================================= + + /** + * Test to_payment_data contains expected keys. + */ + public function test_to_payment_data_keys() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $data = $cart->to_payment_data(); + + $this->assertArrayHasKey('status', $data); + $this->assertArrayHasKey('tax_total', $data); + $this->assertArrayHasKey('fees', $data); + $this->assertArrayHasKey('discounts', $data); + $this->assertArrayHasKey('line_items', $data); + $this->assertArrayHasKey('discount_code', $data); + $this->assertArrayHasKey('subtotal', $data); + $this->assertArrayHasKey('total', $data); + } + + /** + * Test to_payment_data values. + */ + public function test_to_payment_data_values() { + $plan = $this->create_plan(['amount' => 75.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $data = $cart->to_payment_data(); + + $this->assertEquals('pending', $data['status']); + $this->assertEquals(75.00, $data['total']); + $this->assertEquals(75.00, $data['subtotal']); + $this->assertEquals(0.0, $data['tax_total']); + $this->assertEquals('', $data['discount_code']); + } + + // ========================================================================= + // DONE (JSON SERIALIZATION) TESTS + // ========================================================================= + + /** + * Test done() returns an object with expected properties. + */ + public function test_done_returns_expected_properties() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $result = $cart->done(); + + $this->assertIsObject($result); + $this->assertObjectHasProperty('errors', $result); + $this->assertObjectHasProperty('type', $result); + $this->assertObjectHasProperty('valid', $result); + $this->assertObjectHasProperty('is_free', $result); + $this->assertObjectHasProperty('should_collect_payment', $result); + $this->assertObjectHasProperty('has_plan', $result); + $this->assertObjectHasProperty('has_recurring', $result); + $this->assertObjectHasProperty('has_discount', $result); + $this->assertObjectHasProperty('has_trial', $result); + $this->assertObjectHasProperty('line_items', $result); + $this->assertObjectHasProperty('totals', $result); + $this->assertObjectHasProperty('extra', $result); + $this->assertObjectHasProperty('dates', $result); + } + + /** + * Test done() returns correct type. + */ + public function test_done_returns_correct_type() { + $cart = new Cart([]); + + $result = $cart->done(); + + $this->assertEquals('new', $result->type); + } + + /** + * Test done() errors array is empty for valid cart. + */ + public function test_done_errors_empty_for_valid_cart() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $result = $cart->done(); + + $this->assertEmpty($result->errors); + $this->assertTrue($result->valid); + } + + /** + * Test done() errors populated when cart has errors. + */ + public function test_done_errors_populated_for_invalid_cart() { + $cart = new Cart([ + 'products' => [999999], + ]); + + $result = $cart->done(); + + $this->assertNotEmpty($result->errors); + $this->assertFalse($result->valid); + } + + // ========================================================================= + // JSON SERIALIZATION TESTS + // ========================================================================= + + /** + * Test jsonSerialize returns a JSON string. + */ + public function test_json_serialize() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $json = $cart->jsonSerialize(); + + $this->assertIsString($json); + + $decoded = json_decode($json); + $this->assertNotNull($decoded); + } + + // ========================================================================= + // TAX-RELATED TESTS + // ========================================================================= + + /** + * Test get_total_taxes is zero when taxes are not enabled. + */ + public function test_total_taxes_zero_when_taxes_disabled() { + $plan = $this->create_plan(['amount' => 100.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + 'country' => 'US', + ]); + + $this->assertEquals(0.0, $cart->get_total_taxes()); + } + + /** + * Test tax breakthrough is empty when taxes are disabled. + */ + public function test_tax_breakthrough_empty_when_no_taxes() { + $plan = $this->create_plan(['amount' => 100.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $breakthrough = $cart->get_tax_breakthrough(); + // All tax rates should be 0 when taxes are disabled + foreach ($breakthrough as $rate => $total) { + $this->assertEquals(0.0, $total); + } + } + + /** + * Test is_tax_exempt returns false by default. + */ + public function test_is_tax_exempt_default() { + $cart = new Cart([]); + + $this->assertFalse($cart->is_tax_exempt()); + } + + /** + * Test is_tax_exempt can be filtered to true. + */ + public function test_is_tax_exempt_filtered() { + add_filter('wu_cart_is_tax_exempt', '__return_true'); + + $cart = new Cart([]); + + $this->assertTrue($cart->is_tax_exempt()); + + remove_filter('wu_cart_is_tax_exempt', '__return_true'); + } + + // ========================================================================= + // SHOULD COLLECT PAYMENT TESTS + // ========================================================================= + + /** + * Test should_collect_payment returns false for a free cart with no recurring. + */ + public function test_should_not_collect_payment_for_free_cart() { + $plan = $this->create_plan([ + 'amount' => 0, + 'pricing_type' => 'free', + 'recurring' => false, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertFalse($cart->should_collect_payment()); + } + + /** + * Test should_collect_payment returns true for a paid cart. + */ + public function test_should_collect_payment_for_paid_cart() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertTrue($cart->should_collect_payment()); + } + + // ========================================================================= + // MEMBERSHIP / CUSTOMER / PAYMENT SETTER/GETTER TESTS + // ========================================================================= + + /** + * Test set_membership and get_membership. + */ + public function test_set_and_get_membership() { + $cart = new Cart([]); + + $this->assertNull($cart->get_membership()); + + $plan = $this->create_plan(['amount' => 50.00]); + + $membership = wu_create_membership([ + 'customer_id' => self::$customer->get_id(), + 'plan_id' => $plan->get_id(), + 'status' => 'active', + 'amount' => 50.00, + ]); + + $cart->set_membership($membership); + + $this->assertSame($membership, $cart->get_membership()); + + $membership->delete(); + } + + /** + * Test set_customer and get_customer. + */ + public function test_set_and_get_customer() { + $cart = new Cart([]); + + $cart->set_customer(self::$customer); + + $this->assertSame(self::$customer, $cart->get_customer()); + } + + /** + * Test set_payment and get_payment. + */ + public function test_set_and_get_payment() { + $cart = new Cart([]); + + $this->assertNull($cart->get_payment()); + + $payment = new \WP_Ultimo\Models\Payment(); + $payment->set_customer_id(self::$customer->get_id()); + $payment->set_total(50.00); + $payment->set_status(\WP_Ultimo\Database\Payments\Payment_Status::PENDING); + $payment->save(); + + $cart->set_payment($payment); + + $this->assertSame($payment, $cart->get_payment()); + + $payment->delete(); + } + + // ========================================================================= + // CART URL TESTS + // ========================================================================= + + /** + * Test get_cart_url returns a string. + */ + public function test_get_cart_url_is_string() { + $plan = $this->create_plan(['amount' => 49.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $url = $cart->get_cart_url(); + + $this->assertIsString($url); + } + + /** + * Test cart URL includes plan slug. + */ + public function test_cart_url_includes_plan_slug() { + $plan = $this->create_plan([ + 'slug' => 'my-plan-url-test', + 'amount' => 49.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $url = $cart->get_cart_url(); + + $this->assertStringContainsString('my-plan-url-test', $url); + } + + /** + * Test cart URL with yearly duration. + */ + public function test_cart_url_with_year_duration() { + $plan = $this->create_plan([ + 'slug' => 'yearly-plan-url', + 'amount' => 499.00, + 'duration' => 1, + 'duration_unit' => 'year', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $url = $cart->get_cart_url(); + + $this->assertStringContainsString('year', $url); + } + + // ========================================================================= + // DISCOUNT CODE TESTS + // ========================================================================= + + /** + * Test adding an invalid discount code produces an error. + */ + public function test_invalid_discount_code_produces_error() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + 'discount_code' => 'NONEXISTENTCODE', + ]); + + $this->assertTrue($cart->errors->has_errors()); + $error_codes = $cart->errors->get_error_codes(); + $this->assertContains('discount_code', $error_codes); + } + + /** + * Test add_discount_code with a Discount_Code model object. + */ + public function test_add_discount_code_with_model_object() { + $discount = wu_create_discount_code([ + 'name' => 'Test Discount Object', + 'code' => 'TESTOBJ' . wp_rand(1000, 9999), + 'value' => 10, + 'type' => 'percentage', + 'active' => true, + 'skip_validation' => true, + ]); + + $cart = new Cart([]); + + $result = $cart->add_discount_code($discount); + + $this->assertTrue($result); + $this->assertNotNull($cart->get_discount_code()); + $this->assertEquals($discount->get_code(), $cart->get_discount_code()->get_code()); + + $discount->delete(); + } + + /** + * Test add_discount_code with an invalid code string returns false. + */ + public function test_add_discount_code_with_invalid_string() { + $cart = new Cart([]); + + $result = $cart->add_discount_code('INVALIDCODE'); + + $this->assertFalse($result); + $this->assertNull($cart->get_discount_code()); + } + + /** + * Test percentage discount is applied via apply_discounts_to_item. + */ + public function test_percentage_discount_applied_to_line_item() { + $discount_code = new \WP_Ultimo\Models\Discount_Code(); + $discount_code->set_active(true); + $discount_code->set_code('PCTOFF20'); + $discount_code->set_value(20); + $discount_code->set_type('percentage'); + + $plan = $this->create_plan(['amount' => 100.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + // Apply the discount code directly to the cart + $cart->add_discount_code($discount_code); + + // Now manually apply discounts to a new line item + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Test Product', + 'unit_price' => 100, + 'quantity' => 1, + 'discountable' => true, + 'taxable' => false, + ]); + + $discounted = $cart->apply_discounts_to_item($line_item); + + // 20% of 100 = 20 discount + $this->assertEquals(20, $discounted->get_discount_rate()); + $this->assertEquals('percentage', $discounted->get_discount_type()); + $this->assertEquals(20.0, $discounted->get_discount_total()); + $this->assertEquals(80.0, $discounted->get_total()); + } + + /** + * Test absolute (flat) discount is applied to line item. + */ + public function test_absolute_discount_applied_to_line_item() { + $discount_code = new \WP_Ultimo\Models\Discount_Code(); + $discount_code->set_active(true); + $discount_code->set_code('FLAT15'); + $discount_code->set_value(15); + $discount_code->set_type('absolute'); + + $cart = new Cart([]); + $cart->add_discount_code($discount_code); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Test', + 'unit_price' => 100, + 'quantity' => 1, + 'discountable' => true, + 'taxable' => false, + ]); + + $discounted = $cart->apply_discounts_to_item($line_item); + + // 15 flat off 100 + $this->assertEquals(15, $discounted->get_discount_rate()); + $this->assertEquals('absolute', $discounted->get_discount_type()); + $this->assertEquals(15.0, $discounted->get_discount_total()); + $this->assertEquals(85.0, $discounted->get_total()); + } + + /** + * Test 100% discount zeroes out line item total. + */ + public function test_hundred_percent_discount_zeroes_line_item() { + $discount_code = new \WP_Ultimo\Models\Discount_Code(); + $discount_code->set_active(true); + $discount_code->set_code('FULL100'); + $discount_code->set_value(100); + $discount_code->set_type('percentage'); + + $cart = new Cart([]); + $cart->add_discount_code($discount_code); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Test', + 'unit_price' => 50, + 'quantity' => 1, + 'discountable' => true, + 'taxable' => false, + ]); + + $discounted = $cart->apply_discounts_to_item($line_item); + + $this->assertEquals(0.0, $discounted->get_total()); + $this->assertEquals(50.0, $discounted->get_discount_total()); + } + + /** + * Test discount applied to setup fee line item. + */ + public function test_discount_applied_to_fee_line_item() { + $discount_code = new \WP_Ultimo\Models\Discount_Code(); + $discount_code->set_active(true); + $discount_code->set_code('FEEDSC'); + $discount_code->set_value(10); + $discount_code->set_type('percentage'); + $discount_code->set_setup_fee_value(50); + $discount_code->set_setup_fee_type('percentage'); + + $cart = new Cart([]); + $cart->add_discount_code($discount_code); + + // Apply to a fee line item + $fee_item = new Line_Item([ + 'type' => 'fee', + 'title' => 'Setup Fee', + 'unit_price' => 20, + 'quantity' => 1, + 'discountable' => true, + 'taxable' => false, + ]); + + $discounted = $cart->apply_discounts_to_item($fee_item); + + // 50% of 20 = 10 discount on fee + $this->assertEquals(50, $discounted->get_discount_rate()); + $this->assertEquals(10.0, $discounted->get_discount_total()); + $this->assertEquals(10.0, $discounted->get_total()); + } + + /** + * Test discount that does not apply to renewals. + */ + public function test_discount_not_applied_to_renewals() { + $discount_code = new \WP_Ultimo\Models\Discount_Code(); + $discount_code->set_active(true); + $discount_code->set_code('NORNW'); + $discount_code->set_value(50); + $discount_code->set_type('percentage'); + $discount_code->set_apply_to_renewals(false); + + $cart = new Cart([]); + $cart->add_discount_code($discount_code); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Monthly Plan', + 'unit_price' => 100, + 'quantity' => 1, + 'discountable' => true, + 'recurring' => true, + 'taxable' => false, + ]); + + $discounted = $cart->apply_discounts_to_item($line_item); + + // Line item should be discounted by 50% + $this->assertEquals(50.0, $discounted->get_total()); + // But should NOT apply to renewals + $this->assertFalse($discounted->should_apply_discount_to_renewals()); + } + + /** + * Test discount that applies to renewals. + */ + public function test_discount_applied_to_renewals() { + $discount_code = new \WP_Ultimo\Models\Discount_Code(); + $discount_code->set_active(true); + $discount_code->set_code('YESRNW'); + $discount_code->set_value(25); + $discount_code->set_type('percentage'); + $discount_code->set_apply_to_renewals(true); + + $cart = new Cart([]); + $cart->add_discount_code($discount_code); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Monthly Plan', + 'unit_price' => 100, + 'quantity' => 1, + 'discountable' => true, + 'recurring' => true, + 'taxable' => false, + ]); + + $discounted = $cart->apply_discounts_to_item($line_item); + + // Line item should be discounted by 25% + $this->assertEquals(75.0, $discounted->get_total()); + // And SHOULD apply to renewals + $this->assertTrue($discounted->should_apply_discount_to_renewals()); + } + + // ========================================================================= + // TOTAL FEES / TOTAL DISCOUNTS TESTS + // ========================================================================= + + /** + * Test get_total_fees returns zero when no fees. + */ + public function test_get_total_fees_zero_without_fees() { + $plan = $this->create_plan(['amount' => 50.00, 'setup_fee' => 0]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals(0.0, $cart->get_total_fees()); + } + + /** + * Test get_total_fees returns correct value with setup fee. + */ + public function test_get_total_fees_with_setup_fee() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'setup_fee' => 15.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + // Note: get_fees returns 'fees' type but line items use 'fee' type, + // so get_total_fees may return 0. Let's check fee line items directly. + $fee_items = $cart->get_line_items_by_type('fee'); + $fee_total = 0; + foreach ($fee_items as $fee) { + $fee_total += $fee->get_total(); + } + $this->assertEquals(15.00, $fee_total); + } + + /** + * Test get_total_discounts returns zero when no discounts applied. + */ + public function test_get_total_discounts_zero_without_discounts() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals(0.0, $cart->get_total_discounts()); + } + + // ========================================================================= + // RECURRING SUBTOTAL TESTS + // ========================================================================= + + /** + * Test recurring subtotal equals recurring total without taxes. + */ + public function test_recurring_subtotal_equals_total_without_taxes() { + $plan = $this->create_plan(['amount' => 60.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals($cart->get_recurring_total(), $cart->get_recurring_subtotal()); + } + + /** + * Test recurring subtotal is zero for non-recurring cart. + */ + public function test_recurring_subtotal_zero_for_non_recurring() { + $service = $this->create_service([ + 'amount' => 30.00, + 'recurring' => false, + ]); + + $cart = new Cart([ + 'products' => [$service->get_id()], + ]); + + $this->assertEquals(0.0, $cart->get_recurring_subtotal()); + } + + // ========================================================================= + // GET RECOVERED PAYMENT TEST + // ========================================================================= + + /** + * Test get_recovered_payment returns falsy value by default. + */ + public function test_get_recovered_payment_default() { + $cart = new Cart([]); + + $this->assertEmpty($cart->get_recovered_payment()); + } + + // ========================================================================= + // SUBTOTAL NEVER NEGATIVE + // ========================================================================= + + /** + * Test subtotal never goes below zero. + */ + public function test_subtotal_never_negative() { + $cart = new Cart([]); + + // Add a credit that would exceed any subtotal + $credit = new Line_Item([ + 'type' => 'credit', + 'title' => 'Large Credit', + 'unit_price' => -5000, + 'quantity' => 1, + 'discountable' => false, + 'taxable' => false, + ]); + $cart->add_line_item($credit); + + $this->assertGreaterThanOrEqual(0, $cart->get_subtotal()); + } + + // ========================================================================= + // CONSTRUCTOR ACTION TESTS + // ========================================================================= + + /** + * Test that wu_cart_after_setup action fires. + */ + public function test_constructor_triggers_after_setup_action() { + $after_action_called = false; + + add_action( + 'wu_cart_after_setup', + function () use (&$after_action_called) { + $after_action_called = true; + } + ); + + new Cart([]); + + $this->assertTrue($after_action_called); + } + + // ========================================================================= + // APPLY DISCOUNTS / TAXES TO ITEM TESTS + // ========================================================================= + + /** + * Test apply_discounts_to_item returns unchanged item when no discount code. + */ + public function test_apply_discounts_to_item_no_code() { + $cart = new Cart([]); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Test', + 'unit_price' => 100, + 'quantity' => 1, + 'discountable' => true, + 'taxable' => false, + ]); + + $result = $cart->apply_discounts_to_item($line_item); + + $this->assertEquals(0, $result->get_discount_total()); + } + + /** + * Test apply_discounts_to_item returns unchanged item when item is not discountable. + */ + public function test_apply_discounts_to_non_discountable_item() { + $code = 'NODSC' . wp_rand(1000, 9999); + + $discount = wu_create_discount_code([ + 'name' => 'Discount', + 'code' => $code, + 'value' => 50, + 'type' => 'percentage', + 'active' => true, + 'skip_validation' => true, + ]); + + $cart = new Cart([ + 'discount_code' => $code, + ]); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Test', + 'unit_price' => 100, + 'quantity' => 1, + 'discountable' => false, + 'taxable' => false, + ]); + + $result = $cart->apply_discounts_to_item($line_item); + + $this->assertEquals(0, $result->get_discount_total()); + + $discount->delete(); + } + + /** + * Test apply_taxes_to_item returns unchanged item when taxes are disabled. + */ + public function test_apply_taxes_to_item_when_taxes_disabled() { + $cart = new Cart([]); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Test', + 'unit_price' => 100, + 'quantity' => 1, + 'taxable' => true, + ]); + + $result = $cart->apply_taxes_to_item($line_item); + + $this->assertEquals(0, $result->get_tax_total()); + } + + /** + * Test apply_taxes_to_item returns unchanged item when item not taxable. + */ + public function test_apply_taxes_to_non_taxable_item() { + $cart = new Cart([]); + + $line_item = new Line_Item([ + 'type' => 'product', + 'title' => 'Test', + 'unit_price' => 100, + 'quantity' => 1, + 'taxable' => false, + ]); + + $result = $cart->apply_taxes_to_item($line_item); + + $this->assertEquals(0, $result->get_tax_total()); + } + + // ========================================================================= + // MULTIPLE PRODUCT TOTALS + // ========================================================================= + + /** + * Test cart total with multiple products of different types. + */ + public function test_multiple_product_totals() { + $plan = $this->create_plan(['amount' => 50.00]); + $service1 = $this->create_service(['amount' => 10.00, 'recurring' => false]); + $service2 = $this->create_service(['amount' => 5.00, 'recurring' => false]); + + $cart = new Cart([ + 'products' => [$plan->get_id(), $service1->get_id(), $service2->get_id()], + ]); + + $this->assertEquals(65.00, $cart->get_total()); + $this->assertEquals(50.00, $cart->get_recurring_total()); + $this->assertCount(3, $cart->get_all_products()); + } + + // ========================================================================= + // GET LINE ITEMS TESTS + // ========================================================================= + + /** + * Test that all returned line items are Line_Item instances. + */ + public function test_line_items_are_correct_instances() { + $plan = $this->create_plan(['amount' => 50.00, 'setup_fee' => 10.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $items = $cart->get_line_items(); + $this->assertNotEmpty($items); + + foreach ($items as $item) { + $this->assertInstanceOf(Line_Item::class, $item); + } + } + + // ========================================================================= + // RECURRING / NON-RECURRING PRODUCT LISTS + // ========================================================================= + + /** + * Test get_recurring_products returns recurring products. + */ + public function test_get_recurring_products() { + $plan = $this->create_plan(['amount' => 50.00, 'recurring' => true]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + // Note: recurring_products is populated during build; in a simple 'new' cart + // it may not be populated. Check get_all_products instead. + $all_products = $cart->get_all_products(); + $this->assertNotEmpty($all_products); + + $found_recurring = false; + foreach ($all_products as $p) { + if ($p->is_recurring()) { + $found_recurring = true; + } + } + $this->assertTrue($found_recurring); + } + + // ========================================================================= + // BILLING START / NEXT CHARGE DATE TESTS + // ========================================================================= + + /** + * Test billing start date returns null for a free non-recurring cart. + */ + public function test_billing_start_date_null_for_free_non_recurring() { + $plan = $this->create_plan([ + 'amount' => 0, + 'pricing_type' => 'free', + 'recurring' => false, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertNull($cart->get_billing_start_date()); + } + + /** + * Test billing start date for a product with a trial. + */ + public function test_billing_start_date_with_trial() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'trial_duration' => 14, + 'trial_duration_unit' => 'day', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $billing_start = $cart->get_billing_start_date(); + + // Trial should return a timestamp in the future + if ($billing_start !== null) { + $this->assertGreaterThan(time(), $billing_start); + } + } + + /** + * Test get_billing_next_charge_date for a recurring product. + */ + public function test_billing_next_charge_date_for_recurring() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'duration' => 1, + 'duration_unit' => 'month', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $next_charge = $cart->get_billing_next_charge_date(); + + // Should be approximately 1 month from now + $expected_min = strtotime('+27 days'); + $expected_max = strtotime('+32 days'); + + $this->assertGreaterThanOrEqual($expected_min, $next_charge); + $this->assertLessThanOrEqual($expected_max, $next_charge); + } + + // ========================================================================= + // GET DISCOUNTS / GET FEES HELPER METHOD TESTS + // ========================================================================= + + /** + * Test get_discounts returns empty when no discounts applied. + */ + public function test_get_discounts_empty() { + $plan = $this->create_plan(['amount' => 50.00]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $discounts = $cart->get_discounts(); + $this->assertEmpty($discounts); + } + + // ========================================================================= + // ZERO AMOUNT PRODUCT TESTS + // ========================================================================= + + /** + * Test that a zero-amount recurring product creates a free cart. + */ + public function test_zero_amount_recurring_product() { + $plan = $this->create_plan([ + 'amount' => 0, + 'recurring' => true, + 'pricing_type' => 'free', + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertTrue($cart->is_free()); + $this->assertEquals(0.0, $cart->get_total()); + $this->assertEquals(0.0, $cart->get_recurring_total()); + } + + // ========================================================================= + // PRODUCT SLUG ADDITION + // ========================================================================= + + /** + * Test adding product by ID populates the product slug on line item. + */ + public function test_product_slug_on_line_item() { + $plan = $this->create_plan([ + 'slug' => 'slug-test-product', + 'amount' => 50.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $product_items = $cart->get_line_items_by_type('product'); + $this->assertNotEmpty($product_items); + + $item = reset($product_items); + $this->assertEquals('slug-test-product', $item->get_product_slug()); + } + + // ========================================================================= + // FILTER TESTS + // ========================================================================= + + /** + * Test wu_cart_get_total filter modifies the total. + */ + public function test_total_filter() { + $plan = $this->create_plan(['amount' => 50.00]); + + $filter = function ($total) { + return $total + 5.00; + }; + add_filter('wu_cart_get_total', $filter); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals(55.00, $cart->get_total()); + + remove_filter('wu_cart_get_total', $filter); + } + + /** + * Test wu_cart_get_subtotal filter modifies the subtotal. + */ + public function test_subtotal_filter() { + $plan = $this->create_plan(['amount' => 50.00]); + + $filter = function ($subtotal) { + return $subtotal + 10.00; + }; + add_filter('wu_cart_get_subtotal', $filter); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $this->assertEquals(60.00, $cart->get_subtotal()); + + remove_filter('wu_cart_get_subtotal', $filter); + } + + // ========================================================================= + // CONSTRUCTOR WITH PRODUCTS ARRAY + // ========================================================================= + + /** + * Test that products argument passed as non-array does not crash. + */ + public function test_products_as_non_array_does_not_crash() { + // shortcode_atts will keep it as default [] if key does not match + $cart = new Cart([ + 'products' => 'not_an_array', + ]); + + $this->assertInstanceOf(Cart::class, $cart); + } + + /** + * Test cart with empty products array. + */ + public function test_cart_with_empty_products_array() { + $cart = new Cart([ + 'products' => [], + ]); + + $this->assertEmpty($cart->get_all_products()); + $this->assertTrue($cart->is_free()); + } + + // ========================================================================= + // NEGATIVE SETUP FEE (CREDIT) TESTS + // ========================================================================= + + /** + * Test negative setup fee (signup credit) creates appropriate line item. + */ + public function test_negative_setup_fee_creates_credit_line_item() { + $plan = $this->create_plan([ + 'amount' => 50.00, + 'setup_fee' => -10.00, + ]); + + $cart = new Cart([ + 'products' => [$plan->get_id()], + ]); + + $fee_items = $cart->get_line_items_by_type('fee'); + $this->assertNotEmpty($fee_items); + + $fee = reset($fee_items); + // The title should contain "Signup Credit" for negative fee + $this->assertStringContainsString('Signup Credit', $fee->get_title()); + + // Total should be 50 - 10 = 40 + $this->assertEquals(40.00, $cart->get_total()); + } + public static function tear_down_after_class() { global $wpdb; self::$customer->delete(); diff --git a/tests/WP_Ultimo/Checkout/Line_Item_Test.php b/tests/WP_Ultimo/Checkout/Line_Item_Test.php new file mode 100644 index 00000000..bb1d1bee --- /dev/null +++ b/tests/WP_Ultimo/Checkout/Line_Item_Test.php @@ -0,0 +1,602 @@ + 'test_hash_123', + 'type' => 'product', + 'title' => 'Test Product', + 'unit_price' => 100, + 'quantity' => 1, + ]; + + return new Line_Item(array_merge($defaults, $overrides)); + } + + /** + * Test constructor sets basic attributes. + */ + public function test_constructor_sets_attributes(): void { + + $line_item = $this->create_line_item(); + + $this->assertEquals('Test Product', $line_item->get_title()); + $this->assertEquals(100, $line_item->get_unit_price()); + $this->assertEquals(1, $line_item->get_quantity()); + $this->assertEquals('product', $line_item->get_type()); + } + + /** + * Test constructor generates ID from type and hash. + */ + public function test_constructor_generates_id(): void { + + $line_item = $this->create_line_item(['hash' => 'abc123', 'type' => 'fee']); + + $id = $line_item->get_id(); + + $this->assertStringStartsWith('LN_FEE_', $id); + $this->assertStringContainsString('abc123', $id); + } + + /** + * Test recalculate_totals with simple product. + */ + public function test_recalculate_totals_simple(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 50, + 'quantity' => 2, + ]); + + $this->assertEquals(100, $line_item->get_subtotal()); + $this->assertEquals(100, $line_item->get_total()); + $this->assertEquals(0, $line_item->get_discount_total()); + $this->assertEquals(0, $line_item->get_tax_total()); + } + + /** + * Test recalculate_totals with percentage discount. + */ + public function test_recalculate_totals_with_percentage_discount(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 100, + 'quantity' => 1, + 'discount_rate' => 10, + 'discount_type' => 'percentage', + ]); + + $this->assertEquals(100, $line_item->get_subtotal()); + $this->assertEquals(10, $line_item->get_discount_total()); + $this->assertEquals(90, $line_item->get_total()); + } + + /** + * Test recalculate_totals with absolute discount. + */ + public function test_recalculate_totals_with_absolute_discount(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 100, + 'quantity' => 1, + 'discount_rate' => 25, + 'discount_type' => 'absolute', + ]); + + $this->assertEquals(100, $line_item->get_subtotal()); + $this->assertEquals(25, $line_item->get_discount_total()); + $this->assertEquals(75, $line_item->get_total()); + } + + /** + * Test recalculate_totals with tax exclusive. + */ + public function test_recalculate_totals_with_tax_exclusive(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 100, + 'quantity' => 1, + 'tax_rate' => 10, + 'tax_type' => 'percentage', + 'tax_inclusive' => false, + ]); + + $this->assertEquals(100, $line_item->get_subtotal()); + $this->assertEquals(10, $line_item->get_tax_total()); + $this->assertEquals(110, $line_item->get_total()); + } + + /** + * Test recalculate_totals with tax inclusive. + */ + public function test_recalculate_totals_with_tax_inclusive(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 100, + 'quantity' => 1, + 'tax_rate' => 10, + 'tax_type' => 'percentage', + 'tax_inclusive' => true, + ]); + + $this->assertEquals(100, $line_item->get_subtotal()); + // Tax inclusive means total stays the same + $this->assertEquals(100, $line_item->get_total()); + } + + /** + * Test recalculate_totals with tax exempt. + */ + public function test_recalculate_totals_tax_exempt(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 100, + 'quantity' => 1, + 'tax_rate' => 10, + 'tax_type' => 'percentage', + 'tax_inclusive' => false, + 'tax_exempt' => true, + ]); + + $this->assertEquals(100, $line_item->get_subtotal()); + $this->assertEquals(0, $line_item->get_tax_total()); + $this->assertEquals(100, $line_item->get_total()); + } + + /** + * Test discount cannot make total negative. + */ + public function test_discount_cannot_make_total_negative(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 50, + 'quantity' => 1, + 'discount_rate' => 100, + 'discount_type' => 'absolute', + ]); + + $this->assertGreaterThanOrEqual(0, $line_item->get_total()); + $this->assertEquals(50, $line_item->get_discount_total()); + } + + /** + * Test recalculate_totals with discount and tax combined. + */ + public function test_recalculate_totals_discount_and_tax(): void { + + $line_item = $this->create_line_item([ + 'unit_price' => 100, + 'quantity' => 1, + 'discount_rate' => 20, + 'discount_type' => 'percentage', + 'tax_rate' => 10, + 'tax_type' => 'percentage', + 'tax_inclusive' => false, + ]); + + // Subtotal: 100 + // Discount: 20% of 100 = 20 + // After discount: 80 + // Tax: 10% of 80 = 8 + // Total: 80 + 8 = 88 + $this->assertEquals(100, $line_item->get_subtotal()); + $this->assertEquals(20, $line_item->get_discount_total()); + $this->assertEquals(8, $line_item->get_tax_total()); + $this->assertEquals(88, $line_item->get_total()); + } + + /** + * Test getters and setters for type. + */ + public function test_type_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_type('fee'); + $this->assertEquals('fee', $line_item->get_type()); + + $line_item->set_type('credit'); + $this->assertEquals('credit', $line_item->get_type()); + } + + /** + * Test getters and setters for quantity. + */ + public function test_quantity_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_quantity(5); + $this->assertEquals(5, $line_item->get_quantity()); + } + + /** + * Test getters and setters for unit_price. + */ + public function test_unit_price_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_unit_price(250); + $this->assertEquals(250, $line_item->get_unit_price()); + } + + /** + * Test getters and setters for tax_rate. + */ + public function test_tax_rate_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_tax_rate(15.5); + $this->assertEquals(15.5, $line_item->get_tax_rate()); + } + + /** + * Test getters and setters for tax_type. + */ + public function test_tax_type_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_tax_type('absolute'); + $this->assertEquals('absolute', $line_item->get_tax_type()); + } + + /** + * Test getters and setters for tax_inclusive. + */ + public function test_tax_inclusive_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $this->assertFalse($line_item->get_tax_inclusive()); + + $line_item->set_tax_inclusive(true); + $this->assertTrue($line_item->get_tax_inclusive()); + } + + /** + * Test getters and setters for tax_exempt. + */ + public function test_tax_exempt_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $this->assertFalse($line_item->is_tax_exempt()); + + $line_item->set_tax_exempt(true); + $this->assertTrue($line_item->is_tax_exempt()); + } + + /** + * Test getters and setters for recurring. + */ + public function test_recurring_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $this->assertFalse($line_item->is_recurring()); + + $line_item->set_recurring(true); + $this->assertTrue($line_item->is_recurring()); + } + + /** + * Test getters and setters for duration. + */ + public function test_duration_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_duration(3); + $this->assertEquals(3, $line_item->get_duration()); + } + + /** + * Test getters and setters for duration_unit. + */ + public function test_duration_unit_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_duration_unit('year'); + $this->assertEquals('year', $line_item->get_duration_unit()); + } + + /** + * Test getters and setters for billing_cycles. + */ + public function test_billing_cycles_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_billing_cycles(12); + $this->assertEquals(12, $line_item->get_billing_cycles()); + } + + /** + * Test getters and setters for discount_rate. + */ + public function test_discount_rate_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_discount_rate(15.5); + $this->assertEquals(15.5, $line_item->get_discount_rate()); + } + + /** + * Test getters and setters for discount_type. + */ + public function test_discount_type_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_discount_type('absolute'); + $this->assertEquals('absolute', $line_item->get_discount_type()); + } + + /** + * Test getters and setters for discount_label. + */ + public function test_discount_label_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_discount_label('SUMMER20'); + $this->assertEquals('SUMMER20', $line_item->get_discount_label()); + } + + /** + * Test getters and setters for apply_discount_to_renewals. + */ + public function test_apply_discount_to_renewals_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $this->assertTrue($line_item->should_apply_discount_to_renewals()); + + $line_item->set_apply_discount_to_renewals(false); + $this->assertFalse($line_item->should_apply_discount_to_renewals()); + } + + /** + * Test getters and setters for product_id. + */ + public function test_product_id_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_product_id(42); + $this->assertEquals(42, $line_item->get_product_id()); + } + + /** + * Test getters and setters for title. + */ + public function test_title_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_title('New Title'); + $this->assertEquals('New Title', $line_item->get_title()); + } + + /** + * Test getters and setters for description. + */ + public function test_description_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_description('A detailed description'); + $this->assertEquals('A detailed description', $line_item->get_description()); + } + + /** + * Test getters and setters for tax_label. + */ + public function test_tax_label_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_tax_label('VAT'); + $this->assertEquals('VAT', $line_item->get_tax_label()); + } + + /** + * Test getters and setters for tax_category. + */ + public function test_tax_category_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_tax_category('digital_goods'); + $this->assertEquals('digital_goods', $line_item->get_tax_category()); + } + + /** + * Test getters and setters for discountable. + */ + public function test_discountable_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_discountable(true); + $this->assertTrue($line_item->is_discountable()); + + $line_item->set_discountable(false); + $this->assertFalse($line_item->is_discountable()); + } + + /** + * Test getters and setters for taxable. + */ + public function test_taxable_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_taxable(true); + $this->assertTrue($line_item->is_taxable()); + + $line_item->set_taxable(false); + $this->assertFalse($line_item->is_taxable()); + } + + /** + * Test getters and setters for product_slug. + */ + public function test_product_slug_getter_setter(): void { + + $line_item = $this->create_line_item(); + + $line_item->set_product_slug('premium-plan'); + $this->assertEquals('premium-plan', $line_item->get_product_slug()); + } + + /** + * Test get_recurring_description returns empty for non-recurring. + */ + public function test_get_recurring_description_non_recurring(): void { + + $line_item = $this->create_line_item(['recurring' => false]); + + $this->assertEquals('', $line_item->get_recurring_description()); + } + + /** + * Test get_recurring_description for recurring item. + */ + public function test_get_recurring_description_recurring(): void { + + $line_item = $this->create_line_item([ + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + ]); + + $description = $line_item->get_recurring_description(); + + $this->assertNotEmpty($description); + } + + /** + * Test to_array returns all properties. + */ + public function test_to_array(): void { + + $line_item = $this->create_line_item([ + 'title' => 'Test', + 'unit_price' => 100, + 'quantity' => 2, + ]); + + $array = $line_item->to_array(); + + $this->assertIsArray($array); + $this->assertArrayHasKey('title', $array); + $this->assertArrayHasKey('unit_price', $array); + $this->assertArrayHasKey('quantity', $array); + $this->assertArrayHasKey('subtotal', $array); + $this->assertArrayHasKey('total', $array); + $this->assertArrayHasKey('type', $array); + $this->assertArrayHasKey('recurring_description', $array); + } + + /** + * Test jsonSerialize returns same as to_array. + */ + public function test_json_serialize(): void { + + $line_item = $this->create_line_item(); + + $this->assertEquals($line_item->to_array(), $line_item->jsonSerialize()); + } + + /** + * Test json_encode works correctly. + */ + public function test_json_encode(): void { + + $line_item = $this->create_line_item(['title' => 'Premium Plan']); + + $json = json_encode($line_item); + + $this->assertIsString($json); + $this->assertStringContainsString('Premium Plan', $json); + } + + /** + * Test get_product returns false when no product is set. + */ + public function test_get_product_returns_false_when_no_product(): void { + + $line_item = $this->create_line_item(); + + $this->assertFalse($line_item->get_product()); + } + + /** + * Test recalculate_totals returns the line item for chaining. + */ + public function test_recalculate_totals_returns_self(): void { + + $line_item = $this->create_line_item(); + + $result = $line_item->recalculate_totals(); + + $this->assertInstanceOf(Line_Item::class, $result); + } + + /** + * Test zero unit price. + */ + public function test_zero_unit_price(): void { + + $line_item = $this->create_line_item(['unit_price' => 0]); + + $this->assertEquals(0, $line_item->get_subtotal()); + $this->assertEquals(0, $line_item->get_total()); + } + + /** + * Test different line item types. + * + * @dataProvider lineItemTypesProvider + */ + public function test_line_item_types(string $type): void { + + $line_item = $this->create_line_item(['type' => $type]); + + $this->assertEquals($type, $line_item->get_type()); + $this->assertStringContainsString(strtoupper($type), $line_item->get_id()); + } + + /** + * Data provider for line item types. + */ + public function lineItemTypesProvider(): array { + + return [ + 'product' => ['product'], + 'fee' => ['fee'], + 'credit' => ['credit'], + 'discount' => ['discount'], + 'prorate' => ['prorate'], + ]; + } +} diff --git a/tests/WP_Ultimo/Helpers/Arr_Test.php b/tests/WP_Ultimo/Helpers/Arr_Test.php new file mode 100644 index 00000000..ca701f1d --- /dev/null +++ b/tests/WP_Ultimo/Helpers/Arr_Test.php @@ -0,0 +1,257 @@ + 'John', 'age' => 30]; + + $this->assertEquals('John', Arr::get($array, 'name')); + $this->assertEquals(30, Arr::get($array, 'age')); + } + + /** + * Test get with dot notation. + */ + public function test_get_dot_notation(): void { + + $array = [ + 'user' => [ + 'name' => 'John', + 'address' => [ + 'city' => 'New York', + 'country' => 'US', + ], + ], + ]; + + $this->assertEquals('John', Arr::get($array, 'user.name')); + $this->assertEquals('New York', Arr::get($array, 'user.address.city')); + $this->assertEquals('US', Arr::get($array, 'user.address.country')); + } + + /** + * Test get with missing key returns default. + */ + public function test_get_missing_key_returns_default(): void { + + $array = ['name' => 'John']; + + $this->assertNull(Arr::get($array, 'missing')); + $this->assertEquals('default', Arr::get($array, 'missing', 'default')); + $this->assertEquals(0, Arr::get($array, 'missing', 0)); + } + + /** + * Test get with null key returns entire array. + */ + public function test_get_null_key_returns_array(): void { + + $array = ['name' => 'John', 'age' => 30]; + + $this->assertEquals($array, Arr::get($array, null)); + } + + /** + * Test get with missing nested key returns default. + */ + public function test_get_missing_nested_key_returns_default(): void { + + $array = ['user' => ['name' => 'John']]; + + $this->assertNull(Arr::get($array, 'user.email')); + $this->assertNull(Arr::get($array, 'user.address.city')); + } + + /** + * Test set with simple key. + */ + public function test_set_simple_key(): void { + + $array = []; + + Arr::set($array, 'name', 'John'); + + $this->assertEquals('John', $array['name']); + } + + /** + * Test set with dot notation. + */ + public function test_set_dot_notation(): void { + + $array = []; + + Arr::set($array, 'user.name', 'John'); + Arr::set($array, 'user.address.city', 'New York'); + + $this->assertEquals('John', $array['user']['name']); + $this->assertEquals('New York', $array['user']['address']['city']); + } + + /** + * Test set with null key replaces entire array. + */ + public function test_set_null_key_replaces_array(): void { + + $array = ['old' => 'data']; + + $result = Arr::set($array, null, 'new_value'); + + $this->assertEquals('new_value', $result); + } + + /** + * Test set overwrites existing values. + */ + public function test_set_overwrites_existing(): void { + + $array = ['user' => ['name' => 'John']]; + + Arr::set($array, 'user.name', 'Jane'); + + $this->assertEquals('Jane', $array['user']['name']); + } + + /** + * Test set creates intermediate arrays. + */ + public function test_set_creates_intermediate_arrays(): void { + + $array = []; + + Arr::set($array, 'a.b.c.d', 'deep_value'); + + $this->assertEquals('deep_value', $array['a']['b']['c']['d']); + } + + /** + * Test filter with closure. + */ + public function test_filter_with_closure(): void { + + $array = [1, 2, 3, 4, 5, 6]; + + $result = Arr::filter($array, function ($value) { + return $value > 3; + }); + + $this->assertCount(3, $result); + $this->assertEquals([4, 5, 6], $result); + } + + /** + * Test filter preserves matching items. + */ + public function test_filter_preserves_matching_items(): void { + + $array = [ + ['name' => 'John', 'active' => true], + ['name' => 'Jane', 'active' => false], + ['name' => 'Bob', 'active' => true], + ]; + + $result = Arr::filter($array, function ($item) { + return $item['active'] === true; + }); + + $this->assertCount(2, $result); + $this->assertEquals('John', $result[0]['name']); + $this->assertEquals('Bob', $result[1]['name']); + } + + /** + * Test filter_by_property with simple match. + */ + public function test_filter_by_property_simple(): void { + + $array = [ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Jane', 'role' => 'editor'], + ['name' => 'Bob', 'role' => 'admin'], + ]; + + $result = Arr::filter_by_property($array, 'role', 'admin'); + + $this->assertCount(2, $result); + } + + /** + * Test filter_by_property with dot notation. + */ + public function test_filter_by_property_dot_notation(): void { + + $array = [ + ['name' => 'John', 'meta' => ['status' => 'active']], + ['name' => 'Jane', 'meta' => ['status' => 'inactive']], + ['name' => 'Bob', 'meta' => ['status' => 'active']], + ]; + + $result = Arr::filter_by_property($array, 'meta.status', 'active'); + + $this->assertCount(2, $result); + } + + /** + * Test filter_by_property returns first result. + */ + public function test_filter_by_property_returns_first(): void { + + $array = [ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Bob', 'role' => 'admin'], + ]; + + $result = Arr::filter_by_property($array, 'role', 'admin', Arr::RESULTS_FIRST); + + $this->assertIsArray($result); + $this->assertEquals('John', $result['name']); + } + + /** + * Test filter_by_property returns last result. + */ + public function test_filter_by_property_returns_last(): void { + + $array = [ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Bob', 'role' => 'admin'], + ]; + + $result = Arr::filter_by_property($array, 'role', 'admin', Arr::RESULTS_LAST); + + $this->assertIsArray($result); + $this->assertEquals('Bob', $result['name']); + } + + /** + * Test filter_by_property with no matches. + */ + public function test_filter_by_property_no_matches(): void { + + $array = [ + ['name' => 'John', 'role' => 'admin'], + ]; + + $result = Arr::filter_by_property($array, 'role', 'nonexistent'); + + $this->assertEmpty($result); + } + + /** + * Test constants exist. + */ + public function test_constants(): void { + + $this->assertEquals(0, Arr::RESULTS_ALL); + $this->assertEquals(1, Arr::RESULTS_FIRST); + $this->assertEquals(2, Arr::RESULTS_LAST); + } +} diff --git a/tests/WP_Ultimo/Helpers/WP_Config_Test.php b/tests/WP_Ultimo/Helpers/WP_Config_Test.php new file mode 100644 index 00000000..61f61c72 --- /dev/null +++ b/tests/WP_Ultimo/Helpers/WP_Config_Test.php @@ -0,0 +1,188 @@ +wp_config = WP_Config::get_instance(); + } + + /** + * Test get_instance returns singleton. + */ + public function test_get_instance_returns_singleton(): void { + + $instance1 = WP_Config::get_instance(); + $instance2 = WP_Config::get_instance(); + + $this->assertSame($instance1, $instance2); + } + + /** + * Test get_wp_config_path returns a string. + */ + public function test_get_wp_config_path_returns_string(): void { + + $path = $this->wp_config->get_wp_config_path(); + + $this->assertIsString($path); + $this->assertStringContainsString('.php', $path); + } + + /** + * Test inject_contents inserts at correct position. + */ + public function test_inject_contents_inserts_at_position(): void { + + $content = ['line1', 'line2', 'line3']; + + $result = $this->wp_config->inject_contents($content, 1, 'inserted'); + + $this->assertCount(4, $result); + $this->assertEquals('line1', $result[0]); + $this->assertEquals('inserted', $result[1]); + $this->assertEquals('line2', $result[2]); + $this->assertEquals('line3', $result[3]); + } + + /** + * Test inject_contents at beginning. + */ + public function test_inject_contents_at_beginning(): void { + + $content = ['line1', 'line2']; + + $result = $this->wp_config->inject_contents($content, 0, 'first'); + + $this->assertCount(3, $result); + $this->assertEquals('first', $result[0]); + $this->assertEquals('line1', $result[1]); + } + + /** + * Test inject_contents at end. + */ + public function test_inject_contents_at_end(): void { + + $content = ['line1', 'line2']; + + $result = $this->wp_config->inject_contents($content, 2, 'last'); + + $this->assertCount(3, $result); + $this->assertEquals('last', $result[2]); + } + + /** + * Test inject_contents with array value. + */ + public function test_inject_contents_with_array_value(): void { + + $content = ['line1', 'line3']; + + $result = $this->wp_config->inject_contents($content, 1, ['line2a', 'line2b']); + + $this->assertCount(4, $result); + $this->assertEquals('line2a', $result[1]); + $this->assertEquals('line2b', $result[2]); + } + + /** + * Test find_injected_line finds existing constant. + */ + public function test_find_injected_line_finds_constant(): void { + + $config = [ + "wp_config->find_injected_line($config, 'WU_TEST_CONSTANT'); + + $this->assertIsArray($result); + $this->assertEquals(2, $result[1]); + } + + /** + * Test find_injected_line returns false for missing constant. + */ + public function test_find_injected_line_returns_false_for_missing(): void { + + $config = [ + "wp_config->find_injected_line($config, 'NONEXISTENT_CONSTANT'); + + $this->assertFalse($result); + } + + /** + * Test find_reference_hook_line finds table_prefix line. + */ + public function test_find_reference_hook_line_finds_table_prefix(): void { + + global $wpdb; + + $config = [ + "prefix}';\n", + "require_once ABSPATH . 'wp-settings.php';\n", + ]; + + $result = $this->wp_config->find_reference_hook_line($config); + + $this->assertIsInt($result); + $this->assertEquals(2, $result); + } + + /** + * Test find_reference_hook_line finds Happy Publishing comment. + */ + public function test_find_reference_hook_line_finds_happy_publishing(): void { + + $config = [ + "wp_config->find_reference_hook_line($config); + + // The Happy Publishing pattern uses -2 offset + $this->assertIsInt($result); + } + + /** + * Test find_reference_hook_line finds php opening tag as fallback. + */ + public function test_find_reference_hook_line_finds_php_tag_fallback(): void { + + $config = [ + "wp_config->find_reference_hook_line($config); + + $this->assertIsInt($result); + } +} diff --git a/tests/WP_Ultimo/Managers/Domain_Manager_Test.php b/tests/WP_Ultimo/Managers/Domain_Manager_Test.php index e7d524a1..63f8fec1 100644 --- a/tests/WP_Ultimo/Managers/Domain_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Domain_Manager_Test.php @@ -4,16 +4,58 @@ use WP_UnitTestCase; use WP_Ultimo\Settings; +use WP_Ultimo\Models\Domain; +use WP_Ultimo\Database\Domains\Domain_Stage; class Domain_Manager_Test extends WP_UnitTestCase { private Domain_Manager $domain_manager; + /** + * Test blog ID used across tests. + * + * @var int + */ + protected $blog_id; + public function setUp(): void { parent::setUp(); $this->domain_manager = Domain_Manager::get_instance(); } + /** + * Helper to create a test blog, marking test skipped if creation fails. + * + * @param array $args Optional blog creation args. + * @return int Blog ID. + */ + protected function create_test_blog(array $args = []): int { + $blog_id = self::factory()->blog->create($args); + + if (is_wp_error($blog_id)) { + $this->markTestSkipped('Could not create test blog: ' . $blog_id->get_error_message()); + } + + return $blog_id; + } + + /** + * Get or create the default blog_id for tests that need one. + * + * @return int + */ + protected function get_blog_id(): int { + if (empty($this->blog_id)) { + $this->blog_id = $this->create_test_blog(); + } + + return $this->blog_id; + } + + // ---------------------------------------------------------------- + // Existing tests: should_create_www_subdomain + // ---------------------------------------------------------------- + /** * Test should_create_www_subdomain with 'always' setting. */ @@ -120,4 +162,1836 @@ public function test_should_create_www_subdomain_invalid_setting(): void { $this->assertTrue($this->domain_manager->should_create_www_subdomain('example.com')); $this->assertTrue($this->domain_manager->should_create_www_subdomain('subdomain.example.com')); } + + // ---------------------------------------------------------------- + // NEW: should_create_www_subdomain edge cases + // ---------------------------------------------------------------- + + /** + * Test should_create_www_subdomain returns false when domain already starts with www. + */ + public function test_should_create_www_subdomain_already_has_www(): void { + wu_save_setting('auto_create_www_subdomain', 'always'); + + $this->assertFalse($this->domain_manager->should_create_www_subdomain('www.example.com')); + $this->assertFalse($this->domain_manager->should_create_www_subdomain('www.test.co.uk')); + $this->assertFalse($this->domain_manager->should_create_www_subdomain('www.subdomain.example.com')); + } + + /** + * Test should_create_www_subdomain normalizes domain to lowercase. + */ + public function test_should_create_www_subdomain_case_insensitive(): void { + wu_save_setting('auto_create_www_subdomain', 'always'); + + $this->assertTrue($this->domain_manager->should_create_www_subdomain('EXAMPLE.COM')); + $this->assertTrue($this->domain_manager->should_create_www_subdomain('Example.Com')); + } + + /** + * Test should_create_www_subdomain with uppercase WWW prefix is rejected. + */ + public function test_should_create_www_subdomain_uppercase_www_rejected(): void { + wu_save_setting('auto_create_www_subdomain', 'always'); + + // Should be normalized to lowercase, then detected as www prefix + $this->assertFalse($this->domain_manager->should_create_www_subdomain('WWW.EXAMPLE.COM')); + $this->assertFalse($this->domain_manager->should_create_www_subdomain('Www.Example.Com')); + } + + /** + * Test should_create_www_subdomain trims whitespace. + */ + public function test_should_create_www_subdomain_trims_whitespace(): void { + wu_save_setting('auto_create_www_subdomain', 'always'); + + $this->assertTrue($this->domain_manager->should_create_www_subdomain(' example.com ')); + } + + // ---------------------------------------------------------------- + // NEW: is_main_domain static method + // ---------------------------------------------------------------- + + /** + * Test is_main_domain returns true for simple two-part domains. + */ + public function test_is_main_domain_simple_domains(): void { + $this->assertTrue(Domain_Manager::is_main_domain('example.com')); + $this->assertTrue(Domain_Manager::is_main_domain('test.org')); + $this->assertTrue(Domain_Manager::is_main_domain('site.net')); + $this->assertTrue(Domain_Manager::is_main_domain('my-site.io')); + } + + /** + * Test is_main_domain returns true for multi-part TLDs. + */ + public function test_is_main_domain_multi_part_tlds(): void { + $this->assertTrue(Domain_Manager::is_main_domain('example.co.uk')); + $this->assertTrue(Domain_Manager::is_main_domain('test.com.au')); + $this->assertTrue(Domain_Manager::is_main_domain('site.co.nz')); + $this->assertTrue(Domain_Manager::is_main_domain('company.com.br')); + $this->assertTrue(Domain_Manager::is_main_domain('business.co.in')); + } + + /** + * Test is_main_domain returns false for subdomains. + */ + public function test_is_main_domain_subdomains(): void { + $this->assertFalse(Domain_Manager::is_main_domain('sub.example.com')); + $this->assertFalse(Domain_Manager::is_main_domain('api.test.org')); + $this->assertFalse(Domain_Manager::is_main_domain('blog.site.net')); + } + + /** + * Test is_main_domain returns false for deep subdomains. + */ + public function test_is_main_domain_deep_subdomains(): void { + $this->assertFalse(Domain_Manager::is_main_domain('deep.sub.example.com')); + $this->assertFalse(Domain_Manager::is_main_domain('a.b.c.example.com')); + } + + /** + * Test is_main_domain returns false for subdomains of multi-part TLDs. + */ + public function test_is_main_domain_subdomains_of_multi_part_tlds(): void { + $this->assertFalse(Domain_Manager::is_main_domain('sub.example.co.uk')); + $this->assertFalse(Domain_Manager::is_main_domain('api.test.com.au')); + } + + /** + * Test is_main_domain normalizes case. + */ + public function test_is_main_domain_case_normalization(): void { + $this->assertTrue(Domain_Manager::is_main_domain('EXAMPLE.COM')); + $this->assertTrue(Domain_Manager::is_main_domain('Example.Co.Uk')); + } + + /** + * Test is_main_domain trims trailing dot. + */ + public function test_is_main_domain_trailing_dot(): void { + $this->assertTrue(Domain_Manager::is_main_domain('example.com.')); + $this->assertTrue(Domain_Manager::is_main_domain('test.co.uk.')); + } + + /** + * Test is_main_domain trims whitespace. + */ + public function test_is_main_domain_trims_whitespace(): void { + $this->assertTrue(Domain_Manager::is_main_domain(' example.com ')); + $this->assertTrue(Domain_Manager::is_main_domain(' test.co.uk ')); + } + + /** + * Test is_main_domain with single-part domain. + */ + public function test_is_main_domain_single_part(): void { + $this->assertTrue(Domain_Manager::is_main_domain('localhost')); + $this->assertTrue(Domain_Manager::is_main_domain('intranet')); + } + + /** + * Test is_main_domain with custom multi-part TLD filter. + */ + public function test_is_main_domain_custom_multi_part_tld_via_filter(): void { + // Add a custom TLD via filter + add_filter('wu_multi_part_tlds', function ($tlds) { + $tlds[] = '.org.uk'; + return $tlds; + }); + + $this->assertTrue(Domain_Manager::is_main_domain('example.org.uk')); + + // Clean up + remove_all_filters('wu_multi_part_tlds'); + } + + // ---------------------------------------------------------------- + // NEW: Manager initialization and singleton + // ---------------------------------------------------------------- + + /** + * Test manager is a singleton. + */ + public function test_manager_is_singleton(): void { + $instance1 = Domain_Manager::get_instance(); + $instance2 = Domain_Manager::get_instance(); + $this->assertSame($instance1, $instance2); + } + + /** + * Test manager has correct slug. + */ + public function test_manager_slug(): void { + $reflection = new \ReflectionClass($this->domain_manager); + $slug_prop = $reflection->getProperty('slug'); + + if (PHP_VERSION_ID < 80100) { + $slug_prop->setAccessible(true); + } + + $this->assertEquals('domain', $slug_prop->getValue($this->domain_manager)); + } + + /** + * Test manager has correct model class. + */ + public function test_manager_model_class(): void { + $reflection = new \ReflectionClass($this->domain_manager); + $model_prop = $reflection->getProperty('model_class'); + + if (PHP_VERSION_ID < 80100) { + $model_prop->setAccessible(true); + } + + $this->assertEquals(\WP_Ultimo\Models\Domain::class, $model_prop->getValue($this->domain_manager)); + } + + // ---------------------------------------------------------------- + // NEW: Domain CRUD operations via helper functions + // ---------------------------------------------------------------- + + /** + * Test creating a domain with wu_create_domain. + */ + public function test_create_domain_basic(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'test-create-basic.example.com', + 'active' => true, + 'primary_domain' => false, + 'secure' => false, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + $this->assertInstanceOf(Domain::class, $domain); + $this->assertGreaterThan(0, $domain->get_id()); + $this->assertEquals('test-create-basic.example.com', $domain->get_domain()); + $this->assertEquals($this->get_blog_id(), $domain->get_blog_id()); + } + + /** + * Test retrieving a domain by ID. + */ + public function test_get_domain_by_id(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'get-by-id.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $fetched = wu_get_domain($domain->get_id()); + + $this->assertInstanceOf(Domain::class, $fetched); + $this->assertEquals($domain->get_id(), $fetched->get_id()); + $this->assertEquals('get-by-id.example.com', $fetched->get_domain()); + } + + /** + * Test retrieving a domain by domain name. + */ + public function test_get_domain_by_domain_name(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'get-by-name.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $fetched = wu_get_domain_by_domain('get-by-name.example.com'); + + $this->assertInstanceOf(Domain::class, $fetched); + $this->assertEquals($domain->get_id(), $fetched->get_id()); + } + + /** + * Test retrieving a non-existent domain returns false. + */ + public function test_get_nonexistent_domain_returns_false(): void { + $fetched = wu_get_domain(999999); + $this->assertFalse($fetched); + } + + /** + * Test querying domains. + */ + public function test_query_domains(): void { + $domain1 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'query-1.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $domain2 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'query-2.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain1); + $this->assertNotWPError($domain2); + + $domains = wu_get_domains([ + 'blog_id' => $this->get_blog_id(), + ]); + + $this->assertIsArray($domains); + $this->assertGreaterThanOrEqual(2, count($domains)); + } + + /** + * Test querying domains by blog_id. + */ + public function test_query_domains_by_blog_id(): void { + $blog_id_2 = $this->create_test_blog(); + + $domain = wu_create_domain([ + 'blog_id' => $blog_id_2, + 'domain' => 'query-blog-id.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $domains = wu_get_domains([ + 'blog_id' => $blog_id_2, + ]); + + $this->assertIsArray($domains); + $this->assertGreaterThanOrEqual(1, count($domains)); + + foreach ($domains as $d) { + $this->assertEquals($blog_id_2, $d->get_blog_id()); + } + } + + /** + * Test creating a domain without blog_id fails. + */ + public function test_create_domain_without_blog_id_fails(): void { + $result = wu_create_domain([ + 'domain' => 'no-blog.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertWPError($result); + } + + /** + * Test creating a domain without domain name fails. + */ + public function test_create_domain_without_domain_fails(): void { + $result = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertWPError($result); + } + + /** + * Test deleting a domain. + */ + public function test_delete_domain(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'delete-me.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + $domain_id = $domain->get_id(); + + $result = $domain->delete(); + $this->assertNotWPError($result); + + $fetched = wu_get_domain($domain_id); + $this->assertFalse($fetched); + } + + /** + * Test updating a domain. + */ + public function test_update_domain(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'update-me.example.com', + 'stage' => Domain_Stage::DONE, + 'secure' => false, + ]); + + $this->assertNotWPError($domain); + + $domain->set_secure(true); + $result = $domain->save(); + + $this->assertNotWPError($result); + + $fetched = wu_get_domain($domain->get_id()); + $this->assertTrue($fetched->is_secure()); + } + + // ---------------------------------------------------------------- + // NEW: Domain model properties and states + // ---------------------------------------------------------------- + + /** + * Test domain active state with done stage. + */ + public function test_domain_is_active_when_done(): void { + $domain = new Domain(); + $domain->set_active(true); + $domain->set_stage(Domain_Stage::DONE); + + $this->assertTrue($domain->is_active()); + } + + /** + * Test domain is inactive during checking-dns stage. + */ + public function test_domain_is_inactive_during_checking_dns(): void { + $domain = new Domain(); + $domain->set_active(true); + $domain->set_stage(Domain_Stage::CHECKING_DNS); + + $this->assertFalse($domain->is_active()); + } + + /** + * Test domain is inactive during checking-ssl stage. + */ + public function test_domain_is_inactive_during_checking_ssl(): void { + $domain = new Domain(); + $domain->set_active(true); + $domain->set_stage(Domain_Stage::CHECKING_SSL); + + $this->assertFalse($domain->is_active()); + } + + /** + * Test domain is inactive when stage is failed. + */ + public function test_domain_is_inactive_when_failed(): void { + $domain = new Domain(); + $domain->set_active(true); + $domain->set_stage(Domain_Stage::FAILED); + + $this->assertFalse($domain->is_active()); + } + + /** + * Test domain is inactive when stage is ssl-failed. + */ + public function test_domain_is_inactive_when_ssl_failed(): void { + $domain = new Domain(); + $domain->set_active(true); + $domain->set_stage(Domain_Stage::SSL_FAILED); + + $this->assertFalse($domain->is_active()); + } + + /** + * Test domain is inactive when explicitly set to inactive, even with done stage. + */ + public function test_domain_is_inactive_when_explicitly_inactive(): void { + $domain = new Domain(); + $domain->set_active(false); + $domain->set_stage(Domain_Stage::DONE); + + $this->assertFalse($domain->is_active()); + } + + /** + * Test has_inactive_stage for all inactive stages. + */ + public function test_has_inactive_stage(): void { + $domain = new Domain(); + + $inactive_stages = [ + Domain_Stage::CHECKING_DNS, + Domain_Stage::CHECKING_SSL, + Domain_Stage::FAILED, + Domain_Stage::SSL_FAILED, + ]; + + foreach ($inactive_stages as $stage) { + $domain->set_stage($stage); + $this->assertTrue($domain->has_inactive_stage(), "Stage {$stage} should be inactive"); + } + + $active_stages = [ + Domain_Stage::DONE, + Domain_Stage::DONE_WITHOUT_SSL, + ]; + + foreach ($active_stages as $stage) { + $domain->set_stage($stage); + $this->assertFalse($domain->has_inactive_stage(), "Stage {$stage} should be active"); + } + } + + // ---------------------------------------------------------------- + // NEW: Domain secure/SSL handling + // ---------------------------------------------------------------- + + /** + * Test domain secure flag. + */ + public function test_domain_secure_flag(): void { + $domain = new Domain(); + + $domain->set_secure(false); + $this->assertFalse($domain->is_secure()); + + $domain->set_secure(true); + $this->assertTrue($domain->is_secure()); + } + + /** + * Test domain URL uses http when not secure. + */ + public function test_domain_url_http(): void { + $domain = new Domain(); + $domain->set_domain('test.example.com'); + $domain->set_secure(false); + + $url = $domain->get_url(); + $this->assertStringStartsWith('http://', $url); + $this->assertStringContainsString('test.example.com', $url); + } + + /** + * Test domain URL uses https when secure. + */ + public function test_domain_url_https(): void { + $domain = new Domain(); + $domain->set_domain('test.example.com'); + $domain->set_secure(true); + + $url = $domain->get_url(); + $this->assertStringStartsWith('https://', $url); + $this->assertStringContainsString('test.example.com', $url); + } + + /** + * Test domain URL with path. + */ + public function test_domain_url_with_path(): void { + $domain = new Domain(); + $domain->set_domain('test.example.com'); + $domain->set_secure(true); + + $url = $domain->get_url('wp-admin'); + $this->assertEquals('https://test.example.com/wp-admin', $url); + } + + // ---------------------------------------------------------------- + // NEW: Primary domain management + // ---------------------------------------------------------------- + + /** + * Test setting a domain as primary. + */ + public function test_set_primary_domain(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'primary-test.example.com', + 'primary_domain' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + $this->assertTrue($domain->is_primary_domain()); + } + + /** + * Test non-primary domain. + */ + public function test_non_primary_domain(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'non-primary-test.example.com', + 'primary_domain' => false, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + $this->assertFalse($domain->is_primary_domain()); + } + + /** + * Test async_remove_old_primary_domains removes primary flag. + */ + public function test_async_remove_old_primary_domains(): void { + $domain1 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'old-primary-1.example.com', + 'primary_domain' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $domain2 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'old-primary-2.example.com', + 'primary_domain' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain1); + $this->assertNotWPError($domain2); + + // Call the method to remove primary from these domains + $this->domain_manager->async_remove_old_primary_domains([ + $domain1->get_id(), + $domain2->get_id(), + ]); + + // Fetch again to verify + $fetched1 = wu_get_domain($domain1->get_id()); + $fetched2 = wu_get_domain($domain2->get_id()); + + $this->assertFalse($fetched1->is_primary_domain()); + $this->assertFalse($fetched2->is_primary_domain()); + } + + /** + * Test async_remove_old_primary_domains handles empty array. + */ + public function test_async_remove_old_primary_domains_empty_array(): void { + // Should not throw an error with an empty array + $this->domain_manager->async_remove_old_primary_domains([]); + $this->assertTrue(true); // If we got here, no exception was thrown + } + + /** + * Test async_remove_old_primary_domains handles invalid domain IDs. + */ + public function test_async_remove_old_primary_domains_invalid_ids(): void { + // Should not throw an error with non-existent IDs + $this->domain_manager->async_remove_old_primary_domains([999999, 888888]); + $this->assertTrue(true); // If we got here, no exception was thrown + } + + // ---------------------------------------------------------------- + // NEW: Domain-to-site mapping + // ---------------------------------------------------------------- + + /** + * Test get_blog_id and get_site_id return same value. + */ + public function test_get_blog_id_equals_get_site_id(): void { + $domain = new Domain(); + $domain->set_blog_id($this->get_blog_id()); + + $this->assertEquals($domain->get_blog_id(), $domain->get_site_id()); + } + + /** + * Test domain is associated with the correct blog. + */ + public function test_domain_blog_association(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'blog-assoc.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + $this->assertEquals($this->get_blog_id(), $domain->get_blog_id()); + } + + /** + * Test Domain::get_by_site returns domains for a site. + */ + public function test_get_by_site(): void { + $blog_id = $this->create_test_blog(); + + $domain = wu_create_domain([ + 'blog_id' => $blog_id, + 'domain' => 'get-by-site-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + wp_cache_flush(); + + $mappings = Domain::get_by_site($blog_id); + + $this->assertNotFalse($mappings); + $this->assertIsArray($mappings); + $this->assertGreaterThanOrEqual(1, count($mappings)); + } + + /** + * Test Domain::get_by_site with site object. + */ + public function test_get_by_site_with_object(): void { + $blog_id = $this->create_test_blog(); + + $domain = wu_create_domain([ + 'blog_id' => $blog_id, + 'domain' => 'get-by-site-obj.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + wp_cache_flush(); + + $site = get_blog_details($blog_id); + $mappings = Domain::get_by_site($site); + + $this->assertNotFalse($mappings); + $this->assertIsArray($mappings); + } + + /** + * Test Domain::get_by_site returns false for a site with no domains. + */ + public function test_get_by_site_no_domains(): void { + $blog_id = $this->create_test_blog(); + + wp_cache_flush(); + + $mappings = Domain::get_by_site($blog_id); + + // Should be false when no mappings found + $this->assertFalse($mappings); + } + + /** + * Test Domain::get_by_site returns WP_Error for invalid site ID. + */ + public function test_get_by_site_invalid_id(): void { + $result = Domain::get_by_site('not-a-number'); + + $this->assertWPError($result); + } + + /** + * Test Domain::get_by_domain retrieves correct mapping. + */ + public function test_get_by_domain(): void { + global $wpdb; + + // Ensure wu_dmtable is set + if (empty($wpdb->wu_dmtable)) { + $wpdb->wu_dmtable = $wpdb->base_prefix . 'wu_domain_mappings'; + } + + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'get-by-domain-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + wp_cache_flush(); + + $fetched = Domain::get_by_domain('get-by-domain-test.example.com'); + + $this->assertInstanceOf(Domain::class, $fetched); + $this->assertEquals('get-by-domain-test.example.com', $fetched->get_domain()); + } + + /** + * Test Domain::get_by_domain returns null for non-existent domain. + */ + public function test_get_by_domain_nonexistent(): void { + global $wpdb; + + if (empty($wpdb->wu_dmtable)) { + $wpdb->wu_dmtable = $wpdb->base_prefix . 'wu_domain_mappings'; + } + + wp_cache_flush(); + + $fetched = Domain::get_by_domain('nonexistent-domain-xyz.example.com'); + + $this->assertNull($fetched); + } + + /** + * Test Domain::get_by_domain with array of domains. + */ + public function test_get_by_domain_with_array(): void { + global $wpdb; + + if (empty($wpdb->wu_dmtable)) { + $wpdb->wu_dmtable = $wpdb->base_prefix . 'wu_domain_mappings'; + } + + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'array-test-domain.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + wp_cache_flush(); + + $fetched = Domain::get_by_domain([ + 'nonexistent.example.com', + 'array-test-domain.example.com', + ]); + + $this->assertInstanceOf(Domain::class, $fetched); + $this->assertEquals('array-test-domain.example.com', $fetched->get_domain()); + } + + // ---------------------------------------------------------------- + // NEW: Stage transitions + // ---------------------------------------------------------------- + + /** + * Test all domain stage constants exist. + */ + public function test_domain_stage_constants(): void { + $this->assertEquals('checking-dns', Domain_Stage::CHECKING_DNS); + $this->assertEquals('checking-ssl-cert', Domain_Stage::CHECKING_SSL); + $this->assertEquals('done', Domain_Stage::DONE); + $this->assertEquals('done-without-ssl', Domain_Stage::DONE_WITHOUT_SSL); + $this->assertEquals('failed', Domain_Stage::FAILED); + $this->assertEquals('ssl-failed', Domain_Stage::SSL_FAILED); + } + + /** + * Test domain stage can be set and retrieved. + */ + public function test_domain_stage_getter_setter(): void { + $domain = new Domain(); + + $stages = [ + Domain_Stage::CHECKING_DNS, + Domain_Stage::CHECKING_SSL, + Domain_Stage::DONE, + Domain_Stage::DONE_WITHOUT_SSL, + Domain_Stage::FAILED, + Domain_Stage::SSL_FAILED, + ]; + + foreach ($stages as $stage) { + $domain->set_stage($stage); + $this->assertEquals($stage, $domain->get_stage()); + } + } + + /** + * Test domain default stage is checking-dns. + */ + public function test_domain_default_stage(): void { + $domain = new Domain(); + $this->assertEquals(Domain_Stage::CHECKING_DNS, $domain->get_stage()); + } + + /** + * Test async_process_domain_stage with non-existent domain. + */ + public function test_async_process_domain_stage_nonexistent_domain(): void { + // Should return early without error + $this->domain_manager->async_process_domain_stage(999999); + $this->assertTrue(true); + } + + /** + * Test async_process_domain_stage DNS check stage transitions to failed after max tries. + */ + public function test_async_process_domain_stage_dns_max_tries_fails(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'dns-fail-test.example.com', + 'stage' => Domain_Stage::CHECKING_DNS, + ]); + + $this->assertNotWPError($domain); + + // Set max tries to exceed (default is 5) + $this->domain_manager->async_process_domain_stage($domain->get_id(), 6); + + // Fetch the domain again to check the updated stage + $fetched = wu_get_domain($domain->get_id()); + $this->assertEquals(Domain_Stage::FAILED, $fetched->get_stage()); + } + + /** + * Test async_process_domain_stage SSL check stage transitions to ssl-failed after max tries. + */ + public function test_async_process_domain_stage_ssl_max_tries_fails(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'ssl-fail-test.example.com', + 'stage' => Domain_Stage::CHECKING_SSL, + ]); + + $this->assertNotWPError($domain); + + // Set max tries to exceed (default is 5) + $this->domain_manager->async_process_domain_stage($domain->get_id(), 6); + + // Fetch the domain again + $fetched = wu_get_domain($domain->get_id()); + $this->assertEquals(Domain_Stage::SSL_FAILED, $fetched->get_stage()); + } + + /** + * Test async_process_domain_stage with domain in done stage does nothing. + */ + public function test_async_process_domain_stage_done_does_nothing(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'done-stage-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $this->domain_manager->async_process_domain_stage($domain->get_id()); + + // Should still be done + $fetched = wu_get_domain($domain->get_id()); + $this->assertEquals(Domain_Stage::DONE, $fetched->get_stage()); + } + + /** + * Test async_process_domain_stage max_tries filter. + */ + public function test_async_process_domain_stage_max_tries_filter(): void { + // Set max tries to 1 via filter + add_filter('wu_async_process_domain_stage_max_tries', function () { + return 1; + }); + + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'max-tries-filter.example.com', + 'stage' => Domain_Stage::CHECKING_DNS, + ]); + + $this->assertNotWPError($domain); + + // Try count 2 should exceed max of 1 + $this->domain_manager->async_process_domain_stage($domain->get_id(), 2); + + $fetched = wu_get_domain($domain->get_id()); + $this->assertEquals(Domain_Stage::FAILED, $fetched->get_stage()); + + remove_all_filters('wu_async_process_domain_stage_max_tries'); + } + + // ---------------------------------------------------------------- + // NEW: send_domain_to_host + // ---------------------------------------------------------------- + + /** + * Test send_domain_to_host does nothing when old and new values are the same. + */ + public function test_send_domain_to_host_same_value(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'same-value-host.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + // Should not throw any errors + $this->domain_manager->send_domain_to_host( + 'same-value-host.example.com', + 'same-value-host.example.com', + $domain->get_id() + ); + $this->assertTrue(true); + } + + /** + * Test send_domain_to_host enqueues action when values differ. + */ + public function test_send_domain_to_host_different_values(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'new-host-domain.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + // This should enqueue an async action + $this->domain_manager->send_domain_to_host( + 'old-host-domain.example.com', + 'new-host-domain.example.com', + $domain->get_id() + ); + + $this->assertTrue(true); // If we got here, no exception was thrown + } + + // ---------------------------------------------------------------- + // NEW: Domain set_domain normalizes to lowercase + // ---------------------------------------------------------------- + + /** + * Test domain name is stored in lowercase. + */ + public function test_domain_name_stored_lowercase(): void { + $domain = new Domain(); + $domain->set_domain('MyDomain.EXAMPLE.COM'); + + $this->assertEquals('mydomain.example.com', $domain->get_domain()); + } + + // ---------------------------------------------------------------- + // NEW: handle_domain_deleted + // ---------------------------------------------------------------- + + /** + * Test handle_domain_deleted enqueues action on successful deletion. + */ + public function test_handle_domain_deleted_with_success(): void { + $domain = new Domain(); + $domain->set_domain('deleted-domain.example.com'); + $domain->set_blog_id($this->get_blog_id()); + + // Pass true as result - should enqueue async action + $this->domain_manager->handle_domain_deleted(true, $domain); + $this->assertTrue(true); + } + + /** + * Test handle_domain_deleted does nothing on failed deletion. + */ + public function test_handle_domain_deleted_with_failure(): void { + $domain = new Domain(); + $domain->set_domain('failed-delete.example.com'); + $domain->set_blog_id($this->get_blog_id()); + + // Pass false as result - should do nothing + $this->domain_manager->handle_domain_deleted(false, $domain); + $this->assertTrue(true); + } + + // ---------------------------------------------------------------- + // NEW: handle_site_created and handle_site_deleted + // ---------------------------------------------------------------- + + /** + * Test handle_site_created with a site that has a subdomain. + */ + public function test_handle_site_created_with_subdomain(): void { + global $current_site; + + $blog_id = $this->create_test_blog([ + 'domain' => 'sub.' . $current_site->domain, + ]); + + $site = get_blog_details($blog_id); + + // Call the handler directly + $this->domain_manager->handle_site_created($site); + + // Verify that a domain record was created + $domains = wu_get_domains([ + 'blog_id' => $blog_id, + ]); + + $this->assertIsArray($domains); + $this->assertGreaterThanOrEqual(1, count($domains)); + } + + /** + * Test handle_site_created with a site that is the main site domain (no subdomain). + */ + public function test_handle_site_created_without_subdomain(): void { + global $current_site; + + // Use the main site directly + $site = get_blog_details(1); + + // Should return early - no domain record creation + $this->domain_manager->handle_site_created($site); + $this->assertTrue(true); + } + + /** + * Test handle_site_deleted with a subdomain site. + */ + public function test_handle_site_deleted_with_subdomain(): void { + global $current_site; + + // Create a real blog with a subdomain to get a valid WP_Site + $blog_id = $this->create_test_blog([ + 'domain' => 'deleted-sub.' . $current_site->domain, + ]); + + $site = get_blog_details($blog_id); + + // Should enqueue async action for subdomain removal + $this->domain_manager->handle_site_deleted($site); + $this->assertTrue(true); + } + + /** + * Test handle_site_deleted without subdomain does nothing. + */ + public function test_handle_site_deleted_without_subdomain(): void { + // Use the main site directly + $site = get_blog_details(1); + + $this->domain_manager->handle_site_deleted($site); + $this->assertTrue(true); + } + + // ---------------------------------------------------------------- + // NEW: create_domain_record_for_site + // ---------------------------------------------------------------- + + /** + * Test create_domain_record_for_site creates a new domain record. + */ + public function test_create_domain_record_for_site(): void { + $blog_id = $this->create_test_blog([ + 'domain' => 'new-site-record.example.com', + ]); + + $site = get_site($blog_id); + + // Make sure no domains exist yet for this site + wp_cache_flush(); + + $result = $this->domain_manager->create_domain_record_for_site($site); + + $this->assertNotWPError($result); + $this->assertInstanceOf(Domain::class, $result); + $this->assertEquals($blog_id, $result->get_blog_id()); + $this->assertEquals('new-site-record.example.com', $result->get_domain()); + $this->assertTrue($result->is_primary_domain()); + } + + /** + * Test create_domain_record_for_site returns existing domain if one already exists. + */ + public function test_create_domain_record_for_site_returns_existing(): void { + $blog_id = $this->create_test_blog([ + 'domain' => 'existing-record-2.example.com', + ]); + + $site = get_site($blog_id); + + // Check if handle_site_created already created a domain for this site + $existing_domains = wu_get_domains([ + 'blog_id' => $blog_id, + 'number' => 1, + ]); + + if (empty($existing_domains)) { + // Create a domain manually if none exists + $existing = wu_create_domain([ + 'blog_id' => $blog_id, + 'domain' => 'existing-record-2.example.com', + 'primary_domain' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($existing); + } else { + $existing = $existing_domains[0]; + } + + $result = $this->domain_manager->create_domain_record_for_site($site); + + // Should return the existing domain, not create a new one + $this->assertInstanceOf(Domain::class, $result); + $this->assertEquals($existing->get_id(), $result->get_id()); + } + + // ---------------------------------------------------------------- + // NEW: get_integrations + // ---------------------------------------------------------------- + + /** + * Test get_integrations returns an array. + */ + public function test_get_integrations_returns_array(): void { + $integrations = $this->domain_manager->get_integrations(); + $this->assertIsArray($integrations); + } + + /** + * Test get_integrations is filterable. + */ + public function test_get_integrations_is_filterable(): void { + add_filter('wu_domain_manager_get_integrations', function ($integrations) { + $integrations['test_integration'] = 'TestClass'; + return $integrations; + }); + + $integrations = $this->domain_manager->get_integrations(); + $this->assertArrayHasKey('test_integration', $integrations); + + remove_all_filters('wu_domain_manager_get_integrations'); + } + + /** + * Test get_integration_instance returns false for non-existent integration. + */ + public function test_get_integration_instance_nonexistent(): void { + $result = $this->domain_manager->get_integration_instance('nonexistent_integration'); + $this->assertFalse($result); + } + + // ---------------------------------------------------------------- + // NEW: default_domain_mapping_instructions + // ---------------------------------------------------------------- + + /** + * Test default_domain_mapping_instructions returns a non-empty string. + */ + public function test_default_domain_mapping_instructions(): void { + $instructions = $this->domain_manager->default_domain_mapping_instructions(); + $this->assertIsString($instructions); + $this->assertNotEmpty($instructions); + } + + /** + * Test default_domain_mapping_instructions contains the network domain placeholder. + */ + public function test_default_domain_mapping_instructions_has_placeholder(): void { + $instructions = $this->domain_manager->default_domain_mapping_instructions(); + $this->assertStringContainsString('%NETWORK_DOMAIN%', $instructions); + } + + // ---------------------------------------------------------------- + // NEW: get_domain_mapping_instructions + // ---------------------------------------------------------------- + + /** + * Test get_domain_mapping_instructions returns a string. + */ + public function test_get_domain_mapping_instructions(): void { + $instructions = $this->domain_manager->get_domain_mapping_instructions(); + $this->assertIsString($instructions); + $this->assertNotEmpty($instructions); + } + + /** + * Test get_domain_mapping_instructions replaces placeholders. + */ + public function test_get_domain_mapping_instructions_replaces_placeholders(): void { + $instructions = $this->domain_manager->get_domain_mapping_instructions(); + + // The placeholders should be replaced + $this->assertStringNotContainsString('%NETWORK_DOMAIN%', $instructions); + $this->assertStringNotContainsString('%NETWORK_IP%', $instructions); + } + + /** + * Test get_domain_mapping_instructions is filterable. + */ + public function test_get_domain_mapping_instructions_filterable(): void { + add_filter('wu_get_domain_mapping_instructions', function () { + return 'Custom instructions'; + }); + + $instructions = $this->domain_manager->get_domain_mapping_instructions(); + $this->assertEquals('Custom instructions', $instructions); + + remove_all_filters('wu_get_domain_mapping_instructions'); + } + + /** + * Test get_domain_mapping_instructions uses saved setting. + */ + public function test_get_domain_mapping_instructions_from_settings(): void { + wu_save_setting('domain_mapping_instructions', 'Point your domain to %NETWORK_DOMAIN% (%NETWORK_IP%)'); + + $instructions = $this->domain_manager->get_domain_mapping_instructions(); + + $this->assertStringNotContainsString('%NETWORK_DOMAIN%', $instructions); + $this->assertStringNotContainsString('%NETWORK_IP%', $instructions); + $this->assertStringContainsString('Point your domain to', $instructions); + } + + // ---------------------------------------------------------------- + // NEW: Domain model getters/setters + // ---------------------------------------------------------------- + + /** + * Test date_created getter and setter. + */ + public function test_date_created(): void { + $domain = new Domain(); + $date = '2025-01-01 12:00:00'; + $domain->set_date_created($date); + + $this->assertEquals($date, $domain->get_date_created()); + } + + /** + * Test domain model get_site returns site for valid blog_id. + */ + public function test_domain_get_site(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'get-site-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $site = $domain->get_site(); + $this->assertNotFalse($site); + } + + /** + * Test domain model get_path returns a string for valid blog_id. + */ + public function test_domain_get_path(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'get-path-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $path = $domain->get_path(); + $this->assertIsString($path); + } + + /** + * Test domain model get_path returns null for invalid blog_id. + */ + public function test_domain_get_path_invalid_blog(): void { + $domain = new Domain(); + $domain->set_blog_id(999999); + + $path = $domain->get_path(); + $this->assertNull($path); + } + + /** + * Test stage label returns a string. + */ + public function test_domain_stage_label(): void { + $domain = new Domain(); + $domain->set_stage(Domain_Stage::DONE); + + $label = $domain->get_stage_label(); + $this->assertIsString($label); + $this->assertNotEmpty($label); + } + + /** + * Test stage class returns a string. + */ + public function test_domain_stage_class(): void { + $domain = new Domain(); + $domain->set_stage(Domain_Stage::DONE); + + $class = $domain->get_stage_class(); + $this->assertIsString($class); + $this->assertNotEmpty($class); + } + + /** + * Test stage labels for all stages. + */ + public function test_domain_stage_labels_for_all_stages(): void { + $stages = [ + Domain_Stage::CHECKING_DNS, + Domain_Stage::CHECKING_SSL, + Domain_Stage::DONE, + Domain_Stage::DONE_WITHOUT_SSL, + Domain_Stage::FAILED, + Domain_Stage::SSL_FAILED, + ]; + + $domain = new Domain(); + + foreach ($stages as $stage) { + $domain->set_stage($stage); + $label = $domain->get_stage_label(); + $this->assertIsString($label, "Stage {$stage} should have a label"); + $this->assertNotEmpty($label, "Stage {$stage} label should not be empty"); + } + } + + /** + * Test stage classes for all stages. + */ + public function test_domain_stage_classes_for_all_stages(): void { + $stages = [ + Domain_Stage::CHECKING_DNS, + Domain_Stage::CHECKING_SSL, + Domain_Stage::DONE, + Domain_Stage::DONE_WITHOUT_SSL, + Domain_Stage::FAILED, + Domain_Stage::SSL_FAILED, + ]; + + $domain = new Domain(); + + foreach ($stages as $stage) { + $domain->set_stage($stage); + $class = $domain->get_stage_class(); + $this->assertIsString($class, "Stage {$stage} should have a CSS class"); + $this->assertNotEmpty($class, "Stage {$stage} CSS class should not be empty"); + } + } + + // ---------------------------------------------------------------- + // NEW: Domain validation rules + // ---------------------------------------------------------------- + + /** + * Test domain validation rules are returned. + */ + public function test_domain_validation_rules(): void { + $domain = new Domain(); + $rules = $domain->validation_rules(); + + $this->assertIsArray($rules); + $this->assertArrayHasKey('blog_id', $rules); + $this->assertArrayHasKey('domain', $rules); + $this->assertArrayHasKey('stage', $rules); + $this->assertArrayHasKey('active', $rules); + $this->assertArrayHasKey('secure', $rules); + $this->assertArrayHasKey('primary_domain', $rules); + } + + /** + * Test domain validation requires blog_id. + */ + public function test_domain_validation_requires_blog_id(): void { + $domain = new Domain(); + $domain->set_domain('valid.example.com'); + $domain->set_stage(Domain_Stage::DONE); + + // Without blog_id, save should fail + $result = $domain->save(); + $this->assertWPError($result); + } + + /** + * Test domain validation requires domain name. + */ + public function test_domain_validation_requires_domain(): void { + $domain = new Domain(); + $domain->set_blog_id($this->get_blog_id()); + $domain->set_stage(Domain_Stage::DONE); + + // Without domain, save should fail + $result = $domain->save(); + $this->assertWPError($result); + } + + // ---------------------------------------------------------------- + // NEW: DNS check interval setting + // ---------------------------------------------------------------- + + /** + * Test dns_check_interval setting is respected in async_process_domain_stage. + */ + public function test_dns_check_interval_setting(): void { + // Set a custom DNS check interval + wu_save_setting('dns_check_interval', 60); + + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'dns-interval-test.example.com', + 'stage' => Domain_Stage::CHECKING_DNS, + ]); + + $this->assertNotWPError($domain); + + // Process with 0 tries (first try) - won't exceed max, schedules retry + $this->domain_manager->async_process_domain_stage($domain->get_id(), 0); + + // Domain should still be in checking-dns stage (DNS won't resolve in test env) + $fetched = wu_get_domain($domain->get_id()); + $stage = $fetched->get_stage(); + + // It should either still be checking-dns (retry scheduled) or failed (if tries exceeded) + $this->assertContains($stage, [Domain_Stage::CHECKING_DNS, Domain_Stage::FAILED]); + } + + /** + * Test dns_check_interval is clamped to range. + */ + public function test_dns_check_interval_clamped(): void { + // Too low - should be clamped to 10 + wu_save_setting('dns_check_interval', 1); + + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'dns-clamp-low.example.com', + 'stage' => Domain_Stage::CHECKING_DNS, + ]); + + $this->assertNotWPError($domain); + + // Should not throw errors despite low interval + $this->domain_manager->async_process_domain_stage($domain->get_id(), 0); + $this->assertTrue(true); + } + + // ---------------------------------------------------------------- + // NEW: Multiple domains for the same site + // ---------------------------------------------------------------- + + /** + * Test multiple domains can be associated with the same site. + */ + public function test_multiple_domains_per_site(): void { + $domain1 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'multi-1.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $domain2 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'multi-2.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $domain3 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'multi-3.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain1); + $this->assertNotWPError($domain2); + $this->assertNotWPError($domain3); + + $domains = wu_get_domains([ + 'blog_id' => $this->get_blog_id(), + ]); + + $this->assertGreaterThanOrEqual(3, count($domains)); + } + + // ---------------------------------------------------------------- + // NEW: Domain uniqueness + // ---------------------------------------------------------------- + + /** + * Test that duplicate domain names are rejected. + */ + public function test_duplicate_domain_rejected(): void { + $domain1 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'unique-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain1); + + // Try to create another domain with the same name + $domain2 = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'unique-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertWPError($domain2); + } + + // ---------------------------------------------------------------- + // NEW: Query domains with fields parameter + // ---------------------------------------------------------------- + + /** + * Test querying domains with fields=ids. + */ + public function test_query_domains_ids_only(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'ids-only-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $ids = wu_get_domains([ + 'blog_id' => $this->get_blog_id(), + 'fields' => 'ids', + ]); + + $this->assertIsArray($ids); + $this->assertNotEmpty($ids); + + foreach ($ids as $id) { + $this->assertIsNumeric($id); + } + } + + // ---------------------------------------------------------------- + // NEW: Settings registration + // ---------------------------------------------------------------- + + /** + * Test add_domain_mapping_settings registers settings without error. + */ + public function test_add_domain_mapping_settings(): void { + $this->domain_manager->add_domain_mapping_settings(); + $this->assertTrue(true); // No exceptions thrown + } + + /** + * Test add_sso_settings registers settings without error. + */ + public function test_add_sso_settings(): void { + $this->domain_manager->add_sso_settings(); + $this->assertTrue(true); // No exceptions thrown + } + + // ---------------------------------------------------------------- + // NEW: Edge cases + // ---------------------------------------------------------------- + + /** + * Test creating domain with all fields. + */ + public function test_create_domain_with_all_fields(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'all-fields.example.com', + 'active' => true, + 'primary_domain' => true, + 'secure' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + $this->assertEquals('all-fields.example.com', $domain->get_domain()); + $this->assertEquals($this->get_blog_id(), $domain->get_blog_id()); + $this->assertTrue($domain->is_primary_domain()); + $this->assertTrue($domain->is_secure()); + $this->assertEquals(Domain_Stage::DONE, $domain->get_stage()); + $this->assertTrue($domain->is_active()); + } + + /** + * Test creating domain with checking-dns stage makes it inactive. + */ + public function test_create_domain_checking_dns_is_inactive(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'checking-dns-inactive.example.com', + 'active' => true, + 'stage' => Domain_Stage::CHECKING_DNS, + ]); + + $this->assertNotWPError($domain); + + // Even though active is true, the stage should force it to be inactive + $this->assertFalse($domain->is_active()); + } + + /** + * Test the INACTIVE_STAGES constant is correctly defined. + */ + public function test_inactive_stages_constant(): void { + $expected = [ + Domain_Stage::CHECKING_DNS, + Domain_Stage::CHECKING_SSL, + Domain_Stage::FAILED, + Domain_Stage::SSL_FAILED, + ]; + + $this->assertEquals($expected, Domain::INACTIVE_STAGES); + } + + /** + * Test domain done-without-ssl stage is considered active. + */ + public function test_domain_done_without_ssl_is_active(): void { + $domain = new Domain(); + $domain->set_active(true); + $domain->set_stage(Domain_Stage::DONE_WITHOUT_SSL); + + $this->assertTrue($domain->is_active()); + } + + /** + * Test setting primary domain on save unsets other primary domains via action. + */ + public function test_setting_primary_triggers_unset_of_old_primaries(): void { + $blog_id = $this->create_test_blog(); + + $domain1 = wu_create_domain([ + 'blog_id' => $blog_id, + 'domain' => 'primary-1-trigger.example.com', + 'primary_domain' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain1); + + // The wu_async_remove_old_primary_domains action should have been called + // when domain2 is created as primary + $action_called = false; + add_action('wu_async_remove_old_primary_domains', function ($domains) use (&$action_called) { + $action_called = true; + }); + + $domain2 = wu_create_domain([ + 'blog_id' => $blog_id, + 'domain' => 'primary-2-trigger.example.com', + 'primary_domain' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain2); + $this->assertTrue($action_called, 'wu_async_remove_old_primary_domains action should have been triggered'); + } + + /** + * Test the wu_domain_became_primary action fires when setting a new primary. + */ + public function test_domain_became_primary_action_fires(): void { + $blog_id = $this->create_test_blog(); + + $action_fired = false; + add_action('wu_domain_became_primary', function ($domain, $fired_blog_id, $was_new) use (&$action_fired, $blog_id) { + $action_fired = true; + $this->assertEquals($blog_id, $fired_blog_id); + $this->assertTrue($was_new); + }, 10, 3); + + $domain = wu_create_domain([ + 'blog_id' => $blog_id, + 'domain' => 'became-primary-action.example.com', + 'primary_domain' => true, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + $this->assertTrue($action_fired, 'wu_domain_became_primary action should have fired'); + + remove_all_actions('wu_domain_became_primary'); + } + + /** + * Test updating a non-primary domain to primary triggers action. + */ + public function test_updating_to_primary_triggers_action(): void { + $blog_id = $this->create_test_blog(); + + $domain = wu_create_domain([ + 'blog_id' => $blog_id, + 'domain' => 'update-to-primary.example.com', + 'primary_domain' => false, + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + $action_fired = false; + add_action('wu_domain_became_primary', function () use (&$action_fired) { + $action_fired = true; + }, 10, 3); + + $domain->set_primary_domain(true); + $domain->save(); + + $this->assertTrue($action_fired, 'wu_domain_became_primary should fire when updating to primary'); + + remove_all_actions('wu_domain_became_primary'); + } + + // ---------------------------------------------------------------- + // NEW: DNS record filter + // ---------------------------------------------------------------- + + /** + * Test wu_domain_dns_get_record filter is applied. + */ + public function test_dns_get_record_filter_is_applied(): void { + $filter_called = false; + + add_filter('wu_domain_dns_get_record', function ($results, $domain) use (&$filter_called) { + $filter_called = true; + return $results; + }, 10, 2); + + // This might throw an exception due to DNS resolvers not being available + // in the test environment, so we wrap in a try-catch + try { + Domain_Manager::dns_get_record('example.com'); + } catch (\Throwable $e) { + // DNS resolution may fail in test env, that's fine + } + + // The filter may or may not have been called depending on + // whether dns_get_record completed before throwing + // This test mainly validates the filter exists + remove_all_filters('wu_domain_dns_get_record'); + $this->assertTrue(true); + } + + // ---------------------------------------------------------------- + // NEW: Domain save flushes cache + // ---------------------------------------------------------------- + + /** + * Test that saving a domain flushes cache. + */ + public function test_domain_save_flushes_cache(): void { + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'cache-flush-test.example.com', + 'stage' => Domain_Stage::DONE, + ]); + + $this->assertNotWPError($domain); + + // Set a cache value + wp_cache_set('test_key', 'test_value', 'domain_mapping'); + + // Save the domain again + $domain->set_secure(true); + $domain->save(); + + // Cache should have been flushed + $cached = wp_cache_get('test_key', 'domain_mapping'); + $this->assertFalse($cached); + } + + // ---------------------------------------------------------------- + // NEW: try_again_time filter + // ---------------------------------------------------------------- + + /** + * Test wu_async_process_domains_try_again_time filter. + */ + public function test_try_again_time_filter(): void { + $filter_called = false; + add_filter('wu_async_process_domains_try_again_time', function ($time, $domain) use (&$filter_called) { + $filter_called = true; + return $time; + }, 10, 2); + + $domain = wu_create_domain([ + 'blog_id' => $this->get_blog_id(), + 'domain' => 'try-again-time-filter.example.com', + 'stage' => Domain_Stage::CHECKING_DNS, + ]); + + $this->assertNotWPError($domain); + + $this->domain_manager->async_process_domain_stage($domain->get_id(), 0); + + $this->assertTrue($filter_called, 'wu_async_process_domains_try_again_time filter should be called'); + + remove_all_filters('wu_async_process_domains_try_again_time'); + } } diff --git a/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php b/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php index 7e7f3964..c322ae1b 100644 --- a/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Limitation_Manager_Test.php @@ -8,11 +8,40 @@ namespace WP_Ultimo\Tests\Managers; use WP_Ultimo\Managers\Limitation_Manager; +use WP_Ultimo\Objects\Limitations; +use WP_Ultimo\Models\Product; +use WP_Ultimo\Models\Membership; +use WP_Ultimo\Models\Customer; +use WP_Ultimo\Models\Site; +use WP_Ultimo\Database\Sites\Site_Type; class Limitation_Manager_Test extends \WP_UnitTestCase { use Manager_Test_Trait; + /** + * Create a Customer model directly, bypassing wu_create_customer. + * + * This avoids collisions with the wu_customers table which is not + * rolled back between individual test methods. + * + * @return Customer + */ + protected function create_test_customer(): Customer { + + $user_id = self::factory()->user->create([ + 'role' => 'subscriber', + ]); + + $customer = new Customer([ + 'user_id' => $user_id, + ]); + $customer->set_skip_validation(true); + $customer->save(); + + return $customer; + } + protected function get_manager_class(): string { return Limitation_Manager::class; } @@ -70,4 +99,3025 @@ public function test_get_object_type_with_unknown(): void { $this->assertFalse($type); } + + // --------------------------------------------------------------- + // get_object_type tests + // --------------------------------------------------------------- + + /** + * Test get_object_type returns membership for Membership model. + */ + public function test_get_object_type_with_membership(): void { + + $manager = $this->get_manager_instance(); + + $membership = new Membership(); + $type = $manager->get_object_type($membership); + + $this->assertEquals('membership', $type); + } + + /** + * Test get_object_type returns site for Site model. + */ + public function test_get_object_type_with_site(): void { + + $manager = $this->get_manager_instance(); + + $blog_id = self::factory()->blog->create(); + $site = new Site(['blog_id' => $blog_id]); + + $type = $manager->get_object_type($site); + + $this->assertEquals('site', $type); + } + + /** + * Test get_object_type filter can modify the result. + */ + public function test_get_object_type_filter(): void { + + $manager = $this->get_manager_instance(); + + add_filter('wu_limitations_get_object_type', function ($model, $object_model) { + if ($object_model instanceof \stdClass) { + return 'custom_type'; + } + return $model; + }, 10, 2); + + $type = $manager->get_object_type(new \stdClass()); + + $this->assertEquals('custom_type', $type); + + remove_all_filters('wu_limitations_get_object_type'); + } + + // --------------------------------------------------------------- + // Limitations object tests + // --------------------------------------------------------------- + + /** + * Test Limitations repository returns the expected modules. + */ + public function test_limitations_repository_contains_expected_modules(): void { + + $repository = Limitations::repository(); + + $expected_modules = [ + 'post_types', + 'plugins', + 'sites', + 'themes', + 'visits', + 'disk_space', + 'users', + 'site_templates', + 'domain_mapping', + 'customer_user_role', + 'hide_credits', + ]; + + foreach ($expected_modules as $module) { + $this->assertArrayHasKey($module, $repository, "Repository should contain '{$module}' module."); + } + } + + /** + * Test Limitations::get_empty returns a valid Limitations object. + */ + public function test_get_empty_limitations(): void { + + $limitations = Limitations::get_empty(); + + $this->assertInstanceOf(Limitations::class, $limitations); + } + + /** + * Test Limitations with no data has_limitations returns false. + */ + public function test_empty_limitations_has_no_limitations(): void { + + $limitations = new Limitations([]); + + $this->assertFalse($limitations->has_limitations()); + } + + /** + * Test Limitations with enabled module has_limitations returns true. + */ + public function test_limitations_with_enabled_module_has_limitations(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $this->assertTrue($limitations->has_limitations()); + } + + /** + * Test Limitations exists method. + */ + public function test_limitations_exists_method(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $this->assertTrue($limitations->exists('sites')); + $this->assertFalse($limitations->exists('nonexistent')); + } + + /** + * Test Limitations is_module_enabled method. + */ + public function test_limitations_is_module_enabled(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + 'visits' => [ + 'enabled' => false, + 'limit' => 0, + ], + ]); + + $this->assertTrue($limitations->is_module_enabled('sites')); + $this->assertFalse($limitations->is_module_enabled('visits')); + } + + /** + * Test Limitations to_array returns original data. + */ + public function test_limitations_to_array(): void { + + $data = [ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]; + + $limitations = new Limitations($data); + + $this->assertEquals($data, $limitations->to_array()); + } + + // --------------------------------------------------------------- + // Limit module access via magic getter + // --------------------------------------------------------------- + + /** + * Test accessing sites limit module. + */ + public function test_sites_limit_module_access(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 3, + ], + ]); + + $sites = $limitations->sites; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Sites::class, $sites); + $this->assertTrue($sites->is_enabled()); + $this->assertEquals(3, $sites->get_limit()); + } + + /** + * Test accessing disk_space limit module. + */ + public function test_disk_space_limit_module_access(): void { + + $limitations = new Limitations([ + 'disk_space' => [ + 'enabled' => true, + 'limit' => 100, + ], + ]); + + $disk_space = $limitations->disk_space; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Disk_Space::class, $disk_space); + $this->assertTrue($disk_space->is_enabled()); + $this->assertEquals(100, $disk_space->get_limit()); + } + + /** + * Test accessing visits limit module. + */ + public function test_visits_limit_module_access(): void { + + $limitations = new Limitations([ + 'visits' => [ + 'enabled' => true, + 'limit' => 10000, + ], + ]); + + $visits = $limitations->visits; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Visits::class, $visits); + $this->assertTrue($visits->is_enabled()); + $this->assertEquals(10000, $visits->get_limit()); + } + + /** + * Test accessing users limit module. + */ + public function test_users_limit_module_access(): void { + + $limitations = new Limitations([ + 'users' => [ + 'enabled' => true, + 'limit' => [ + 'administrator' => [ + 'enabled' => true, + 'number' => 1, + ], + 'editor' => [ + 'enabled' => true, + 'number' => 5, + ], + ], + ], + ]); + + $users = $limitations->users; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Users::class, $users); + $this->assertTrue($users->is_enabled()); + } + + /** + * Test accessing plugins limit module. + */ + public function test_plugins_limit_module_access(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'test-plugin/test-plugin.php' => [ + 'visibility' => 'visible', + 'behavior' => 'default', + ], + ], + ], + ]); + + $plugins = $limitations->plugins; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Plugins::class, $plugins); + $this->assertTrue($plugins->is_enabled()); + } + + /** + * Test accessing themes limit module. + */ + public function test_themes_limit_module_access(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'visible', + 'behavior' => 'available', + ], + ], + ], + ]); + + $themes = $limitations->themes; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Themes::class, $themes); + $this->assertTrue($themes->is_enabled()); + } + + /** + * Test accessing post_types limit module. + */ + public function test_post_types_limit_module_access(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 100, + ], + 'page' => [ + 'enabled' => true, + 'number' => 50, + ], + ], + ], + ]); + + $post_types = $limitations->post_types; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Post_Types::class, $post_types); + $this->assertTrue($post_types->is_enabled()); + } + + /** + * Test accessing domain_mapping limit module. + */ + public function test_domain_mapping_limit_module_access(): void { + + $limitations = new Limitations([ + 'domain_mapping' => [ + 'enabled' => true, + 'limit' => null, + ], + ]); + + $domain_mapping = $limitations->domain_mapping; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Domain_Mapping::class, $domain_mapping); + $this->assertTrue($domain_mapping->is_enabled()); + } + + /** + * Test accessing hide_credits limit module. + */ + public function test_hide_credits_limit_module_access(): void { + + $limitations = new Limitations([ + 'hide_credits' => [ + 'enabled' => true, + 'limit' => true, + ], + ]); + + $hide_credits = $limitations->hide_credits; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Hide_Footer_Credits::class, $hide_credits); + $this->assertTrue($hide_credits->is_enabled()); + } + + /** + * Test accessing customer_user_role limit module. + */ + public function test_customer_user_role_limit_module_access(): void { + + $limitations = new Limitations([ + 'customer_user_role' => [ + 'enabled' => true, + 'limit' => 'administrator', + ], + ]); + + $customer_user_role = $limitations->customer_user_role; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Customer_User_Role::class, $customer_user_role); + $this->assertTrue($customer_user_role->is_enabled()); + } + + /** + * Test accessing site_templates limit module. + */ + public function test_site_templates_limit_module_access(): void { + + $limitations = new Limitations([ + 'site_templates' => [ + 'enabled' => true, + 'limit' => null, + 'mode' => 'default', + ], + ]); + + $site_templates = $limitations->site_templates; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Site_Templates::class, $site_templates); + $this->assertTrue($site_templates->is_enabled()); + } + + /** + * Test accessing a nonexistent module returns false. + */ + public function test_accessing_nonexistent_module_returns_false(): void { + + $limitations = new Limitations([]); + + $result = $limitations->nonexistent_module; + + $this->assertFalse($result); + } + + // --------------------------------------------------------------- + // Limit enabled/disabled tests + // --------------------------------------------------------------- + + /** + * Test sites limit disabled by default when no data. + */ + public function test_sites_limit_enabled_by_default_when_no_enabled_key(): void { + + $limitations = new Limitations([ + 'sites' => [], + ]); + + $sites = $limitations->sites; + + // Default enabled_default_value is true in base Limit + $this->assertTrue($sites->is_enabled()); + } + + /** + * Test sites limit can be disabled. + */ + public function test_sites_limit_can_be_disabled(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => false, + 'limit' => 5, + ], + ]); + + $this->assertFalse($limitations->sites->is_enabled()); + } + + /** + * Test hide_credits defaults to disabled. + */ + public function test_hide_credits_defaults_to_disabled(): void { + + $limitations = new Limitations([ + 'hide_credits' => [], + ]); + + $hide_credits = $limitations->hide_credits; + + // Hide credits has enabled_default_value = false + $this->assertFalse($hide_credits->is_enabled()); + } + + // --------------------------------------------------------------- + // Limit has_own_limit and has_own_enabled tests + // --------------------------------------------------------------- + + /** + * Test has_own_limit returns true when limit is set. + */ + public function test_has_own_limit_when_set(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 10, + ], + ]); + + $this->assertTrue($limitations->sites->has_own_limit()); + } + + /** + * Test has_own_limit returns false when limit is not set. + */ + public function test_has_own_limit_when_not_set(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + ], + ]); + + $this->assertFalse($limitations->sites->has_own_limit()); + } + + /** + * Test has_own_enabled returns true when enabled is set. + */ + public function test_has_own_enabled_when_set(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $this->assertTrue($limitations->sites->has_own_enabled()); + } + + /** + * Test has_own_enabled returns false when enabled is not set. + */ + public function test_has_own_enabled_when_not_set(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'limit' => 5, + ], + ]); + + $this->assertFalse($limitations->sites->has_own_enabled()); + } + + // --------------------------------------------------------------- + // Plugins limit tests + // --------------------------------------------------------------- + + /** + * Test plugins is always enabled. + */ + public function test_plugins_limit_always_enabled(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => false, + 'limit' => [], + ], + ]); + + // Limit_Plugins::is_enabled() always returns true + $this->assertTrue($limitations->plugins->is_enabled()); + } + + /** + * Test plugins default permissions. + */ + public function test_plugins_default_permissions(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [], + ], + ]); + + $plugin = $limitations->plugins->{'nonexistent-plugin/nonexistent.php'}; + + $this->assertEquals('visible', $plugin->visibility); + $this->assertEquals('default', $plugin->behavior); + } + + /** + * Test plugins with custom permissions. + */ + public function test_plugins_custom_permissions(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'akismet/akismet.php' => [ + 'visibility' => 'hidden', + 'behavior' => 'force_active', + ], + ], + ], + ]); + + $plugin = $limitations->plugins->{'akismet/akismet.php'}; + + $this->assertEquals('hidden', $plugin->visibility); + $this->assertEquals('force_active', $plugin->behavior); + } + + /** + * Test plugins exists method. + */ + public function test_plugins_exists(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'akismet/akismet.php' => [ + 'visibility' => 'hidden', + 'behavior' => 'force_active', + ], + ], + ], + ]); + + $this->assertTrue($limitations->plugins->exists('akismet/akismet.php')); + $this->assertFalse($limitations->plugins->exists('nonexistent/nonexistent.php')); + } + + /** + * Test plugins check method with various types. + */ + public function test_plugins_check_method(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'akismet/akismet.php' => [ + 'visibility' => 'visible', + 'behavior' => 'force_active', + ], + ], + ], + ]); + + $this->assertTrue($limitations->plugins->allowed('akismet/akismet.php', 'visible')); + $this->assertFalse($limitations->plugins->allowed('akismet/akismet.php', 'hidden')); + $this->assertTrue($limitations->plugins->allowed('akismet/akismet.php', 'force_active')); + $this->assertFalse($limitations->plugins->allowed('akismet/akismet.php', 'force_inactive')); + $this->assertFalse($limitations->plugins->allowed('akismet/akismet.php', 'default')); + } + + // --------------------------------------------------------------- + // Themes limit tests + // --------------------------------------------------------------- + + /** + * Test themes is always enabled. + */ + public function test_themes_limit_always_enabled(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => false, + 'limit' => [], + ], + ]); + + // Limit_Themes::is_enabled() always returns true + $this->assertTrue($limitations->themes->is_enabled()); + } + + /** + * Test themes default permissions. + */ + public function test_themes_default_permissions(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [], + ], + ]); + + $theme = $limitations->themes->{'nonexistent-theme'}; + + $this->assertEquals('visible', $theme->visibility); + $this->assertEquals('available', $theme->behavior); + } + + /** + * Test themes custom permissions. + */ + public function test_themes_custom_permissions(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'hidden', + 'behavior' => 'not_available', + ], + ], + ], + ]); + + $theme = $limitations->themes->twentytwentyfour; + + $this->assertEquals('hidden', $theme->visibility); + $this->assertEquals('not_available', $theme->behavior); + } + + /** + * Test themes get_forced_active_theme. + */ + public function test_themes_get_forced_active_theme(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'visible', + 'behavior' => 'force_active', + ], + 'twentytwentythree' => [ + 'visibility' => 'visible', + 'behavior' => 'available', + ], + ], + ], + ]); + + $forced = $limitations->themes->get_forced_active_theme(); + + $this->assertEquals('twentytwentyfour', $forced); + } + + /** + * Test themes get_forced_active_theme returns false when none forced. + */ + public function test_themes_get_forced_active_theme_returns_false_when_none(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'visible', + 'behavior' => 'available', + ], + ], + ], + ]); + + $forced = $limitations->themes->get_forced_active_theme(); + + $this->assertFalse($forced); + } + + /** + * Test themes get_available_themes. + */ + public function test_themes_get_available_themes(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'visible', + 'behavior' => 'available', + ], + 'twentytwentythree' => [ + 'visibility' => 'visible', + 'behavior' => 'not_available', + ], + 'twentytwentytwo' => [ + 'visibility' => 'visible', + 'behavior' => 'available', + ], + ], + ], + ]); + + $available = $limitations->themes->get_available_themes(); + + $this->assertContains('twentytwentyfour', $available); + $this->assertContains('twentytwentytwo', $available); + $this->assertNotContains('twentytwentythree', $available); + } + + /** + * Test themes exists method. + */ + public function test_themes_exists(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'visible', + 'behavior' => 'available', + ], + ], + ], + ]); + + $this->assertTrue($limitations->themes->exists('twentytwentyfour')); + $this->assertFalse($limitations->themes->exists('nonexistent')); + } + + /** + * Test themes get_all_themes. + */ + public function test_themes_get_all_themes(): void { + + $limitations = new Limitations([ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'visibility' => 'visible', + 'behavior' => 'available', + ], + 'twentytwentythree' => [ + 'visibility' => 'visible', + 'behavior' => 'not_available', + ], + ], + ], + ]); + + $all_themes = $limitations->themes->get_all_themes(); + + $this->assertCount(2, $all_themes); + $this->assertContains('twentytwentyfour', $all_themes); + $this->assertContains('twentytwentythree', $all_themes); + } + + // --------------------------------------------------------------- + // Post types limit tests + // --------------------------------------------------------------- + + /** + * Test post_types default permissions for unknown types. + */ + public function test_post_types_default_permissions(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [], + ], + ]); + + $post_type = $limitations->post_types->unknown_type; + + $this->assertTrue($post_type->enabled); + $this->assertEquals('', $post_type->number); + } + + /** + * Test post_types check method with subtype. + */ + public function test_post_types_check_allowed(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 10, + ], + ], + ], + ]); + + // Current count 5 is less than limit 10 + $this->assertTrue($limitations->post_types->allowed(5, 'post')); + + // Current count 10 is not less than limit 10 + $this->assertFalse($limitations->post_types->allowed(10, 'post')); + + // Current count 15 is not less than limit 10 + $this->assertFalse($limitations->post_types->allowed(15, 'post')); + } + + /** + * Test post_types check with unlimited (0 or empty number). + */ + public function test_post_types_check_unlimited(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 0, + ], + ], + ], + ]); + + // Number 0 means unlimited + $this->assertTrue($limitations->post_types->allowed(9999, 'post')); + } + + /** + * Test post_types check with disabled post type. + */ + public function test_post_types_check_disabled_type(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => false, + 'number' => 100, + ], + ], + ], + ]); + + // Post type is disabled + $this->assertFalse($limitations->post_types->allowed(0, 'post')); + } + + /** + * Test post_types check without type returns false. + */ + public function test_post_types_check_without_type(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 10, + ], + ], + ], + ]); + + // No type provided + $this->assertFalse($limitations->post_types->allowed(5, '')); + } + + /** + * Test post_types exists method. + */ + public function test_post_types_exists(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 10, + ], + ], + ], + ]); + + $this->assertTrue($limitations->post_types->exists('post')); + $this->assertFalse($limitations->post_types->exists('nonexistent')); + } + + // --------------------------------------------------------------- + // Users limit tests + // --------------------------------------------------------------- + + /** + * Test users limit check with subtype. + */ + public function test_users_check_allowed(): void { + + $limitations = new Limitations([ + 'users' => [ + 'enabled' => true, + 'limit' => [ + 'administrator' => [ + 'enabled' => true, + 'number' => 2, + ], + ], + ], + ]); + + // Current count 1 is less than limit 2 + $this->assertTrue($limitations->users->allowed(1, 'administrator')); + + // Current count 2 is not less than limit 2 + $this->assertFalse($limitations->users->allowed(2, 'administrator')); + } + + /** + * Test users limit check with unlimited users. + */ + public function test_users_check_unlimited(): void { + + $limitations = new Limitations([ + 'users' => [ + 'enabled' => true, + 'limit' => [ + 'editor' => [ + 'enabled' => true, + 'number' => 0, + ], + ], + ], + ]); + + // Number 0 means unlimited + $this->assertTrue($limitations->users->allowed(999, 'editor')); + } + + /** + * Test users limit with disabled role. + */ + public function test_users_disabled_role(): void { + + $limitations = new Limitations([ + 'users' => [ + 'enabled' => true, + 'limit' => [ + 'subscriber' => [ + 'enabled' => false, + 'number' => 10, + ], + ], + ], + ]); + + $this->assertFalse($limitations->users->allowed(0, 'subscriber')); + } + + /** + * Test users default permissions for unknown roles. + */ + public function test_users_default_permissions(): void { + + $limitations = new Limitations([ + 'users' => [ + 'enabled' => true, + 'limit' => [], + ], + ]); + + $role = $limitations->users->unknown_role; + + $this->assertTrue($role->enabled); + $this->assertEquals('', $role->number); + } + + // --------------------------------------------------------------- + // Sites limit tests + // --------------------------------------------------------------- + + /** + * Test sites limit get_limit returns correct value. + */ + public function test_sites_limit_get_limit(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $this->assertEquals(5, $limitations->sites->get_limit()); + } + + /** + * Test sites limit check always returns true. + */ + public function test_sites_limit_check_returns_true(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 1, + ], + ]); + + // Limit_Sites::check() always returns true + $this->assertTrue($limitations->sites->allowed(100)); + } + + // --------------------------------------------------------------- + // Disk space limit tests + // --------------------------------------------------------------- + + /** + * Test disk_space get_limit. + */ + public function test_disk_space_get_limit(): void { + + $limitations = new Limitations([ + 'disk_space' => [ + 'enabled' => true, + 'limit' => 500, + ], + ]); + + $this->assertEquals(500, $limitations->disk_space->get_limit()); + } + + /** + * Test disk_space check always returns true. + */ + public function test_disk_space_check_returns_true(): void { + + $limitations = new Limitations([ + 'disk_space' => [ + 'enabled' => true, + 'limit' => 100, + ], + ]); + + $this->assertTrue($limitations->disk_space->allowed(999)); + } + + // --------------------------------------------------------------- + // Visits limit tests + // --------------------------------------------------------------- + + /** + * Test visits limit get_limit. + */ + public function test_visits_limit_get_limit(): void { + + $limitations = new Limitations([ + 'visits' => [ + 'enabled' => true, + 'limit' => 50000, + ], + ]); + + $this->assertEquals(50000, $limitations->visits->get_limit()); + } + + // --------------------------------------------------------------- + // Domain mapping limit tests + // --------------------------------------------------------------- + + /** + * Test domain_mapping enabled state. + */ + public function test_domain_mapping_enabled(): void { + + $limitations = new Limitations([ + 'domain_mapping' => [ + 'enabled' => true, + 'limit' => null, + ], + ]); + + $this->assertTrue($limitations->domain_mapping->is_enabled()); + } + + /** + * Test domain_mapping disabled state. + */ + public function test_domain_mapping_disabled(): void { + + $limitations = new Limitations([ + 'domain_mapping' => [ + 'enabled' => false, + 'limit' => null, + ], + ]); + + $this->assertFalse($limitations->domain_mapping->is_enabled()); + } + + /** + * Test domain_mapping get_mode. + */ + public function test_domain_mapping_get_mode(): void { + + $limitations = new Limitations([ + 'domain_mapping' => [ + 'enabled' => true, + 'limit' => null, + 'mode' => 'assign_template', + ], + ]); + + $this->assertEquals('assign_template', $limitations->domain_mapping->get_mode()); + } + + /** + * Test domain_mapping default mode. + */ + public function test_domain_mapping_default_mode(): void { + + $limitations = new Limitations([ + 'domain_mapping' => [ + 'enabled' => true, + 'limit' => null, + ], + ]); + + $this->assertEquals('default', $limitations->domain_mapping->get_mode()); + } + + // --------------------------------------------------------------- + // Hide footer credits limit tests + // --------------------------------------------------------------- + + /** + * Test hide_credits check when enabled with boolean true limit. + */ + public function test_hide_credits_check_enabled_with_true_limit(): void { + + $limitations = new Limitations([ + 'hide_credits' => [ + 'enabled' => true, + 'limit' => true, + ], + ]); + + $this->assertTrue($limitations->hide_credits->allowed(null)); + } + + /** + * Test hide_credits check when disabled. + */ + public function test_hide_credits_check_when_disabled(): void { + + $limitations = new Limitations([ + 'hide_credits' => [ + 'enabled' => false, + 'limit' => true, + ], + ]); + + $this->assertFalse($limitations->hide_credits->allowed(null)); + } + + // --------------------------------------------------------------- + // Customer user role limit tests + // --------------------------------------------------------------- + + /** + * Test customer_user_role get_limit with specific role. + */ + public function test_customer_user_role_get_limit_specific(): void { + + $limitations = new Limitations([ + 'customer_user_role' => [ + 'enabled' => true, + 'limit' => 'editor', + ], + ]); + + $this->assertEquals('editor', $limitations->customer_user_role->get_limit()); + } + + // --------------------------------------------------------------- + // Site templates limit tests + // --------------------------------------------------------------- + + /** + * Test site_templates get_mode. + */ + public function test_site_templates_get_mode(): void { + + $limitations = new Limitations([ + 'site_templates' => [ + 'enabled' => true, + 'limit' => null, + 'mode' => 'assign_template', + ], + ]); + + $this->assertEquals('assign_template', $limitations->site_templates->get_mode()); + } + + /** + * Test site_templates default mode. + */ + public function test_site_templates_default_mode(): void { + + $limitations = new Limitations([ + 'site_templates' => [ + 'enabled' => true, + 'limit' => null, + ], + ]); + + $this->assertEquals('default', $limitations->site_templates->get_mode()); + } + + /** + * Test site_templates get_available_site_templates. + */ + public function test_site_templates_get_available(): void { + + $limitations = new Limitations([ + 'site_templates' => [ + 'enabled' => true, + 'limit' => [ + '1' => [ + 'behavior' => 'available', + ], + '2' => [ + 'behavior' => 'not_available', + ], + '3' => [ + 'behavior' => 'pre_selected', + ], + ], + 'mode' => 'choose_available_templates', + ], + ]); + + $available = $limitations->site_templates->get_available_site_templates(); + + $this->assertContains(1, $available); + $this->assertNotContains(2, $available); + $this->assertContains(3, $available); + } + + /** + * Test site_templates get_pre_selected_site_template. + */ + public function test_site_templates_get_pre_selected(): void { + + $limitations = new Limitations([ + 'site_templates' => [ + 'enabled' => true, + 'limit' => [ + '1' => [ + 'behavior' => 'available', + ], + '2' => [ + 'behavior' => 'pre_selected', + ], + ], + 'mode' => 'choose_available_templates', + ], + ]); + + $pre_selected = $limitations->site_templates->get_pre_selected_site_template(); + + $this->assertEquals('2', $pre_selected); + } + + /** + * Test site_templates get_pre_selected_site_template returns false when none. + */ + public function test_site_templates_get_pre_selected_returns_false(): void { + + $limitations = new Limitations([ + 'site_templates' => [ + 'enabled' => true, + 'limit' => [ + '1' => [ + 'behavior' => 'available', + ], + ], + 'mode' => 'choose_available_templates', + ], + ]); + + $pre_selected = $limitations->site_templates->get_pre_selected_site_template(); + + $this->assertFalse($pre_selected); + } + + // --------------------------------------------------------------- + // Limitations merge tests + // --------------------------------------------------------------- + + /** + * Test merging two limitations with summing (default). + */ + public function test_merge_limitations_sum_numeric_values(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $addon = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 3, + ], + ]); + + $merged = $base->merge($addon); + + $this->assertEquals(8, $merged->sites->get_limit()); + } + + /** + * Test merging limitations with override (true). + */ + public function test_merge_limitations_override(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $addon = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 3, + ], + ]); + + $merged = $base->merge(true, $addon); + + $this->assertEquals(3, $merged->sites->get_limit()); + } + + /** + * Test merging limitations preserves unlimited values when summing. + */ + public function test_merge_limitations_preserves_unlimited(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 0, // unlimited + ], + ]); + + $addon = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $merged = $base->merge($addon); + + // When one value is 0 (unlimited) and we're summing, unlimited should be preserved + $this->assertEquals(0, $merged->sites->get_limit()); + } + + /** + * Test merging disk space limitations. + */ + public function test_merge_disk_space_limitations(): void { + + $base = new Limitations([ + 'disk_space' => [ + 'enabled' => true, + 'limit' => 100, + ], + ]); + + $addon = new Limitations([ + 'disk_space' => [ + 'enabled' => true, + 'limit' => 50, + ], + ]); + + $merged = $base->merge($addon); + + $this->assertEquals(150, $merged->disk_space->get_limit()); + } + + /** + * Test merging visits limitations. + */ + public function test_merge_visits_limitations(): void { + + $base = new Limitations([ + 'visits' => [ + 'enabled' => true, + 'limit' => 10000, + ], + ]); + + $addon = new Limitations([ + 'visits' => [ + 'enabled' => true, + 'limit' => 5000, + ], + ]); + + $merged = $base->merge($addon); + + $this->assertEquals(15000, $merged->visits->get_limit()); + } + + /** + * Test merging with disabled module in second set is skipped when summing. + */ + public function test_merge_skips_disabled_module_when_summing(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $disabled = new Limitations([ + 'sites' => [ + 'enabled' => false, + 'limit' => 3, + ], + ]); + + $merged = $base->merge($disabled); + + // Disabled module should be skipped when summing + $this->assertEquals(5, $merged->sites->get_limit()); + } + + /** + * Test merging multiple limitations. + */ + public function test_merge_multiple_limitations(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 2, + ], + ]); + + $addon1 = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 3, + ], + ]); + + $addon2 = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 1, + ], + ]); + + $merged = $base->merge($addon1, $addon2); + + $this->assertEquals(6, $merged->sites->get_limit()); + } + + /** + * Test merging with empty limitations. + */ + public function test_merge_with_empty_limitations(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $empty = new Limitations([]); + + $merged = $base->merge($empty); + + $this->assertEquals(5, $merged->sites->get_limit()); + } + + /** + * Test merging with non-array value is skipped. + */ + public function test_merge_with_non_array_value_skipped(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $merged = $base->merge('not-an-array'); + + $this->assertEquals(5, $merged->sites->get_limit()); + } + + /** + * Test merging plugin behavior priorities. + */ + public function test_merge_plugin_behavior_priorities(): void { + + $base_data = [ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'akismet/akismet.php' => [ + 'id' => 'plugins', + 'visibility' => 'visible', + 'behavior' => 'default', + ], + ], + ], + ]; + + $override_data = [ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'akismet/akismet.php' => [ + 'id' => 'plugins', + 'visibility' => 'visible', + 'behavior' => 'force_active', + ], + ], + ], + ]; + + $base = new Limitations($base_data); + $merged = $base->merge($override_data); + + // force_active has higher priority than default, so it should win + $plugin = $merged->plugins->{'akismet/akismet.php'}; + $this->assertEquals('force_active', $plugin->behavior); + } + + /** + * Test merging theme behavior priorities. + */ + public function test_merge_theme_behavior_priorities(): void { + + $base_data = [ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'id' => 'themes', + 'visibility' => 'visible', + 'behavior' => 'not_available', + ], + ], + ], + ]; + + $override_data = [ + 'themes' => [ + 'enabled' => true, + 'limit' => [ + 'twentytwentyfour' => [ + 'id' => 'themes', + 'visibility' => 'visible', + 'behavior' => 'available', + ], + ], + ], + ]; + + $base = new Limitations($base_data); + $merged = $base->merge($override_data); + + $theme = $merged->themes->twentytwentyfour; + $this->assertEquals('available', $theme->behavior); + } + + /** + * Test merging visibility priorities. + */ + public function test_merge_visibility_priorities(): void { + + $base_data = [ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'akismet/akismet.php' => [ + 'id' => 'plugins', + 'visibility' => 'hidden', + 'behavior' => 'default', + ], + ], + ], + ]; + + $override_data = [ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'akismet/akismet.php' => [ + 'id' => 'plugins', + 'visibility' => 'visible', + 'behavior' => 'default', + ], + ], + ], + ]; + + $base = new Limitations($base_data); + $merged = $base->merge($override_data); + + $plugin = $merged->plugins->{'akismet/akismet.php'}; + // visible has higher priority than hidden + $this->assertEquals('visible', $plugin->visibility); + } + + // --------------------------------------------------------------- + // Serialization tests + // --------------------------------------------------------------- + + /** + * Test Limitations serialization round-trip. + */ + public function test_limitations_serialization(): void { + + $data = [ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + 'disk_space' => [ + 'enabled' => true, + 'limit' => 100, + ], + ]; + + $limitations = new Limitations($data); + + $serialized = serialize($limitations); + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(Limitations::class, $unserialized); + $this->assertEquals($data, $unserialized->to_array()); + } + + /** + * Test Limit module serialization. + */ + public function test_limit_module_json_serialization(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $json = $limitations->sites->jsonSerialize(); + + $this->assertIsString($json); + + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertTrue($decoded['enabled']); + $this->assertEquals(5, $decoded['limit']); + } + + /** + * Test Limit module to_array. + */ + public function test_limit_module_to_array(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $array = $limitations->sites->to_array(); + + $this->assertIsArray($array); + $this->assertArrayHasKey('enabled', $array); + $this->assertArrayHasKey('limit', $array); + $this->assertArrayHasKey('id', $array); + $this->assertEquals('sites', $array['id']); + } + + // --------------------------------------------------------------- + // Limit default_state tests + // --------------------------------------------------------------- + + /** + * Test base Limit default_state. + */ + public function test_limit_default_state(): void { + + $state = \WP_Ultimo\Limitations\Limit_Sites::default_state(); + + $this->assertArrayHasKey('enabled', $state); + $this->assertArrayHasKey('limit', $state); + $this->assertFalse($state['enabled']); + $this->assertNull($state['limit']); + } + + /** + * Test site_templates default_state includes mode. + */ + public function test_site_templates_default_state(): void { + + $state = \WP_Ultimo\Limitations\Limit_Site_Templates::default_state(); + + $this->assertArrayHasKey('mode', $state); + $this->assertEquals('default', $state['mode']); + } + + /** + * Test domain_mapping default_state. + */ + public function test_domain_mapping_default_state(): void { + + $state = \WP_Ultimo\Limitations\Limit_Domain_Mapping::default_state(); + + $this->assertArrayHasKey('mode', $state); + $this->assertEquals('default', $state['mode']); + $this->assertTrue($state['enabled']); + } + + /** + * Test hide_credits default_state. + */ + public function test_hide_credits_default_state(): void { + + $state = \WP_Ultimo\Limitations\Limit_Hide_Footer_Credits::default_state(); + + $this->assertFalse($state['enabled']); + $this->assertFalse($state['limit']); + } + + /** + * Test customer_user_role default_state. + */ + public function test_customer_user_role_default_state(): void { + + $state = \WP_Ultimo\Limitations\Limit_Customer_User_Role::default_state(); + + $this->assertTrue($state['enabled']); + $this->assertEquals('default', $state['limit']); + } + + // --------------------------------------------------------------- + // Limit get_id tests + // --------------------------------------------------------------- + + /** + * Test get_id for each limit module. + */ + public function test_limit_module_ids(): void { + + $modules_to_test = [ + 'sites' => 'sites', + 'disk_space' => 'disk_space', + 'visits' => 'visits', + 'users' => 'users', + 'post_types' => 'post_types', + 'plugins' => 'plugins', + 'themes' => 'themes', + 'domain_mapping' => 'domain_mapping', + 'site_templates' => 'site_templates', + 'customer_user_role' => 'customer_user_role', + 'hide_credits' => 'hide_credits', + ]; + + foreach ($modules_to_test as $module_key => $expected_id) { + $limitations = new Limitations([ + $module_key => [ + 'enabled' => true, + 'limit' => null, + ], + ]); + + $module = $limitations->{$module_key}; + $this->assertEquals($expected_id, $module->get_id(), "Module '$module_key' should have id '$expected_id'."); + } + } + + // --------------------------------------------------------------- + // Limitation_Manager plugin exclusion tests + // --------------------------------------------------------------- + + /** + * Test get_all_plugins excludes WP Ultimo plugin. + */ + public function test_get_all_plugins_excludes_wp_ultimo(): void { + + $manager = $this->get_manager_instance(); + $plugins = $manager->get_all_plugins(); + + $this->assertArrayNotHasKey('wp-ultimo/wp-ultimo.php', $plugins); + } + + /** + * Test get_all_plugins excludes user-switching plugin. + */ + public function test_get_all_plugins_excludes_user_switching(): void { + + $manager = $this->get_manager_instance(); + $plugins = $manager->get_all_plugins(); + + $this->assertArrayNotHasKey('user-switching/user-switching.php', $plugins); + } + + /** + * Test plugin exclusion list filter. + */ + public function test_plugin_exclusion_list_filter(): void { + + add_filter('wu_limitations_plugin_exclusion_list', function ($list) { + $list[] = 'custom-plugin/custom-plugin.php'; + return $list; + }); + + $manager = $this->get_manager_instance(); + $plugins = $manager->get_all_plugins(); + + $this->assertArrayNotHasKey('custom-plugin/custom-plugin.php', $plugins); + + remove_all_filters('wu_limitations_plugin_exclusion_list'); + } + + /** + * Test theme exclusion list filter. + */ + public function test_theme_exclusion_list_filter(): void { + + $manager = $this->get_manager_instance(); + + // First get all themes count + $all_themes = $manager->get_all_themes(); + $count = count($all_themes); + + if ($count > 0) { + $first_key = array_key_first($all_themes); + + add_filter('wu_limitations_theme_exclusion_list', function ($list) use ($first_key) { + $list[] = $first_key; + return $list; + }); + + $filtered_themes = $manager->get_all_themes(); + + $this->assertArrayNotHasKey($first_key, $filtered_themes); + $this->assertCount($count - 1, $filtered_themes); + + remove_all_filters('wu_limitations_theme_exclusion_list'); + } else { + $this->assertCount(0, $all_themes); + } + } + + // --------------------------------------------------------------- + // add_limitation_sections tests + // --------------------------------------------------------------- + + /** + * Test add_limitation_sections returns array for product. + */ + public function test_add_limitation_sections_for_product(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Plan', + 'slug' => 'test-plan-sections', + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + $this->assertIsArray($sections); + + // Check that expected sections exist + $this->assertArrayHasKey('sites', $sections); + $this->assertArrayHasKey('users', $sections); + $this->assertArrayHasKey('post_types', $sections); + $this->assertArrayHasKey('limit_disk_space', $sections); + $this->assertArrayHasKey('custom_domain', $sections); + $this->assertArrayHasKey('hide_credits', $sections); + $this->assertArrayHasKey('allowed_themes', $sections); + $this->assertArrayHasKey('allowed_plugins', $sections); + $this->assertArrayHasKey('reset_limitations', $sections); + } + + /** + * Test add_limitation_sections for membership. + */ + public function test_add_limitation_sections_for_membership(): void { + + $manager = $this->get_manager_instance(); + $customer = $this->create_test_customer(); + + $membership = new Membership([ + 'customer_id' => $customer->get_id(), + 'status' => 'active', + ]); + $membership->set_skip_validation(true); + $membership->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $membership); + + $this->assertIsArray($sections); + $this->assertArrayHasKey('sites', $sections); + $this->assertArrayHasKey('users', $sections); + $this->assertArrayHasKey('post_types', $sections); + $this->assertArrayHasKey('limit_disk_space', $sections); + $this->assertArrayHasKey('reset_limitations', $sections); + } + + /** + * Test add_limitation_sections for non-customer-owned site. + */ + public function test_add_limitation_sections_for_non_customer_site(): void { + + $manager = $this->get_manager_instance(); + + $blog_id = self::factory()->blog->create(); + $site = new Site([ + 'blog_id' => $blog_id, + 'type' => Site_Type::MAIN, + ]); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $site); + + $this->assertIsArray($sections); + // For non-customer-owned sites, only a 'sites' section with a note should exist + $this->assertArrayHasKey('sites', $sections); + $this->assertArrayHasKey('fields', $sections['sites']); + $this->assertArrayHasKey('note', $sections['sites']['fields']); + } + + /** + * Test add_limitation_sections for customer-owned site does not have sites section. + */ + public function test_add_limitation_sections_for_customer_owned_site_no_sites(): void { + + $manager = $this->get_manager_instance(); + + $blog_id = self::factory()->blog->create(); + $site = new Site([ + 'blog_id' => $blog_id, + 'type' => Site_Type::CUSTOMER_OWNED, + ]); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $site); + + // For customer-owned sites, it should NOT have a 'sites' tab (that's only for product/membership) + // But it should have other limitation sections + $this->assertArrayHasKey('users', $sections); + $this->assertArrayHasKey('post_types', $sections); + $this->assertArrayHasKey('limit_disk_space', $sections); + } + + // --------------------------------------------------------------- + // register_user_fields tests + // --------------------------------------------------------------- + + /** + * Test register_user_fields adds fields for each user role. + */ + public function test_register_user_fields_adds_role_fields(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Plan User Fields', + 'slug' => 'test-plan-user-fields', + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = [ + 'users' => [ + 'title' => 'Users', + 'fields' => [], + ], + ]; + + $manager->register_user_fields($sections, $product); + + $user_roles = get_editable_roles(); + + $this->assertArrayHasKey('state', $sections['users']); + $this->assertArrayHasKey('roles', $sections['users']['state']); + + foreach ($user_roles as $role_slug => $role) { + $this->assertArrayHasKey("control_{$role_slug}", $sections['users']['fields']); + } + } + + // --------------------------------------------------------------- + // register_post_type_fields tests + // --------------------------------------------------------------- + + /** + * Test register_post_type_fields adds fields for visible post types. + */ + public function test_register_post_type_fields_adds_post_type_fields(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Plan PT Fields', + 'slug' => 'test-plan-pt-fields', + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = [ + 'post_types' => [ + 'title' => 'Post Types', + 'fields' => [], + ], + ]; + + $manager->register_post_type_fields($sections, $product); + + $this->assertArrayHasKey('state', $sections['post_types']); + $this->assertArrayHasKey('types', $sections['post_types']['state']); + + // At minimum, 'post' and 'page' should be there + $this->assertArrayHasKey('control_post', $sections['post_types']['fields']); + $this->assertArrayHasKey('control_page', $sections['post_types']['fields']); + } + + // --------------------------------------------------------------- + // Product limitations + // --------------------------------------------------------------- + + /** + * Test product get_limitations returns Limitations object. + */ + public function test_product_get_limitations_returns_limitations(): void { + + $product = new Product([ + 'name' => 'Test Plan Get Limitations', + 'slug' => 'test-plan-get-limits', + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $limitations = $product->get_limitations(); + + $this->assertInstanceOf(Limitations::class, $limitations); + } + + /** + * Test product limitations_to_merge returns empty array. + */ + public function test_product_limitations_to_merge_is_empty(): void { + + $product = new Product([ + 'name' => 'Test Plan Merge', + 'slug' => 'test-plan-merge', + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $this->assertEquals([], $product->limitations_to_merge()); + } + + // --------------------------------------------------------------- + // Async operations tests + // --------------------------------------------------------------- + + /** + * Test async_handle_plugins returns early for main site. + */ + public function test_async_handle_plugins_skips_main_site(): void { + + $manager = $this->get_manager_instance(); + + $main_site_id = get_main_site_id(); + + // This should not throw any errors - it just returns early + $manager->async_handle_plugins('activate', $main_site_id, []); + + $this->assertTrue(true); // No exception thrown + } + + /** + * Test async_switch_theme switches theme on given site. + */ + public function test_async_switch_theme(): void { + + $manager = $this->get_manager_instance(); + + $blog_id = self::factory()->blog->create(); + + $themes = wp_get_themes(); + if (count($themes) < 1) { + $this->markTestSkipped('No themes available to test with.'); + } + + $theme_key = array_key_first($themes); + + $manager->async_switch_theme($blog_id, $theme_key); + + switch_to_blog($blog_id); + $current_theme = get_stylesheet(); + restore_current_blog(); + + $this->assertEquals($theme_key, $current_theme); + } + + // --------------------------------------------------------------- + // register_forms test + // --------------------------------------------------------------- + + /** + * Test register_forms registers the confirmation form. + */ + public function test_register_forms(): void { + + $manager = $this->get_manager_instance(); + $manager->register_forms(); + + // After registration, the form should exist. We just verify no error is thrown. + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Limitations build_modules and build tests + // --------------------------------------------------------------- + + /** + * Test build_modules replaces internal data. + */ + public function test_limitations_build_modules(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $new_data = [ + 'sites' => [ + 'enabled' => true, + 'limit' => 10, + ], + ]; + + $result = $limitations->build_modules($new_data); + + $this->assertSame($limitations, $result); + $this->assertEquals(10, $limitations->sites->get_limit()); + } + + /** + * Test Limitations::build for a known module. + */ + public function test_limitations_build_known_module(): void { + + $module = Limitations::build( + [ + 'enabled' => true, + 'limit' => 42, + ], + 'sites' + ); + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Sites::class, $module); + $this->assertEquals(42, $module->get_limit()); + } + + /** + * Test Limitations::build for an unknown module returns false. + */ + public function test_limitations_build_unknown_module_returns_false(): void { + + $module = Limitations::build([], 'nonexistent_module'); + + $this->assertFalse($module); + } + + /** + * Test Limitations::build with JSON string data. + */ + public function test_limitations_build_with_json_string(): void { + + $json = json_encode([ + 'enabled' => true, + 'limit' => 7, + ]); + + $module = Limitations::build($json, 'sites'); + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Sites::class, $module); + $this->assertEquals(7, $module->get_limit()); + } + + // --------------------------------------------------------------- + // Plugin selection list test + // --------------------------------------------------------------- + + /** + * Test get_plugin_selection_list returns a string. + */ + public function test_get_plugin_selection_list_returns_string(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Plan Plugin List', + 'slug' => 'test-plan-plugin-list', + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $result = $manager->get_plugin_selection_list($product); + + $this->assertIsString($result); + } + + // --------------------------------------------------------------- + // Post type limit - is_post_above_limit + // --------------------------------------------------------------- + + /** + * Test post_types is_post_above_limit. + */ + public function test_post_types_is_post_above_limit(): void { + + $limitations = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 5, + ], + ], + ], + ]); + + // With no posts created, should not be above limit + // Note: is_post_above_limit depends on actual post count + $result = $limitations->post_types->is_post_above_limit('post'); + + // Result depends on actual post count, but we can at least verify it returns bool + $this->assertIsBool($result); + } + + // --------------------------------------------------------------- + // Limit allowed method + // --------------------------------------------------------------- + + /** + * Test allowed method respects enabled state. + */ + public function test_allowed_respects_enabled_state(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => false, + 'limit' => 5, + ], + ]); + + // When module is disabled, allowed should return false + $this->assertFalse($limitations->sites->allowed(1)); + } + + /** + * Test allowed method when enabled calls check. + */ + public function test_allowed_when_enabled_calls_check(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + // Limit_Sites::check() always returns true + $this->assertTrue($limitations->sites->allowed(1)); + } + + /** + * Test allowed filter hook. + */ + public function test_allowed_filter_hook(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + add_filter('wu_limit_sites__allowed', function ($allowed) { + return false; + }); + + $this->assertFalse($limitations->sites->allowed(1)); + + remove_all_filters('wu_limit_sites__allowed'); + } + + // --------------------------------------------------------------- + // Limitation modules from repository filter + // --------------------------------------------------------------- + + /** + * Test wu_limit_classes filter can add custom limit classes. + */ + public function test_wu_limit_classes_filter(): void { + + add_filter('wu_limit_classes', function ($classes) { + // Add a fake class that does not exist + $classes['custom_limit'] = 'NonExistentClass'; + return $classes; + }); + + $repository = Limitations::repository(); + + $this->assertArrayHasKey('custom_limit', $repository); + + remove_all_filters('wu_limit_classes'); + } + + // --------------------------------------------------------------- + // Limitation_Manager init tests + // --------------------------------------------------------------- + + /** + * Test init registers expected hooks. + */ + public function test_init_registers_hooks(): void { + + $manager = $this->get_manager_instance(); + + $this->assertNotFalse(has_filter('wu_product_options_sections', [$manager, 'add_limitation_sections'])); + $this->assertNotFalse(has_filter('wu_membership_options_sections', [$manager, 'add_limitation_sections'])); + $this->assertNotFalse(has_filter('wu_site_options_sections', [$manager, 'add_limitation_sections'])); + $this->assertNotFalse(has_action('wu_async_handle_plugins', [$manager, 'async_handle_plugins'])); + $this->assertNotFalse(has_action('wu_async_switch_theme', [$manager, 'async_switch_theme'])); + } + + // --------------------------------------------------------------- + // Limitation sections include visits when setting is enabled + // --------------------------------------------------------------- + + /** + * Test visits section included when visits limiting is enabled. + */ + public function test_add_limitation_sections_includes_visits_when_enabled(): void { + + $manager = $this->get_manager_instance(); + + wu_save_setting('enable_visits_limiting', true); + + $product = new Product([ + 'name' => 'Test Plan Visits', + 'slug' => 'test-plan-visits-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + $this->assertArrayHasKey('visits', $sections); + } + + /** + * Test visits section not included when visits limiting is disabled. + */ + public function test_add_limitation_sections_excludes_visits_when_disabled(): void { + + $manager = $this->get_manager_instance(); + + wu_save_setting('enable_visits_limiting', false); + + $product = new Product([ + 'name' => 'Test Plan No Visits', + 'slug' => 'test-plan-no-visits-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + $this->assertArrayNotHasKey('visits', $sections); + + // Restore default + wu_save_setting('enable_visits_limiting', true); + } + + // --------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------- + + /** + * Test Limitations with all modules disabled. + */ + public function test_limitations_all_disabled(): void { + + $data = []; + foreach (array_keys(Limitations::repository()) as $module_name) { + $data[ $module_name ] = [ + 'enabled' => false, + 'limit' => null, + ]; + } + + $limitations = new Limitations($data); + + $this->assertFalse($limitations->has_limitations()); + } + + /** + * Test Limitations with mixed enabled/disabled modules. + */ + public function test_limitations_mixed_enabled_disabled(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + 'disk_space' => [ + 'enabled' => false, + 'limit' => 100, + ], + ]); + + $this->assertTrue($limitations->has_limitations()); + $this->assertTrue($limitations->is_module_enabled('sites')); + $this->assertFalse($limitations->is_module_enabled('disk_space')); + } + + /** + * Test Limit setup with non-array data casts to array. + */ + public function test_limit_setup_with_non_array_data(): void { + + $limitations = new Limitations([ + 'sites' => (object) [ + 'enabled' => true, + 'limit' => 3, + ], + ]); + + $sites = $limitations->sites; + + $this->assertInstanceOf(\WP_Ultimo\Limitations\Limit_Sites::class, $sites); + $this->assertTrue($sites->is_enabled()); + $this->assertEquals(3, $sites->get_limit()); + } + + /** + * Test accessing the same module multiple times returns cached instance. + */ + public function test_module_access_is_cached(): void { + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $first = $limitations->sites; + $second = $limitations->sites; + + $this->assertSame($first, $second); + } + + /** + * Test merging preserves enabled true when summing. + */ + public function test_merge_preserves_true_enabled_when_summing(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $override = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 3, + ], + ]); + + $merged = $base->merge($override); + + $this->assertTrue($merged->sites->is_enabled()); + } + + /** + * Test merging with disabled base and enabled addon. + */ + public function test_merge_disabled_base_enabled_addon(): void { + + $base = new Limitations([ + 'sites' => [ + 'enabled' => false, + ], + ]); + + $addon = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + $merged = $base->merge($addon); + + // When base is disabled, merge_recursive sets it to {enabled: false} + // and addon's values should still apply based on the merge logic + $arr = $merged->to_array(); + $this->assertArrayHasKey('sites', $arr); + } + + /** + * Test get_all_plugins does not include network-only plugins. + */ + public function test_get_all_plugins_excludes_network_only(): void { + + $manager = $this->get_manager_instance(); + $plugins = $manager->get_all_plugins(); + + // All returned plugins should NOT have Network = true + foreach ($plugins as $plugin_info) { + $network = wu_get_isset($plugin_info, 'Network', false); + $this->assertNotTrue($network, 'Network-only plugins should be excluded.'); + } + } + + /** + * Test plugins get_by_type for force_active behavior. + */ + public function test_plugins_get_by_type_force_active(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'plugin-a/plugin-a.php' => [ + 'visibility' => 'visible', + 'behavior' => 'force_active', + ], + 'plugin-b/plugin-b.php' => [ + 'visibility' => 'visible', + 'behavior' => 'default', + ], + 'plugin-c/plugin-c.php' => [ + 'visibility' => 'hidden', + 'behavior' => 'force_active', + ], + ], + ], + ]); + + $forced_active = $limitations->plugins->get_by_type('force_active'); + + $this->assertArrayHasKey('plugin-a/plugin-a.php', $forced_active); + $this->assertArrayHasKey('plugin-c/plugin-c.php', $forced_active); + $this->assertArrayNotHasKey('plugin-b/plugin-b.php', $forced_active); + } + + /** + * Test plugins get_by_type for force_inactive behavior. + */ + public function test_plugins_get_by_type_force_inactive(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'plugin-a/plugin-a.php' => [ + 'visibility' => 'visible', + 'behavior' => 'force_inactive', + ], + 'plugin-b/plugin-b.php' => [ + 'visibility' => 'visible', + 'behavior' => 'default', + ], + ], + ], + ]); + + $forced_inactive = $limitations->plugins->get_by_type('force_inactive'); + + $this->assertArrayHasKey('plugin-a/plugin-a.php', $forced_inactive); + $this->assertArrayNotHasKey('plugin-b/plugin-b.php', $forced_inactive); + } + + /** + * Test plugins get_by_type with visibility filter. + */ + public function test_plugins_get_by_type_with_visibility(): void { + + $limitations = new Limitations([ + 'plugins' => [ + 'enabled' => true, + 'limit' => [ + 'plugin-a/plugin-a.php' => [ + 'visibility' => 'hidden', + 'behavior' => 'default', + ], + 'plugin-b/plugin-b.php' => [ + 'visibility' => 'visible', + 'behavior' => 'default', + ], + ], + ], + ]); + + $hidden = $limitations->plugins->get_by_type(null, 'hidden'); + + $this->assertArrayHasKey('plugin-a/plugin-a.php', $hidden); + $this->assertArrayNotHasKey('plugin-b/plugin-b.php', $hidden); + } + + // --------------------------------------------------------------- + // Limitation_Manager sections have correct structure + // --------------------------------------------------------------- + + /** + * Test sites section has correct field structure. + */ + public function test_sites_section_has_fields(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Sites Fields', + 'slug' => 'test-sites-fields-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + $this->assertArrayHasKey('fields', $sections['sites']); + $this->assertArrayHasKey('modules[sites][enabled]', $sections['sites']['fields']); + $this->assertArrayHasKey('modules[sites][limit]', $sections['sites']['fields']); + } + + /** + * Test disk space section has correct field structure. + */ + public function test_disk_space_section_has_fields(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Disk Fields', + 'slug' => 'test-disk-fields-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + $this->assertArrayHasKey('fields', $sections['limit_disk_space']); + $this->assertArrayHasKey('modules[disk_space][enabled]', $sections['limit_disk_space']['fields']); + $this->assertArrayHasKey('modules[disk_space][limit]', $sections['limit_disk_space']['fields']); + } + + /** + * Test domain mapping section has correct field structure. + */ + public function test_domain_mapping_section_has_fields(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Domain Fields', + 'slug' => 'test-domain-fields-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + $this->assertArrayHasKey('custom_domain', $sections); + $this->assertArrayHasKey('fields', $sections['custom_domain']); + $this->assertArrayHasKey('modules[domain_mapping][enabled]', $sections['custom_domain']['fields']); + } + + /** + * Test hide credits section has correct field structure. + */ + public function test_hide_credits_section_has_fields(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test Credits Fields', + 'slug' => 'test-credits-fields-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + $this->assertArrayHasKey('hide_credits', $sections); + $this->assertArrayHasKey('fields', $sections['hide_credits']); + $this->assertArrayHasKey('modules[hide_credits][enabled]', $sections['hide_credits']['fields']); + } + + // --------------------------------------------------------------- + // Membership override notices + // --------------------------------------------------------------- + + /** + * Test membership sections include override notices. + */ + public function test_membership_sections_include_override_notices(): void { + + $manager = $this->get_manager_instance(); + $customer = $this->create_test_customer(); + + $membership = new Membership([ + 'customer_id' => $customer->get_id(), + 'status' => 'active', + ]); + $membership->set_skip_validation(true); + $membership->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $membership); + + // For non-product models, override notices should be added + // Check users section has override notice field + $this->assertArrayHasKey('modules_user_overwrite', $sections['users']['fields']); + } + + /** + * Test product sections do not include override notices. + */ + public function test_product_sections_no_override_notices(): void { + + $manager = $this->get_manager_instance(); + + $product = new Product([ + 'name' => 'Test No Override', + 'slug' => 'test-no-override-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + $sections = []; + $sections = $manager->add_limitation_sections($sections, $product); + + // Products should NOT have override notices + $this->assertArrayNotHasKey('modules_user_overwrite', $sections['users']['fields']); + } + + // --------------------------------------------------------------- + // Limitation early_get_limitations + // --------------------------------------------------------------- + + /** + * Test early_get_limitations returns empty array when no limitations set. + */ + public function test_early_get_limitations_empty(): void { + + $product = new Product([ + 'name' => 'Test Early Lim', + 'slug' => 'test-early-lim-' . wp_rand(), + 'type' => 'plan', + ]); + $product->set_skip_validation(true); + $product->save(); + + // Clear cache + $reflection = new \ReflectionClass(Limitations::class); + $cache_prop = $reflection->getProperty('limitations_cache'); + $cache_prop->setAccessible(true); + $cache_prop->setValue(null, []); + + $result = Limitations::early_get_limitations('product', $product->get_id()); + + // Should return empty array or empty Limitations when no meta set + $this->assertEmpty($result); + } + + // --------------------------------------------------------------- + // Limitations remove_limitations + // --------------------------------------------------------------- + + /** + * Test remove_limitations does not error on nonexistent data. + */ + public function test_remove_limitations_no_error_on_missing(): void { + + // This should not throw any errors + Limitations::remove_limitations('product', 99999); + + $this->assertTrue(true); // No exception + } + + // --------------------------------------------------------------- + // Limit setup action hook + // --------------------------------------------------------------- + + /** + * Test limit setup action fires during construction. + */ + public function test_limit_setup_fires_action(): void { + + $action_fired = false; + + add_action('wu_sites_limit_setup', function () use (&$action_fired) { + $action_fired = true; + }); + + $limitations = new Limitations([ + 'sites' => [ + 'enabled' => true, + 'limit' => 5, + ], + ]); + + // Access the module to trigger construction + $limitations->sites; + + $this->assertTrue($action_fired); + + remove_all_actions('wu_sites_limit_setup'); + } + + // --------------------------------------------------------------- + // Merging post type limitations + // --------------------------------------------------------------- + + /** + * Test merging post type limits sums numbers. + */ + public function test_merge_post_type_limits_sum(): void { + + $base = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 10, + ], + ], + ], + ]); + + $addon = new Limitations([ + 'post_types' => [ + 'enabled' => true, + 'limit' => [ + 'post' => [ + 'enabled' => true, + 'number' => 5, + ], + ], + ], + ]); + + $merged = $base->merge($addon); + + $post = $merged->post_types->post; + + $this->assertEquals(15, $post->number); + } + + /** + * Test merging user role limits sums numbers. + */ + public function test_merge_user_limits_sum(): void { + + $base = new Limitations([ + 'users' => [ + 'enabled' => true, + 'limit' => [ + 'editor' => [ + 'enabled' => true, + 'number' => 3, + ], + ], + ], + ]); + + $addon = new Limitations([ + 'users' => [ + 'enabled' => true, + 'limit' => [ + 'editor' => [ + 'enabled' => true, + 'number' => 2, + ], + ], + ], + ]); + + $merged = $base->merge($addon); + + $editor = $merged->users->editor; + + $this->assertEquals(5, $editor->number); + } } diff --git a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php index 0638afd0..753f88da 100644 --- a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php @@ -13,19 +13,13 @@ use WP_Ultimo\Models\Customer; use WP_Ultimo\Models\Product; use WP_Ultimo\Database\Memberships\Membership_Status; -use WP_UnitTestCase; /** * Test Membership Manager functionality. */ -class Membership_Manager_Test extends WP_UnitTestCase { +class Membership_Manager_Test extends \WP_UnitTestCase { - /** - * Test membership manager instance. - * - * @var Membership_Manager - */ - private $manager; + use Manager_Test_Trait; /** * Test customer. @@ -41,30 +35,40 @@ class Membership_Manager_Test extends WP_UnitTestCase { */ private $product; + protected function get_manager_class(): string { + return Membership_Manager::class; + } + + protected function get_expected_slug(): ?string { + return 'membership'; + } + + protected function get_expected_model_class(): ?string { + return \WP_Ultimo\Models\Membership::class; + } + /** * Set up test. */ public function setUp(): void { parent::setUp(); - $this->manager = Membership_Manager::get_instance(); - - // Create test customer - $customer = wu_create_customer( + $this->customer = wu_create_customer( [ - 'username' => 'testeuser', - 'email' => 'teste@example.com', + 'username' => 'testmember' . wp_rand(), + 'email' => 'testmember' . wp_rand() . '@example.com', 'password' => 'password123', ] ); - $this->customer = $customer; + if (is_wp_error($this->customer)) { + $this->fail('Could not create test customer: ' . $this->customer->get_error_message()); + } - // Create test product - $product = wu_create_product( + $this->product = wu_create_product( [ 'name' => 'Test Product', - 'slug' => 'test-product', + 'slug' => 'test-product-' . wp_rand(), 'description' => 'A test product', 'type' => 'plan', 'amount' => 10, @@ -74,228 +78,607 @@ public function setUp(): void { ] ); - if (is_wp_error($product)) { - $this->fail('Could not create test product: ' . $product->get_error_message()); + if (is_wp_error($this->product)) { + $this->fail('Could not create test product: ' . $this->product->get_error_message()); } + } - $this->product = $product; + /** + * Clean up after tests. + */ + public function tearDown(): void { + + if ($this->customer && ! is_wp_error($this->customer)) { + $this->customer->delete(); + } + if ($this->product && ! is_wp_error($this->product)) { + $this->product->delete(); + } + + parent::tearDown(); } /** - * Test manager initialization. + * Helper to create a membership. + * + * @param array $overrides Overrides for the membership data. + * @return Membership */ - public function test_manager_initialization() { - $this->assertInstanceOf(Membership_Manager::class, $this->manager); + protected function create_membership(array $overrides = []): Membership { + + $defaults = [ + 'customer_id' => $this->customer->get_id(), + 'plan_id' => $this->product->get_id(), + 'status' => Membership_Status::ACTIVE, + 'amount' => 10, + 'currency' => 'USD', + 'skip_validation' => true, + ]; - // Use reflection to access protected properties - $reflection = new \ReflectionClass($this->manager); - $slug_property = $reflection->getProperty('slug'); + $membership = wu_create_membership(array_merge($defaults, $overrides)); - // Only call setAccessible() on PHP < 8.1 where it's needed - if (PHP_VERSION_ID < 80100) { - $slug_property->setAccessible(true); + if (is_wp_error($membership)) { + $this->fail('Could not create test membership: ' . $membership->get_error_message()); } - $this->assertEquals('membership', $slug_property->getValue($this->manager)); + return $membership; + } - $model_class_property = $reflection->getProperty('model_class'); + // ======================================================================== + // init() -- verify hooks are registered + // ======================================================================== - // Only call setAccessible() on PHP < 8.1 where it's needed - if (PHP_VERSION_ID < 80100) { - $model_class_property->setAccessible(true); - } + /** + * Test init registers the wu_async_transfer_membership hook. + */ + public function test_init_registers_async_transfer_membership_hook(): void { + + $manager = $this->get_manager_instance(); - $this->assertEquals(\WP_Ultimo\Models\Membership::class, $model_class_property->getValue($this->manager)); + $this->assertIsInt( + has_action('wu_async_transfer_membership', [$manager, 'async_transfer_membership']) + ); } /** - * Test async publish pending site with valid membership. + * Test init registers the wu_async_delete_membership hook. */ - public function test_async_publish_pending_site_success() { - // Create membership with pending site - $membership = wu_create_membership( - [ - 'customer_id' => $this->customer->get_id(), - 'plan_id' => $this->product->get_id(), - 'status' => Membership_Status::ACTIVE, - 'amount' => 10, - 'currency' => 'USD', - ] + public function test_init_registers_async_delete_membership_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_async_delete_membership', [$manager, 'async_delete_membership']) ); + } - if (is_wp_error($membership)) { - $this->fail($membership->get_error_message()); - } + /** + * Test init registers the mark_cancelled_date hook. + */ + public function test_init_registers_mark_cancelled_date_hook(): void { - $this->assertInstanceOf(Membership::class, $membership); + $manager = $this->get_manager_instance(); - // Test async publish with valid membership ID - $result = $this->manager->async_publish_pending_site($membership->get_id()); + $this->assertIsInt( + has_action('wu_transition_membership_status', [$manager, 'mark_cancelled_date']) + ); + } + + /** + * Test init registers the transition_membership_status hook. + */ + public function test_init_registers_transition_membership_status_hook(): void { - // Since we don't have a pending site in this test setup, - // we expect the method to handle gracefully - $this->assertNotInstanceOf(\WP_Error::class, $result); + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_transition_membership_status', [$manager, 'transition_membership_status']) + ); } /** - * Test async publish pending site with invalid membership ID. + * Test init registers the wu_async_membership_swap hook. */ - public function test_async_publish_pending_site_invalid_id() { - $result = $this->manager->async_publish_pending_site(99999); + public function test_init_registers_async_membership_swap_hook(): void { - $this->assertNull($result); + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_async_membership_swap', [$manager, 'async_membership_swap']) + ); } /** - * Test mark cancelled date functionality. + * Test init registers the wp_ajax_wu_publish_pending_site hook. */ - public function test_mark_cancelled_date() { - $membership = wu_create_membership( - [ - 'customer_id' => $this->customer->get_id(), - 'plan_id' => $this->product->get_id(), - 'status' => Membership_Status::ACTIVE, - 'amount' => 10, - 'currency' => 'USD', - ] + public function test_init_registers_publish_pending_site_ajax_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wp_ajax_wu_publish_pending_site', [$manager, 'publish_pending_site']) ); + } - $this->assertInstanceOf(Membership::class, $membership); + /** + * Test init registers the wp_ajax_wu_check_pending_site_created hook. + */ + public function test_init_registers_check_pending_site_created_ajax_hook(): void { - // Test status transition to cancelled - $old_status = Membership_Status::ACTIVE; - $new_status = Membership_Status::CANCELLED; + $manager = $this->get_manager_instance(); - // Mock the method call that would be triggered by status transition - $this->manager->mark_cancelled_date($old_status, $new_status, $membership->get_id()); + $this->assertIsInt( + has_action('wp_ajax_wu_check_pending_site_created', [$manager, 'check_pending_site_created']) + ); + } + + /** + * Test init registers the wu_async_publish_pending_site hook. + */ + public function test_init_registers_async_publish_pending_site_hook(): void { - // Refresh membership from database - $membership = wu_get_membership($membership->get_id()); + $manager = $this->get_manager_instance(); - // If status changed to cancelled, cancelled_at should be set - if (Membership_Status::CANCELLED === $new_status) { - $this->assertNotNull($membership->get_date_cancellation()); - } + $this->assertIsInt( + has_action('wu_async_publish_pending_site', [$manager, 'async_publish_pending_site']) + ); + } + + // ======================================================================== + // mark_cancelled_date() + // ======================================================================== + + /** + * Test mark_cancelled_date sets date_cancellation when transitioning to cancelled. + */ + public function test_mark_cancelled_date_sets_cancellation_date(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ACTIVE]); + $manager = $this->get_manager_instance(); + + $this->assertEmpty($membership->get_date_cancellation()); + + $manager->mark_cancelled_date( + Membership_Status::ACTIVE, + Membership_Status::CANCELLED, + $membership->get_id() + ); + + $refreshed = wu_get_membership($membership->get_id()); + + $this->assertNotEmpty($refreshed->get_date_cancellation()); } /** - * Test membership status transition. + * Test mark_cancelled_date does NOT set cancellation date when status is not cancelled. */ - public function test_transition_membership_status() { - $membership = wu_create_membership( - [ - 'customer_id' => $this->customer->get_id(), - 'plan_id' => $this->product->get_id(), - 'status' => Membership_Status::PENDING, - 'amount' => 10, - 'currency' => 'USD', - ] + public function test_mark_cancelled_date_does_not_set_for_non_cancelled(): void { + + $membership = $this->create_membership(['status' => Membership_Status::PENDING]); + $manager = $this->get_manager_instance(); + + $manager->mark_cancelled_date( + Membership_Status::PENDING, + Membership_Status::ACTIVE, + $membership->get_id() ); - $this->assertInstanceOf(Membership::class, $membership); + $refreshed = wu_get_membership($membership->get_id()); - $old_status = Membership_Status::PENDING; - $new_status = Membership_Status::ACTIVE; + $this->assertEmpty($refreshed->get_date_cancellation()); + } + + /** + * Test mark_cancelled_date from pending to cancelled. + */ + public function test_mark_cancelled_date_from_pending_to_cancelled(): void { - // Test transition method doesn't throw errors - $this->manager->transition_membership_status($old_status, $new_status, $membership->get_id()); + $membership = $this->create_membership(['status' => Membership_Status::PENDING]); + $manager = $this->get_manager_instance(); + + $manager->mark_cancelled_date( + Membership_Status::PENDING, + Membership_Status::CANCELLED, + $membership->get_id() + ); + + $refreshed = wu_get_membership($membership->get_id()); + + $this->assertNotEmpty($refreshed->get_date_cancellation()); + } + + /** + * Test mark_cancelled_date from on-hold to cancelled. + */ + public function test_mark_cancelled_date_from_on_hold_to_cancelled(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ON_HOLD]); + $manager = $this->get_manager_instance(); + + $manager->mark_cancelled_date( + Membership_Status::ON_HOLD, + Membership_Status::CANCELLED, + $membership->get_id() + ); + + $refreshed = wu_get_membership($membership->get_id()); + + $this->assertNotEmpty($refreshed->get_date_cancellation()); + } + + // ======================================================================== + // transition_membership_status() + // ======================================================================== + + /** + * Test transition from pending to active does not throw errors. + */ + public function test_transition_membership_status_pending_to_active(): void { + + $membership = $this->create_membership(['status' => Membership_Status::PENDING]); + $manager = $this->get_manager_instance(); + + $manager->transition_membership_status( + Membership_Status::PENDING, + Membership_Status::ACTIVE, + $membership->get_id() + ); + + // If we get here without errors, the method executed correctly. + $this->assertTrue(true); + } + + /** + * Test transition from pending to trialing does not throw errors. + */ + public function test_transition_membership_status_pending_to_trialing(): void { + + $membership = $this->create_membership(['status' => Membership_Status::PENDING]); + $manager = $this->get_manager_instance(); + + $manager->transition_membership_status( + Membership_Status::PENDING, + Membership_Status::TRIALING, + $membership->get_id() + ); + + $this->assertTrue(true); + } + + /** + * Test transition from on_hold to active does not throw errors. + */ + public function test_transition_membership_status_on_hold_to_active(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ON_HOLD]); + $manager = $this->get_manager_instance(); + + $manager->transition_membership_status( + Membership_Status::ON_HOLD, + Membership_Status::ACTIVE, + $membership->get_id() + ); - // This test mainly ensures the method executes without errors $this->assertTrue(true); } /** - * Test async transfer membership. + * Test transition from active to cancelled is a no-op (old_status not in allowed list). */ - public function test_async_transfer_membership() { - $this->markTestSkipped('Ill figure it out later'); - $membership = wu_create_membership( + public function test_transition_membership_status_active_to_cancelled_returns_early(): void { + + $membership = $this->create_membership(['status' => Membership_Status::ACTIVE]); + $manager = $this->get_manager_instance(); + + // This should return early because 'active' is not in allowed_previous_status. + $manager->transition_membership_status( + Membership_Status::ACTIVE, + Membership_Status::CANCELLED, + $membership->get_id() + ); + + $this->assertTrue(true); + } + + /** + * Test transition from pending to expired is a no-op (new_status not in allowed list). + */ + public function test_transition_membership_status_pending_to_expired_returns_early(): void { + + $membership = $this->create_membership(['status' => Membership_Status::PENDING]); + $manager = $this->get_manager_instance(); + + // This should return early because 'expired' is not in allowed_status. + $manager->transition_membership_status( + Membership_Status::PENDING, + Membership_Status::EXPIRED, + $membership->get_id() + ); + + $this->assertTrue(true); + } + + /** + * Test transition from pending to on_hold is a no-op (new_status not in allowed list). + */ + public function test_transition_membership_status_pending_to_on_hold_returns_early(): void { + + $membership = $this->create_membership(['status' => Membership_Status::PENDING]); + $manager = $this->get_manager_instance(); + + $manager->transition_membership_status( + Membership_Status::PENDING, + Membership_Status::ON_HOLD, + $membership->get_id() + ); + + $this->assertTrue(true); + } + + /** + * Test transition from expired to active is a no-op (old_status not in allowed list). + */ + public function test_transition_membership_status_expired_to_active_returns_early(): void { + + $membership = $this->create_membership(['status' => Membership_Status::EXPIRED]); + $manager = $this->get_manager_instance(); + + $manager->transition_membership_status( + Membership_Status::EXPIRED, + Membership_Status::ACTIVE, + $membership->get_id() + ); + + $this->assertTrue(true); + } + + // ======================================================================== + // async_publish_pending_site() + // ======================================================================== + + /** + * Test async publish pending site with valid membership ID. + */ + public function test_async_publish_pending_site_valid_membership(): void { + + $membership = $this->create_membership(); + $manager = $this->get_manager_instance(); + + $result = $manager->async_publish_pending_site($membership->get_id()); + + $this->assertNull($result); + } + + /** + * Test async publish pending site with invalid membership ID. + */ + public function test_async_publish_pending_site_invalid_id(): void { + + $manager = $this->get_manager_instance(); + + $result = $manager->async_publish_pending_site(99999); + + $this->assertNull($result); + } + + /** + * Test async publish pending site with zero membership ID. + */ + public function test_async_publish_pending_site_zero_id(): void { + + $manager = $this->get_manager_instance(); + + $result = $manager->async_publish_pending_site(0); + + $this->assertNull($result); + } + + // ======================================================================== + // async_membership_swap() + // ======================================================================== + + /** + * Test async membership swap with a membership that has no scheduled swap. + */ + public function test_async_membership_swap_no_scheduled_swap(): void { + + $membership = $this->create_membership(); + $manager = $this->get_manager_instance(); + + // Should return early because there is no scheduled swap. + $manager->async_membership_swap($membership->get_id()); + + $this->assertTrue(true); + } + + /** + * Test async membership swap with invalid membership ID. + */ + public function test_async_membership_swap_invalid_id(): void { + + $manager = $this->get_manager_instance(); + + $manager->async_membership_swap(99999); + + $this->assertTrue(true); + } + + // ======================================================================== + // async_transfer_membership() + // ======================================================================== + + /** + * Test async transfer membership to a new customer. + */ + public function test_async_transfer_membership_success(): void { + + $membership = $this->create_membership(); + $manager = $this->get_manager_instance(); + + $new_customer = wu_create_customer( [ - 'customer_id' => $this->customer->get_id(), - 'plan_id' => $this->product->get_id(), - 'status' => Membership_Status::ACTIVE, - 'amount' => 10, - 'currency' => 'USD', + 'username' => 'transfer_target_' . wp_rand(), + 'email' => 'transfer_target_' . wp_rand() . '@example.com', + 'password' => 'password123', ] ); - // Create another customer to transfer to + if (is_wp_error($new_customer)) { + $this->fail('Could not create target customer: ' . $new_customer->get_error_message()); + } + + $manager->async_transfer_membership($membership->get_id(), $new_customer->get_id()); + + $refreshed = wu_get_membership($membership->get_id()); + + $this->assertEquals($new_customer->get_id(), $refreshed->get_customer_id()); + + $new_customer->delete(); + } + + /** + * Test async transfer membership with invalid membership ID. + */ + public function test_async_transfer_membership_invalid_membership_id(): void { + + $manager = $this->get_manager_instance(); + $new_customer = wu_create_customer( [ - 'username' => 'newusere', - 'email' => 'newe@example.com', + 'username' => 'transfer_invalid_' . wp_rand(), + 'email' => 'transfer_invalid_' . wp_rand() . '@example.com', 'password' => 'password123', ] ); - $this->assertInstanceOf(Membership::class, $membership); - $this->assertInstanceOf(Customer::class, $new_customer, is_wp_error($new_customer) ? $new_customer->get_error_message() : ''); + if (is_wp_error($new_customer)) { + $this->fail('Could not create target customer: ' . $new_customer->get_error_message()); + } - // Test async transfer - $this->manager->async_transfer_membership($membership->get_id(), $new_customer->get_id()); + // Should return early without error. + $manager->async_transfer_membership(99999, $new_customer->get_id()); - // Method should execute without throwing errors $this->assertTrue(true); + + $new_customer->delete(); + } + + /** + * Test async transfer membership with invalid target customer ID. + */ + public function test_async_transfer_membership_invalid_customer_id(): void { + + $membership = $this->create_membership(); + $manager = $this->get_manager_instance(); + + // Should return early without error because target customer does not exist. + $manager->async_transfer_membership($membership->get_id(), 99999); + + // Membership should remain unchanged. + $refreshed = wu_get_membership($membership->get_id()); + $this->assertEquals($this->customer->get_id(), $refreshed->get_customer_id()); } + /** + * Test async transfer membership to same customer is a no-op. + */ + public function test_async_transfer_membership_same_customer_is_noop(): void { + + $membership = $this->create_membership(); + $manager = $this->get_manager_instance(); + + // Transferring to the same customer should be a no-op. + $manager->async_transfer_membership($membership->get_id(), $this->customer->get_id()); + + $refreshed = wu_get_membership($membership->get_id()); + $this->assertEquals($this->customer->get_id(), $refreshed->get_customer_id()); + } + + // ======================================================================== + // async_delete_membership() + // ======================================================================== + /** * Test async delete membership. */ - public function test_async_delete_membership() { - $this->markTestSkipped('Ill figure it out later'); - $membership = wu_create_membership( - [ - 'customer_id' => $this->customer->get_id(), - 'plan_id' => $this->product->get_id(), - 'status' => Membership_Status::ACTIVE, - 'amount' => 10, - 'currency' => 'USD', - ] - ); + public function test_async_delete_membership_success(): void { + + $membership = $this->create_membership(); + $manager = $this->get_manager_instance(); - $this->assertInstanceOf(Membership::class, $membership); $membership_id = $membership->get_id(); - // Test async delete - $this->manager->async_delete_membership($membership_id); + $manager->async_delete_membership($membership_id); + + $deleted = wu_get_membership($membership_id); - // Check if membership was deleted - $deleted_membership = wu_get_membership($membership_id); - $this->assertFalse($deleted_membership); + $this->assertEmpty($deleted); } /** - * Test async membership swap. + * Test async delete membership with invalid ID. */ - public function test_async_membership_swap() { - $membership = wu_create_membership( - [ - 'customer_id' => $this->customer->get_id(), - 'plan_id' => $this->product->get_id(), - 'status' => Membership_Status::ACTIVE, - 'amount' => 10, - 'currency' => 'USD', - ] + public function test_async_delete_membership_invalid_id(): void { + + $manager = $this->get_manager_instance(); + + // Should return early without error. + $manager->async_delete_membership(99999); + + $this->assertTrue(true); + } + + /** + * Test async delete membership with zero ID. + */ + public function test_async_delete_membership_zero_id(): void { + + $manager = $this->get_manager_instance(); + + $manager->async_delete_membership(0); + + $this->assertTrue(true); + } + + // ======================================================================== + // Integration: status transition fires action + // ======================================================================== + + /** + * Test that wu_transition_membership_status action fires when membership status changes. + */ + public function test_wu_transition_membership_status_action_fires(): void { + + $membership = $this->create_membership(['status' => Membership_Status::PENDING]); + + $fired = false; + + add_action( + 'wu_transition_membership_status', + function ($old_status, $new_status, $id) use (&$fired, $membership) { + if ($id === $membership->get_id()) { + $fired = true; + } + }, + 1, + 3 ); - $this->assertInstanceOf(Membership::class, $membership); + $membership->set_status(Membership_Status::ACTIVE); + $membership->save(); - // Test async swap - this mainly tests that method doesn't throw errors - $this->manager->async_membership_swap($membership->get_id()); + $this->assertTrue($fired); } + // ======================================================================== + // Edge cases + // ======================================================================== + /** - * Clean up after tests. + * Test LOG_FILE_NAME constant. */ - public function tearDown(): void { - // Clean up test data - if ($this->customer && ! is_wp_error($this->customer)) { - $this->customer->delete(); - } - if ($this->product && ! is_wp_error($this->product)) { - $this->product->delete(); - } + public function test_log_file_name_constant(): void { - parent::tearDown(); + $this->assertEquals('memberships', Membership_Manager::LOG_FILE_NAME); } } diff --git a/tests/WP_Ultimo/Managers/Site_Manager_Test.php b/tests/WP_Ultimo/Managers/Site_Manager_Test.php index 576b4ab0..ebe10879 100644 --- a/tests/WP_Ultimo/Managers/Site_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Site_Manager_Test.php @@ -154,4 +154,1827 @@ public function test_hide_super_admin_from_list(): void { // Restore. wp_set_current_user(0); } + + // ======================================================================== + // allow_hyphens_in_site_name – additional edge cases + // ======================================================================== + + /** + * Test allow_hyphens_in_site_name passes through names with only lowercase letters and numbers. + */ + public function test_allow_hyphens_allows_alphanumeric_only(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', __('Site names can only contain lowercase letters (a-z) and numbers.', 'ultimate-multisite')); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'mysite123', + 'errors' => $errors, + ] + ); + + $this->assertFalse( + $result['errors']->has_errors(), + 'Alphanumeric site name should be valid after removing the WP error.' + ); + } + + /** + * Test allow_hyphens_in_site_name does nothing when the relevant error is not present. + */ + public function test_allow_hyphens_ignores_unrelated_errors(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', 'Some other unrelated error.'); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'my-site', + 'errors' => $errors, + ] + ); + + $this->assertTrue( + $result['errors']->has_errors(), + 'Unrelated errors should remain untouched.' + ); + + $messages = $result['errors']->get_error_messages('blogname'); + $this->assertContains('Some other unrelated error.', $messages); + } + + /** + * Test allow_hyphens_in_site_name with no errors at all. + */ + public function test_allow_hyphens_with_no_errors(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'valid-name', + 'errors' => $errors, + ] + ); + + $this->assertFalse( + $result['errors']->has_errors(), + 'No errors should remain when none were present initially.' + ); + } + + /** + * Test allow_hyphens_in_site_name rejects uppercase characters. + */ + public function test_allow_hyphens_rejects_uppercase(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', __('Site names can only contain lowercase letters (a-z) and numbers.', 'ultimate-multisite')); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'MyUPPERSite', + 'errors' => $errors, + ] + ); + + $this->assertTrue( + $result['errors']->has_errors(), + 'Uppercase characters should be rejected.' + ); + } + + /** + * Test allow_hyphens_in_site_name with multiple hyphens. + */ + public function test_allow_hyphens_allows_multiple_hyphens(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', __('Site names can only contain lowercase letters (a-z) and numbers.', 'ultimate-multisite')); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'my-cool-site-123', + 'errors' => $errors, + ] + ); + + $this->assertFalse( + $result['errors']->has_errors(), + 'Multiple hyphens in site name should be valid.' + ); + } + + /** + * Test allow_hyphens_in_site_name preserves other blogname errors alongside the one it removes. + */ + public function test_allow_hyphens_preserves_other_blogname_errors(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', __('Site names can only contain lowercase letters (a-z) and numbers.', 'ultimate-multisite')); + $errors->add('blogname', 'Site name is too short.'); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'my-site', + 'errors' => $errors, + ] + ); + + // The WP letters-numbers error should be removed, but the "too short" error remains. + $messages = $result['errors']->get_error_messages('blogname'); + $this->assertContains('Site name is too short.', $messages); + } + + /** + * Test allow_hyphens_in_site_name rejects spaces. + */ + public function test_allow_hyphens_rejects_spaces(): void { + + $manager = $this->get_manager_instance(); + + $errors = new \WP_Error(); + $errors->add('blogname', __('Site names can only contain lowercase letters (a-z) and numbers.', 'ultimate-multisite')); + + $result = $manager->allow_hyphens_in_site_name( + [ + 'blogname' => 'my site', + 'errors' => $errors, + ] + ); + + $this->assertTrue( + $result['errors']->has_errors(), + 'Spaces in site name should be rejected.' + ); + } + + // ======================================================================== + // filter_illegal_search_keys – additional cases + // ======================================================================== + + /** + * Test filter_illegal_search_keys with all valid keys. + */ + public function test_filter_illegal_search_keys_all_valid(): void { + + $manager = $this->get_manager_instance(); + + $input = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]; + + $result = $manager->filter_illegal_search_keys($input); + + $this->assertCount(3, $result); + $this->assertEquals($input, $result); + } + + /** + * Test filter_illegal_search_keys with empty array. + */ + public function test_filter_illegal_search_keys_empty_array(): void { + + $manager = $this->get_manager_instance(); + + $result = $manager->filter_illegal_search_keys([]); + + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + /** + * Test filter_illegal_search_keys preserves values even when key looks odd but is valid. + */ + public function test_filter_illegal_search_keys_numeric_keys(): void { + + $manager = $this->get_manager_instance(); + + $input = [ + '0' => 'zero-string', + 1 => 'one-int', + 'key' => 'value', + ]; + + // Note: PHP treats '0' and 0 as the same array key, but both should be filtered as empty/false-like + // Actually, '0' is not empty for array_filter with ARRAY_FILTER_USE_KEY, but 0 and '' are + $result = $manager->filter_illegal_search_keys($input); + + // 'key' should remain, numeric keys depend on the filter behavior + $this->assertArrayHasKey('key', $result); + } + + // ======================================================================== + // get_search_and_replace_settings – additional cases + // ======================================================================== + + /** + * Test get_search_and_replace_settings with empty settings. + */ + public function test_get_search_and_replace_settings_empty(): void { + + wu_save_setting('search_and_replace', []); + + $manager = $this->get_manager_instance(); + $pairs = $manager->get_search_and_replace_settings(); + + $this->assertIsArray($pairs); + $this->assertEmpty($pairs); + } + + /** + * Test get_search_and_replace_settings skips items without search key. + */ + public function test_get_search_and_replace_settings_skips_missing_search(): void { + + wu_save_setting( + 'search_and_replace', + [ + ['replace' => 'bar'], + ['search' => 'hello', 'replace' => 'world'], + ] + ); + + $manager = $this->get_manager_instance(); + $pairs = $manager->get_search_and_replace_settings(); + + $this->assertCount(1, $pairs); + $this->assertEquals('world', $pairs['hello']); + } + + /** + * Test get_search_and_replace_settings allows empty replace value. + */ + public function test_get_search_and_replace_settings_allows_empty_replace(): void { + + wu_save_setting( + 'search_and_replace', + [ + ['search' => 'remove-me', 'replace' => ''], + ] + ); + + $manager = $this->get_manager_instance(); + $pairs = $manager->get_search_and_replace_settings(); + + $this->assertCount(1, $pairs); + $this->assertEquals('', $pairs['remove-me']); + } + + // ======================================================================== + // search_and_replace_on_duplication + // ======================================================================== + + /** + * Test search_and_replace_on_duplication merges settings with incoming pairs. + */ + public function test_search_and_replace_on_duplication_merges(): void { + + wu_save_setting( + 'search_and_replace', + [ + ['search' => 'old-domain', 'replace' => 'new-domain'], + ] + ); + + $manager = $this->get_manager_instance(); + + $incoming = [ + 'existing-key' => 'existing-value', + ]; + + $result = $manager->search_and_replace_on_duplication($incoming, 1, 2); + + $this->assertArrayHasKey('existing-key', $result); + $this->assertArrayHasKey('old-domain', $result); + $this->assertEquals('new-domain', $result['old-domain']); + $this->assertEquals('existing-value', $result['existing-key']); + } + + /** + * Test search_and_replace_on_duplication filters out illegal keys from merged result. + */ + public function test_search_and_replace_on_duplication_filters_illegal(): void { + + wu_save_setting('search_and_replace', []); + + $manager = $this->get_manager_instance(); + + $incoming = [ + '' => 'should-be-removed', + 'valid-key' => 'valid-value', + ]; + + $result = $manager->search_and_replace_on_duplication($incoming, 1, 2); + + $this->assertArrayNotHasKey('', $result); + $this->assertArrayHasKey('valid-key', $result); + } + + /** + * Test search_and_replace_on_duplication applies the wu_search_and_replace_on_duplication filter. + */ + public function test_search_and_replace_on_duplication_applies_filter(): void { + + wu_save_setting('search_and_replace', []); + + add_filter('wu_search_and_replace_on_duplication', function ($settings, $from, $to) { + $settings['filter-key'] = 'filter-value'; + return $settings; + }, 10, 3); + + $manager = $this->get_manager_instance(); + + $result = $manager->search_and_replace_on_duplication([], 1, 2); + + $this->assertArrayHasKey('filter-key', $result); + $this->assertEquals('filter-value', $result['filter-key']); + + remove_all_filters('wu_search_and_replace_on_duplication'); + } + + // ======================================================================== + // hide_super_admin_from_list – additional cases + // ======================================================================== + + /** + * Test hide_super_admin_from_list does not modify args for super admins. + */ + public function test_hide_super_admin_from_list_as_super_admin(): void { + + $manager = $this->get_manager_instance(); + + // Set current user to a super admin. + $user_id = $this->factory()->user->create(['role' => 'administrator']); + grant_super_admin($user_id); + wp_set_current_user($user_id); + + $args = ['existing_key' => 'existing_value']; + $result = $manager->hide_super_admin_from_list($args); + + $this->assertArrayNotHasKey('login__not_in', $result); + $this->assertArrayHasKey('existing_key', $result); + + // Clean up. + revoke_super_admin($user_id); + wp_set_current_user(0); + } + + /** + * Test hide_super_admin_from_list preserves existing args for non-super-admins. + */ + public function test_hide_super_admin_preserves_existing_args(): void { + + $manager = $this->get_manager_instance(); + + $user_id = $this->factory()->user->create(['role' => 'subscriber']); + wp_set_current_user($user_id); + + $args = ['role' => 'editor', 'number' => 10]; + $result = $manager->hide_super_admin_from_list($args); + + $this->assertArrayHasKey('login__not_in', $result); + $this->assertEquals('editor', $result['role']); + $this->assertEquals(10, $result['number']); + + wp_set_current_user(0); + } + + // ======================================================================== + // login_header_url / login_header_text – additional cases + // ======================================================================== + + /** + * Test login_header_url returns a string URL. + */ + public function test_login_header_url_is_string(): void { + + $manager = $this->get_manager_instance(); + + $url = $manager->login_header_url(); + + $this->assertIsString($url); + $this->assertNotEmpty($url); + } + + /** + * Test login_header_text returns a non-empty string. + */ + public function test_login_header_text_is_string(): void { + + $manager = $this->get_manager_instance(); + + $text = $manager->login_header_text(); + + $this->assertIsString($text); + } + + // ======================================================================== + // init – verify hooks are registered + // ======================================================================== + + /** + * Test init registers the after_setup_theme hook. + */ + public function test_init_registers_additional_thumbnail_sizes_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('after_setup_theme', [$manager, 'additional_thumbnail_sizes']) + ); + } + + /** + * Test init registers the lock_site hook. + */ + public function test_init_registers_lock_site_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wp', [$manager, 'lock_site']) + ); + } + + /** + * Test init registers the admin_init hook for no-index warning. + */ + public function test_init_registers_add_no_index_warning_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('admin_init', [$manager, 'add_no_index_warning']) + ); + } + + /** + * Test init registers the wp_head hook for preventing template indexing. + */ + public function test_init_registers_prevent_site_template_indexing_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wp_head', [$manager, 'prevent_site_template_indexing']) + ); + } + + /** + * Test init registers the login_enqueue_scripts hook for custom login logo. + */ + public function test_init_registers_custom_login_logo_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('login_enqueue_scripts', [$manager, 'custom_login_logo']) + ); + } + + /** + * Test init registers the login_headerurl filter. + */ + public function test_init_registers_login_header_url_filter(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_filter('login_headerurl', [$manager, 'login_header_url']) + ); + } + + /** + * Test init registers the login_headertext filter. + */ + public function test_init_registers_login_header_text_filter(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_filter('login_headertext', [$manager, 'login_header_text']) + ); + } + + /** + * Test init registers the wu_pending_site_published hook. + */ + public function test_init_registers_handle_site_published_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_pending_site_published', [$manager, 'handle_site_published']) + ); + } + + /** + * Test init registers the mucd_string_to_replace filter. + */ + public function test_init_registers_search_and_replace_on_duplication_filter(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_filter('mucd_string_to_replace', [$manager, 'search_and_replace_on_duplication']) + ); + } + + /** + * Test init registers the wu_site_created action. + */ + public function test_init_registers_search_and_replace_for_new_site_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_site_created', [$manager, 'search_and_replace_for_new_site']) + ); + } + + /** + * Test init registers the users_list_table_query_args filter. + */ + public function test_init_registers_hide_super_admin_from_list_filter(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_filter('users_list_table_query_args', [$manager, 'hide_super_admin_from_list']) + ); + } + + /** + * Test init registers the wpmu_validate_blog_signup filter. + */ + public function test_init_registers_allow_hyphens_in_site_name_filter(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_filter('wpmu_validate_blog_signup', [$manager, 'allow_hyphens_in_site_name']) + ); + } + + /** + * Test init registers the wu_daily action for delete_pending_sites. + */ + public function test_init_registers_delete_pending_sites_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_daily', [$manager, 'delete_pending_sites']) + ); + } + + /** + * Test init registers the pre_get_blogs_of_user filter. + */ + public function test_init_registers_hide_customer_sites_from_super_admin_list_filter(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_filter('pre_get_blogs_of_user', [$manager, 'hide_customer_sites_from_super_admin_list']) + ); + } + + /** + * Test init registers wu_before_handle_order_submission action. + */ + public function test_init_registers_maybe_validate_add_new_site_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_before_handle_order_submission', [$manager, 'maybe_validate_add_new_site']) + ); + } + + /** + * Test init registers wu_checkout_before_process_checkout action. + */ + public function test_init_registers_maybe_add_new_site_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_checkout_before_process_checkout', [$manager, 'maybe_add_new_site']) + ); + } + + /** + * Test init registers wu_async_take_screenshot action. + */ + public function test_init_registers_async_get_site_screenshot_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wu_async_take_screenshot', [$manager, 'async_get_site_screenshot']) + ); + } + + /** + * Test init registers wp_ajax_wu_get_screenshot action. + */ + public function test_init_registers_get_site_screenshot_ajax_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('wp_ajax_wu_get_screenshot', [$manager, 'get_site_screenshot']) + ); + } + + /** + * Test init registers load-sites.php action. + */ + public function test_init_registers_add_notices_to_default_site_page_hook(): void { + + $manager = $this->get_manager_instance(); + + $this->assertIsInt( + has_action('load-sites.php', [$manager, 'add_notices_to_default_site_page']) + ); + } + + // ======================================================================== + // Site creation via wu_create_site + // ======================================================================== + + /** + * Test wu_create_site creates a basic site. + */ + public function test_wu_create_site_creates_basic_site(): void { + + $site = wu_create_site( + [ + 'title' => 'Basic Test Site', + 'domain' => 'basic-test.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + $this->assertInstanceOf(\WP_Ultimo\Models\Site::class, $site); + $this->assertNotEmpty($site->get_id()); + $this->assertEquals('Basic Test Site', $site->get_title()); + } + + /** + * Test wu_create_site with a template ID. + */ + public function test_wu_create_site_with_template_id(): void { + + // Create a template site first. + $template = wu_create_site( + [ + 'title' => 'Template Site', + 'domain' => 'template-for-test.example.com', + 'path' => '/', + 'type' => 'site_template', + ] + ); + + $this->assertNotWPError($template); + + // Now create a site from this template. + $site = wu_create_site( + [ + 'title' => 'Site From Template', + 'domain' => 'from-template.example.com', + 'path' => '/', + 'template_id' => $template->get_id(), + ] + ); + + $this->assertNotWPError($site); + $this->assertNotEmpty($site->get_id()); + } + + // ======================================================================== + // Site type management + // ======================================================================== + + /** + * Test site type can be set and retrieved. + */ + public function test_site_type_set_and_get(): void { + + $site = wu_create_site( + [ + 'title' => 'Typed Site', + 'domain' => 'typed-site.example.com', + 'path' => '/', + 'type' => \WP_Ultimo\Database\Sites\Site_Type::CUSTOMER_OWNED, + ] + ); + + $this->assertNotWPError($site); + $this->assertEquals(\WP_Ultimo\Database\Sites\Site_Type::CUSTOMER_OWNED, $site->get_type()); + } + + /** + * Test site_template type can be set. + */ + public function test_site_template_type(): void { + + $site = wu_create_site( + [ + 'title' => 'Template Type Site', + 'domain' => 'template-type.example.com', + 'path' => '/', + 'type' => \WP_Ultimo\Database\Sites\Site_Type::SITE_TEMPLATE, + ] + ); + + $this->assertNotWPError($site); + $this->assertEquals(\WP_Ultimo\Database\Sites\Site_Type::SITE_TEMPLATE, $site->get_type()); + } + + /** + * Test main site returns 'main' type. + */ + public function test_main_site_returns_main_type(): void { + + $main_site_id = wu_get_main_site_id(); + $site = wu_get_current_site(); + + // On the main site, get_type() should return 'main' + if ($site->get_id() === $main_site_id) { + $this->assertEquals('main', $site->get_type()); + } else { + $this->assertTrue(true, 'Current site is not main; skipping.'); + } + } + + // ======================================================================== + // get_all_by_type – template sites and customer-owned sites + // ======================================================================== + + /** + * Test get_all_by_type returns site_template sites. + */ + public function test_get_all_by_type_returns_templates(): void { + + $site = wu_create_site( + [ + 'title' => 'Template for Query', + 'domain' => 'template-query.example.com', + 'path' => '/', + 'type' => \WP_Ultimo\Database\Sites\Site_Type::SITE_TEMPLATE, + ] + ); + + $this->assertNotWPError($site); + + $templates = \WP_Ultimo\Models\Site::get_all_by_type('site_template'); + + $this->assertIsArray($templates); + + $found = false; + foreach ($templates as $t) { + if ($t->get_id() === $site->get_id()) { + $found = true; + break; + } + } + + $this->assertTrue($found, 'The created template site should appear in get_all_by_type results.'); + } + + /** + * Test get_all_by_type returns customer_owned sites. + */ + public function test_get_all_by_type_returns_customer_owned(): void { + + $site = wu_create_site( + [ + 'title' => 'Customer Owned for Query', + 'domain' => 'customer-query.example.com', + 'path' => '/', + 'type' => \WP_Ultimo\Database\Sites\Site_Type::CUSTOMER_OWNED, + ] + ); + + $this->assertNotWPError($site); + + $customer_sites = \WP_Ultimo\Models\Site::get_all_by_type('customer_owned'); + + $this->assertIsArray($customer_sites); + + $found = false; + foreach ($customer_sites as $cs) { + if ($cs->get_id() === $site->get_id()) { + $found = true; + break; + } + } + + $this->assertTrue($found, 'The created customer_owned site should appear in get_all_by_type results.'); + } + + /** + * Test wu_get_site_templates helper function. + */ + public function test_wu_get_site_templates(): void { + + $site = wu_create_site( + [ + 'title' => 'Template Helper Test', + 'domain' => 'template-helper.example.com', + 'path' => '/', + 'type' => \WP_Ultimo\Database\Sites\Site_Type::SITE_TEMPLATE, + ] + ); + + $this->assertNotWPError($site); + + $templates = wu_get_site_templates(); + + $this->assertIsArray($templates); + + $ids = array_map(fn($t) => $t->get_id(), $templates); + $this->assertContains($site->get_id(), $ids); + } + + // ======================================================================== + // additional_thumbnail_sizes + // ======================================================================== + + /** + * Test additional_thumbnail_sizes registers image sizes on main site. + */ + public function test_additional_thumbnail_sizes_on_main_site(): void { + + $manager = $this->get_manager_instance(); + + // We should be on the main site in tests. + if (is_main_site()) { + $manager->additional_thumbnail_sizes(); + + global $_wp_additional_image_sizes; + + $this->assertArrayHasKey('wu-thumb-large', $_wp_additional_image_sizes); + $this->assertArrayHasKey('wu-thumb-medium', $_wp_additional_image_sizes); + $this->assertEquals(900, $_wp_additional_image_sizes['wu-thumb-large']['width']); + $this->assertEquals(675, $_wp_additional_image_sizes['wu-thumb-large']['height']); + $this->assertEquals(400, $_wp_additional_image_sizes['wu-thumb-medium']['width']); + $this->assertEquals(300, $_wp_additional_image_sizes['wu-thumb-medium']['height']); + } else { + $this->assertTrue(true, 'Not on main site; skipping.'); + } + } + + // ======================================================================== + // prevent_site_template_indexing + // ======================================================================== + + /** + * Test prevent_site_template_indexing does nothing when setting is disabled. + */ + public function test_prevent_site_template_indexing_disabled(): void { + + wu_save_setting('stop_template_indexing', false); + + $manager = $this->get_manager_instance(); + + // Should not add the wp_robots filter. + $priority_before = has_filter('wp_robots', 'wp_robots_no_robots'); + + $manager->prevent_site_template_indexing(); + + $priority_after = has_filter('wp_robots', 'wp_robots_no_robots'); + + // Priority shouldn't change if setting is disabled. + $this->assertEquals($priority_before, $priority_after); + } + + // ======================================================================== + // add_no_index_warning + // ======================================================================== + + /** + * Test add_no_index_warning does nothing when setting is disabled. + */ + public function test_add_no_index_warning_disabled(): void { + + wu_save_setting('stop_template_indexing', false); + + $manager = $this->get_manager_instance(); + $manager->add_no_index_warning(); + + // Should not have added the meta box. + // We can check there is no error/exception. + $this->assertTrue(true, 'No exception thrown when stop_template_indexing is false.'); + } + + /** + * Test add_no_index_warning adds meta box when setting is enabled. + */ + public function test_add_no_index_warning_enabled(): void { + + wu_save_setting('stop_template_indexing', true); + + $manager = $this->get_manager_instance(); + $manager->add_no_index_warning(); + + // Check the meta box was registered. + global $wp_meta_boxes; + + $found = isset($wp_meta_boxes['dashboard-network']['normal']['high']['wu-warnings']); + + $this->assertTrue($found, 'Meta box wu-warnings should be registered when stop_template_indexing is true.'); + } + + // ======================================================================== + // async_get_site_screenshot + // ======================================================================== + + /** + * Test async_get_site_screenshot returns early for non-existing site. + */ + public function test_async_get_site_screenshot_returns_for_missing_site(): void { + + $manager = $this->get_manager_instance(); + + // Calling with a non-existing site ID should return early without errors. + $manager->async_get_site_screenshot(999999); + + $this->assertTrue(true, 'No exception thrown for missing site.'); + } + + // ======================================================================== + // Site model – edge cases + // ======================================================================== + + /** + * Test site exists() returns false for new unsaved site. + */ + public function test_site_exists_returns_false_for_new_site(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $this->assertFalse($site->exists()); + } + + /** + * Test site exists() returns true for saved site. + */ + public function test_site_exists_returns_true_for_saved_site(): void { + + $site = wu_create_site( + [ + 'title' => 'Exists Test Site', + 'domain' => 'exists-test.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + $this->assertTrue($site->exists()); + } + + /** + * Test site get_id returns integer. + */ + public function test_site_get_id_returns_integer(): void { + + $site = wu_create_site( + [ + 'title' => 'ID Type Test Site', + 'domain' => 'id-type-test.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + $this->assertIsInt($site->get_id()); + } + + /** + * Test wu_get_site returns correct site. + */ + public function test_wu_get_site_returns_correct_site(): void { + + $site = wu_create_site( + [ + 'title' => 'Get Site Test', + 'domain' => 'get-site-test.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $fetched = wu_get_site($site->get_id()); + + $this->assertNotFalse($fetched); + $this->assertEquals($site->get_id(), $fetched->get_id()); + } + + /** + * Test wu_get_site returns false for non-existing ID. + */ + public function test_wu_get_site_returns_false_for_missing(): void { + + $result = wu_get_site(999999); + + $this->assertFalse($result); + } + + // ======================================================================== + // Site type labels + // ======================================================================== + + /** + * Test Site_Type constants are defined correctly. + */ + public function test_site_type_constants(): void { + + $this->assertEquals('default', \WP_Ultimo\Database\Sites\Site_Type::REGULAR); + $this->assertEquals('site_template', \WP_Ultimo\Database\Sites\Site_Type::SITE_TEMPLATE); + $this->assertEquals('customer_owned', \WP_Ultimo\Database\Sites\Site_Type::CUSTOMER_OWNED); + $this->assertEquals('pending', \WP_Ultimo\Database\Sites\Site_Type::PENDING); + $this->assertEquals('external', \WP_Ultimo\Database\Sites\Site_Type::EXTERNAL); + $this->assertEquals('main', \WP_Ultimo\Database\Sites\Site_Type::MAIN); + } + + /** + * Test site get_type_label returns a non-empty string. + */ + public function test_site_get_type_label(): void { + + $site = wu_create_site( + [ + 'title' => 'Type Label Site', + 'domain' => 'type-label.example.com', + 'path' => '/', + 'type' => \WP_Ultimo\Database\Sites\Site_Type::SITE_TEMPLATE, + ] + ); + + $this->assertNotWPError($site); + + $label = $site->get_type_label(); + + $this->assertIsString($label); + $this->assertNotEmpty($label); + } + + // ======================================================================== + // Site model – duplication arguments + // ======================================================================== + + /** + * Test get_duplication_arguments returns defaults. + */ + public function test_get_duplication_arguments_defaults(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $args = $site->get_duplication_arguments(); + + $this->assertIsArray($args); + $this->assertTrue($args['keep_users']); + $this->assertTrue($args['copy_files']); + $this->assertTrue($args['public']); + } + + /** + * Test set_duplication_arguments overrides defaults. + */ + public function test_set_duplication_arguments_overrides(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_duplication_arguments(['keep_users' => false]); + + $args = $site->get_duplication_arguments(); + + $this->assertFalse($args['keep_users']); + $this->assertTrue($args['copy_files']); + $this->assertTrue($args['public']); + } + + // ======================================================================== + // Site model – categories + // ======================================================================== + + /** + * Test site categories can be set and retrieved. + */ + public function test_site_categories_set_and_get(): void { + + $site = wu_create_site( + [ + 'title' => 'Category Site', + 'domain' => 'category-test.example.com', + 'path' => '/', + 'type' => \WP_Ultimo\Database\Sites\Site_Type::SITE_TEMPLATE, + ] + ); + + $this->assertNotWPError($site); + + $site->set_categories(['blog', 'portfolio']); + $site->save(); + + $fetched = wu_get_site($site->get_id()); + $cats = $fetched->get_categories(); + + $this->assertIsArray($cats); + $this->assertContains('blog', $cats); + $this->assertContains('portfolio', $cats); + } + + /** + * Test get_categories returns empty array for new site. + */ + public function test_site_categories_empty_by_default(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $cats = $site->get_categories(); + + $this->assertIsArray($cats); + $this->assertEmpty($cats); + } + + // ======================================================================== + // Site model – active / inactive + // ======================================================================== + + /** + * Test site active status can be set and retrieved. + */ + public function test_site_active_status(): void { + + $site = wu_create_site( + [ + 'title' => 'Active Status Site', + 'domain' => 'active-status.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $site->set_active(true); + $site->save(); + + $fetched = wu_get_site($site->get_id()); + + $this->assertTrue((bool) $fetched->is_active()); + } + + /** + * Test site can be set to inactive. + */ + public function test_site_inactive_status(): void { + + $site = wu_create_site( + [ + 'title' => 'Inactive Status Site', + 'domain' => 'inactive-status.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $site->set_active(false); + $site->save(); + + $fetched = wu_get_site($site->get_id()); + + $this->assertFalse((bool) $fetched->is_active()); + } + + // ======================================================================== + // Site model – featured image + // ======================================================================== + + /** + * Test featured_image_id can be set and retrieved. + */ + public function test_site_featured_image_id(): void { + + $site = wu_create_site( + [ + 'title' => 'Featured Image Site', + 'domain' => 'featured-image.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $site->set_featured_image_id(42); + $site->save(); + + $fetched = wu_get_site($site->get_id()); + + $this->assertEquals(42, $fetched->get_featured_image_id()); + } + + // ======================================================================== + // Site model – template ID + // ======================================================================== + + /** + * Test template_id can be set and retrieved. + */ + public function test_site_template_id_set_and_get(): void { + + $site = wu_create_site( + [ + 'title' => 'Template ID Site', + 'domain' => 'template-id.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $site->set_template_id(99); + $site->save(); + + $fetched = wu_get_site($site->get_id()); + + $this->assertEquals(99, $fetched->get_template_id()); + } + + // ======================================================================== + // Site model – publishing status + // ======================================================================== + + /** + * Test is_publishing can be set and retrieved. + */ + public function test_site_is_publishing(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_publishing(true); + + $this->assertTrue((bool) $site->is_publishing()); + + $site->set_publishing(false); + + $this->assertFalse((bool) $site->is_publishing()); + } + + // ======================================================================== + // Site model – signup options / meta + // ======================================================================== + + /** + * Test signup_options can be set and retrieved. + */ + public function test_site_signup_options(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $options = ['option1' => 'value1', 'option2' => 'value2']; + + $site->set_signup_options($options); + + $this->assertEquals($options, $site->get_signup_options()); + } + + /** + * Test signup_options returns empty array when not set. + */ + public function test_site_signup_options_empty_by_default(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $this->assertIsArray($site->get_signup_options()); + $this->assertEmpty($site->get_signup_options()); + } + + /** + * Test signup_meta can be set and retrieved. + */ + public function test_site_signup_meta(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $meta = ['meta1' => 'val1']; + + $site->set_signup_meta($meta); + + $this->assertEquals($meta, $site->get_signup_meta()); + } + + /** + * Test signup_meta returns empty array when not set. + */ + public function test_site_signup_meta_empty_by_default(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $this->assertIsArray($site->get_signup_meta()); + $this->assertEmpty($site->get_signup_meta()); + } + + // ======================================================================== + // Site model – description + // ======================================================================== + + /** + * Test site description can be set and retrieved. + */ + public function test_site_description(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_description('A test site description'); + + $this->assertEquals('A test site description', $site->get_description()); + } + + // ======================================================================== + // Site model – domain and path + // ======================================================================== + + /** + * Test site domain can be set and retrieved. + */ + public function test_site_domain_set_and_get(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_domain('my-domain.example.com'); + + $this->assertEquals('my-domain.example.com', $site->get_domain()); + } + + /** + * Test site path can be set and retrieved. + */ + public function test_site_path_set_and_get(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_path('/my-path/'); + + $this->assertEquals('/my-path/', $site->get_path()); + } + + // ======================================================================== + // Site model – archived / mature / spam / deleted + // ======================================================================== + + /** + * Test site archived status. + */ + public function test_site_archived_status(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $site->set_archived(true); + $this->assertTrue((bool) $site->is_archived()); + + $site->set_archived(false); + $this->assertFalse((bool) $site->is_archived()); + } + + /** + * Test site mature status. + */ + public function test_site_mature_status(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $site->set_mature(true); + $this->assertTrue((bool) $site->is_mature()); + + $site->set_mature(false); + $this->assertFalse((bool) $site->is_mature()); + } + + /** + * Test site spam status. + */ + public function test_site_spam_status(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $site->set_spam(true); + $this->assertTrue((bool) $site->is_spam()); + + $site->set_spam(false); + $this->assertFalse((bool) $site->is_spam()); + } + + /** + * Test site deleted status. + */ + public function test_site_deleted_status(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $site->set_deleted(true); + $this->assertTrue((bool) $site->is_deleted()); + + $site->set_deleted(false); + $this->assertFalse((bool) $site->is_deleted()); + } + + // ======================================================================== + // Site model – public status + // ======================================================================== + + /** + * Test site public status. + */ + public function test_site_public_status(): void { + + $site = new \WP_Ultimo\Models\Site(); + + // Default is true + $this->assertTrue((bool) $site->get_public()); + + $site->set_public(false); + $this->assertFalse((bool) $site->get_public()); + + $site->set_public(true); + $this->assertTrue((bool) $site->get_public()); + } + + // ======================================================================== + // Site model – lang_id + // ======================================================================== + + /** + * Test site lang_id can be set and retrieved. + */ + public function test_site_lang_id(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $site->set_lang_id(5); + $this->assertEquals(5, $site->get_lang_id()); + } + + // ======================================================================== + // Site model – name alias + // ======================================================================== + + /** + * Test get_name is an alias for get_title. + */ + public function test_site_get_name_alias(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_title('Alias Test'); + + $this->assertEquals($site->get_title(), $site->get_name()); + } + + /** + * Test set_name is an alias for set_title. + */ + public function test_site_set_name_alias(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_name('Name Alias'); + + $this->assertEquals('Name Alias', $site->get_title()); + } + + // ======================================================================== + // Site model – network/site_id + // ======================================================================== + + /** + * Test site_id (network_id) can be set and retrieved. + */ + public function test_site_network_id(): void { + + $site = new \WP_Ultimo\Models\Site(); + + // Default is 1 + $this->assertEquals(1, $site->get_site_id()); + + $site->set_site_id(2); + $this->assertEquals(2, $site->get_site_id()); + } + + // ======================================================================== + // Site model – to_wp_site + // ======================================================================== + + /** + * Test to_wp_site returns WP_Site for existing site. + */ + public function test_site_to_wp_site(): void { + + $site = wu_create_site( + [ + 'title' => 'WP Site Conversion', + 'domain' => 'wp-site-conversion.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $wp_site = $site->to_wp_site(); + + $this->assertInstanceOf(\WP_Site::class, $wp_site); + $this->assertEquals($site->get_id(), (int) $wp_site->blog_id); + } + + // ======================================================================== + // Site model – date fields + // ======================================================================== + + /** + * Test site registered date can be set and retrieved. + */ + public function test_site_registered_date(): void { + + $site = new \WP_Ultimo\Models\Site(); + $date = '2024-01-15 10:30:00'; + + $site->set_registered($date); + + $this->assertEquals($date, $site->get_registered()); + $this->assertEquals($date, $site->get_date_registered()); + } + + /** + * Test site last_updated can be set and retrieved. + */ + public function test_site_last_updated_date(): void { + + $site = new \WP_Ultimo\Models\Site(); + $date = '2024-06-20 15:00:00'; + + $site->set_last_updated($date); + + $this->assertEquals($date, $site->get_last_updated()); + $this->assertEquals($date, $site->get_date_modified()); + } + + // ======================================================================== + // Site model – customer_id + // ======================================================================== + + /** + * Test customer_id can be set and retrieved. + */ + public function test_site_customer_id(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $site->set_customer_id(42); + + $this->assertEquals(42, $site->get_customer_id()); + } + + // ======================================================================== + // Site model – membership_id + // ======================================================================== + + /** + * Test membership_id can be set and retrieved. + */ + public function test_site_membership_id(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $site->set_membership_id(10); + + $this->assertEquals(10, $site->get_membership_id()); + } + + // ======================================================================== + // Site model – transient + // ======================================================================== + + /** + * Test transient data can be set and retrieved. + */ + public function test_site_transient(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $data = ['key1' => 'value1', 'key2' => 'value2']; + + $site->set_transient($data); + + $this->assertEquals($data, $site->get_transient()); + } + + // ======================================================================== + // Site helper functions + // ======================================================================== + + /** + * Test wu_get_current_site returns a site object. + */ + public function test_wu_get_current_site(): void { + + $site = wu_get_current_site(); + + $this->assertInstanceOf(\WP_Ultimo\Models\Site::class, $site); + $this->assertNotEmpty($site->get_id()); + } + + /** + * Test wu_get_sites returns an array. + */ + public function test_wu_get_sites_returns_array(): void { + + $sites = wu_get_sites(); + + $this->assertIsArray($sites); + } + + /** + * Test wu_get_site_domain_and_path returns object with domain and path. + */ + public function test_wu_get_site_domain_and_path(): void { + + $d = wu_get_site_domain_and_path('testpath'); + + $this->assertIsObject($d); + $this->assertObjectHasProperty('domain', $d); + $this->assertObjectHasProperty('path', $d); + } + + /** + * Test wu_handle_site_domain parses domain correctly. + */ + public function test_wu_handle_site_domain(): void { + + $result = wu_handle_site_domain('https://example.com/path'); + + $this->assertIsObject($result); + $this->assertEquals('example.com', $result->host); + $this->assertEquals('/path', $result->path); + } + + /** + * Test wu_handle_site_domain adds https when missing. + */ + public function test_wu_handle_site_domain_adds_scheme(): void { + + $result = wu_handle_site_domain('example.com'); + + $this->assertIsObject($result); + $this->assertEquals('example.com', $result->host); + } + + // ======================================================================== + // Site model – delete + // ======================================================================== + + /** + * Test site delete method returns error for unsaved site. + */ + public function test_site_delete_unsaved_returns_error(): void { + + $site = new \WP_Ultimo\Models\Site(); + + $result = $site->delete(); + + $this->assertWPError($result); + } + + /** + * Test site delete method removes the site. + */ + public function test_site_delete_removes_site(): void { + + $site = wu_create_site( + [ + 'title' => 'Deletable Site', + 'domain' => 'deletable-site.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $site_id = $site->get_id(); + $result = $site->delete(); + + $this->assertTrue($result); + + // The site should no longer exist. + $fetched = wu_get_site($site_id); + $this->assertFalse($fetched); + } + + // ======================================================================== + // Site model – site URL + // ======================================================================== + + /** + * Test get_site_url returns a URL string. + */ + public function test_site_get_site_url(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_domain('example.com'); + $site->set_path('/mysite/'); + + $url = $site->get_site_url(); + + $this->assertIsString($url); + $this->assertStringContainsString('example.com', $url); + } + + /** + * Test get_active_site_url returns URL for site without ID. + */ + public function test_site_get_active_site_url_without_id(): void { + + $site = new \WP_Ultimo\Models\Site(); + $site->set_domain('no-id.example.com'); + $site->set_path('/'); + + $url = $site->get_active_site_url(); + + $this->assertIsString($url); + $this->assertStringContainsString('no-id.example.com', $url); + } + + // ======================================================================== + // Site model – to_search_results + // ======================================================================== + + /** + * Test to_search_results returns array with siteurl. + */ + public function test_site_to_search_results(): void { + + $site = wu_create_site( + [ + 'title' => 'Search Results Site', + 'domain' => 'search-results.example.com', + 'path' => '/', + ] + ); + + $this->assertNotWPError($site); + + $results = $site->to_search_results(); + + $this->assertIsArray($results); + $this->assertArrayHasKey('siteurl', $results); + } + + // ======================================================================== + // Site URL generation helpers + // ======================================================================== + + /** + * Test wu_generate_site_url_from_title generates a URL-safe slug. + */ + public function test_wu_generate_site_url_from_title(): void { + + $slug = wu_generate_site_url_from_title('My Cool Site'); + + $this->assertIsString($slug); + $this->assertEquals('mycoolsite', $slug); + } + + /** + * Test wu_generate_site_url_from_title with empty string. + */ + public function test_wu_generate_site_url_from_title_empty(): void { + + $slug = wu_generate_site_url_from_title(''); + + $this->assertEmpty($slug); + } + + /** + * Test wu_generate_site_url_from_title prepends site when starting with number. + */ + public function test_wu_generate_site_url_from_title_numeric_start(): void { + + $slug = wu_generate_site_url_from_title('123test'); + + $this->assertStringStartsWith('site', $slug); + } } diff --git a/tests/WP_Ultimo/Models/Checkout_Form_Test.php b/tests/WP_Ultimo/Models/Checkout_Form_Test.php index 8869193b..7936613b 100644 --- a/tests/WP_Ultimo/Models/Checkout_Form_Test.php +++ b/tests/WP_Ultimo/Models/Checkout_Form_Test.php @@ -534,4 +534,1665 @@ public function test_steps_to_show_functionality(): void { $steps_to_show = $checkout_form->get_steps_to_show(); $this->assertIsArray($steps_to_show); } + + /** + * Test get_steps_to_show filters out logged_only steps for guests. + */ + public function test_get_steps_to_show_filters_logged_only_for_guests(): void { + // Ensure no user is logged in. + wp_set_current_user(0); + + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'always_step', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + ], + ], + ], + [ + 'id' => 'logged_only_step', + 'logged' => 'logged_only', + 'fields' => [ + [ + 'id' => 'profile_name', + 'type' => 'text', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $steps_to_show = $checkout_form->get_steps_to_show(); + + // logged_only step should be filtered out since no user is logged in + $step_ids = array_column($steps_to_show, 'id'); + $this->assertContains('always_step', $step_ids); + $this->assertNotContains('logged_only_step', $step_ids); + } + + /** + * Test get_steps_to_show shows guests_only steps for guests. + */ + public function test_get_steps_to_show_shows_guest_steps_for_guests(): void { + wp_set_current_user(0); + + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'always_step', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'pricing', + 'type' => 'pricing_table', + ], + ], + ], + [ + 'id' => 'guest_step', + 'logged' => 'guests_only', + 'fields' => [ + [ + 'id' => 'username', + 'type' => 'text', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $steps_to_show = $checkout_form->get_steps_to_show(); + + $step_ids = array_column($steps_to_show, 'id'); + $this->assertContains('always_step', $step_ids); + $this->assertContains('guest_step', $step_ids); + } + + /** + * Test get_steps_to_show caches result. + */ + public function test_get_steps_to_show_caches_result(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $first_call = $checkout_form->get_steps_to_show(); + $second_call = $checkout_form->get_steps_to_show(); + + // Should return same result on subsequent calls (cached) + $this->assertSame($first_call, $second_call); + } + + /** + * Test get_steps_to_show merges hidden step fields into last visible step. + */ + public function test_get_steps_to_show_merges_hidden_fields_into_last_step(): void { + wp_set_current_user(0); + + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'visible_step', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + ], + ], + ], + [ + 'id' => 'hidden_fields_step', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'hidden_1', + 'type' => 'hidden', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $steps_to_show = $checkout_form->get_steps_to_show(); + + // The hidden_fields_step only has hidden fields, so it should not appear as its own step. + // Its data fields should be merged into the last visible step. + $step_ids = array_column($steps_to_show, 'id'); + $this->assertContains('visible_step', $step_ids); + $this->assertNotContains('hidden_fields_step', $step_ids); + } + + /** + * Test get_steps_to_show with step that has only non-data fields (submit_button, period_selection, steps). + */ + public function test_get_steps_to_show_with_only_non_data_fields(): void { + wp_set_current_user(0); + + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'main_step', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + ], + [ + 'id' => 'submit', + 'type' => 'submit_button', + ], + ], + ], + [ + 'id' => 'submit_only_step', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'another_submit', + 'type' => 'submit_button', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $steps_to_show = $checkout_form->get_steps_to_show(); + + // The submit_only_step has only non-data fields (submit_button is filtered as non-data), + // so after filtering data fields it becomes empty and should not be its own step. + $step_ids = array_column($steps_to_show, 'id'); + $this->assertContains('main_step', $step_ids); + $this->assertNotContains('submit_only_step', $step_ids); + } + + /** + * Test get_step with to_show parameter. + */ + public function test_get_step_with_to_show_parameter(): void { + wp_set_current_user(0); + + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'always_step', + 'logged' => 'always', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + ], + ], + ], + [ + 'id' => 'logged_step', + 'logged' => 'logged_only', + 'fields' => [ + [ + 'id' => 'profile', + 'type' => 'text', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + // Without to_show, logged_step is accessible from all settings + $step = $checkout_form->get_step('logged_step', false); + $this->assertIsArray($step); + $this->assertEquals('logged_step', $step['id']); + + // With to_show=true, logged_step should not be found (filtered out for guests) + $step_to_show = $checkout_form->get_step('logged_step', true); + $this->assertFalse($step_to_show); + + // always_step should still be accessible with to_show=true + $always_step = $checkout_form->get_step('always_step', true); + $this->assertIsArray($always_step); + $this->assertEquals('always_step', $always_step['id']); + } + + /** + * Test get_step default values parsed via wp_parse_args. + */ + public function test_get_step_parses_default_values(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'minimal_step', + 'name' => 'Minimal Step', + ], + ]; + + $checkout_form->set_settings($settings); + + $step = $checkout_form->get_step('minimal_step'); + $this->assertIsArray($step); + $this->assertEquals('always', $step['logged']); + $this->assertEquals([], $step['fields']); + } + + /** + * Test allowed countries with empty array returns no lock. + */ + public function test_has_country_lock_with_empty_array(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_allowed_countries([]); + $this->assertFalse($checkout_form->has_country_lock()); + } + + /** + * Test allowed countries with single country. + */ + public function test_allowed_countries_with_single_country(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_allowed_countries(['BR']); + $this->assertTrue($checkout_form->has_country_lock()); + $this->assertEquals(['BR'], $checkout_form->get_allowed_countries()); + } + + /** + * Test has_thank_you_page returns false for non-existent page. + */ + public function test_has_thank_you_page_with_non_existent_page(): void { + $checkout_form = new Checkout_Form(); + + // Set a page ID that does not correspond to any post + $checkout_form->set_thank_you_page_id(999999); + + $this->assertEquals(999999, $checkout_form->get_thank_you_page_id()); + $this->assertFalse($checkout_form->has_thank_you_page()); + } + + /** + * Test has_thank_you_page returns false for zero. + */ + public function test_has_thank_you_page_with_zero(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_thank_you_page_id(0); + + $this->assertFalse($checkout_form->has_thank_you_page()); + } + + /** + * Test set_thank_you_page_id stores value in meta array. + */ + public function test_set_thank_you_page_id_stores_meta(): void { + $checkout_form = new Checkout_Form(); + + $page_id = self::factory()->post->create(['post_type' => 'page']); + $checkout_form->set_thank_you_page_id($page_id); + + $this->assertArrayHasKey(Checkout_Form::META_THANK_YOU_PAGE_ID, $checkout_form->meta); + $this->assertEquals($page_id, $checkout_form->meta[ Checkout_Form::META_THANK_YOU_PAGE_ID ]); + } + + /** + * Test set_conversion_snippets stores value in meta array. + */ + public function test_set_conversion_snippets_stores_meta(): void { + $checkout_form = new Checkout_Form(); + + $snippets = ''; + $checkout_form->set_conversion_snippets($snippets); + + $this->assertArrayHasKey(Checkout_Form::META_CONVERSION_SNIPPETS, $checkout_form->meta); + $this->assertEquals($snippets, $checkout_form->meta[ Checkout_Form::META_CONVERSION_SNIPPETS ]); + } + + /** + * Test conversion snippets with empty string. + */ + public function test_conversion_snippets_with_empty_string(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_conversion_snippets(''); + $this->assertEquals('', $checkout_form->get_conversion_snippets()); + } + + /** + * Test shortcode with empty slug. + */ + public function test_shortcode_with_empty_slug(): void { + $checkout_form = new Checkout_Form(); + + $expected = '[wu_checkout slug=""]'; + $this->assertEquals($expected, $checkout_form->get_shortcode()); + } + + /** + * Test shortcode with special characters in slug. + */ + public function test_shortcode_with_special_slug(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_slug('my-custom-form-2024'); + $expected = '[wu_checkout slug="my-custom-form-2024"]'; + $this->assertEquals($expected, $checkout_form->get_shortcode()); + } + + /** + * Test custom CSS with empty string. + */ + public function test_custom_css_empty(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_custom_css(''); + $this->assertEquals('', $checkout_form->get_custom_css()); + } + + /** + * Test custom CSS with complex CSS. + */ + public function test_custom_css_complex(): void { + $checkout_form = new Checkout_Form(); + + $css = ".wu-form { margin: 10px; }\n.wu-form .field { padding: 5px; border: 1px solid #ccc; }"; + $checkout_form->set_custom_css($css); + $this->assertEquals($css, $checkout_form->get_custom_css()); + } + + /** + * Test name setter and getter with various string types. + */ + public function test_name_with_various_strings(): void { + $checkout_form = new Checkout_Form(); + + // Empty name + $checkout_form->set_name(''); + $this->assertEquals('', $checkout_form->get_name()); + + // Name with special characters + $checkout_form->set_name('Checkout Form #1 - Special & "Quoted"'); + $this->assertEquals('Checkout Form #1 - Special & "Quoted"', $checkout_form->get_name()); + + // Name with unicode + $checkout_form->set_name('Formulaire de paiement'); + $this->assertEquals('Formulaire de paiement', $checkout_form->get_name()); + } + + /** + * Test slug setter and getter. + */ + public function test_slug_with_various_strings(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_slug(''); + $this->assertEquals('', $checkout_form->get_slug()); + + $checkout_form->set_slug('a-very-long-slug-with-many-parts'); + $this->assertEquals('a-very-long-slug-with-many-parts', $checkout_form->get_slug()); + } + + /** + * Test use_template with blank template. + */ + public function test_use_template_blank(): void { + $checkout_form = new Checkout_Form(); + + // First set some settings + $checkout_form->set_settings([ + [ + 'id' => 'step1', + 'fields' => [['id' => 'field1', 'type' => 'text']], + ], + ]); + + // Using 'blank' template (not 'single-step' or 'multi-step') should set empty settings + $checkout_form->use_template('blank'); + $settings = $checkout_form->get_settings(); + + // Blank template doesn't match 'multi-step' or 'single-step' branches, + // so $fields stays as empty array and gets set to settings + $this->assertEquals([], $settings); + } + + /** + * Test single-step template structure. + */ + public function test_single_step_template_has_correct_structure(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->use_template('single-step'); + $settings = $checkout_form->get_settings(); + + // Single step should have exactly 1 step + $this->assertCount(1, $settings); + $this->assertEquals('checkout', $settings[0]['id']); + + // Check that the step has fields + $this->assertNotEmpty($settings[0]['fields']); + $this->assertIsArray($settings[0]['fields']); + + // Verify expected field types exist in the single step + $field_types = array_column($settings[0]['fields'], 'type'); + $this->assertContains('email', $field_types); + $this->assertContains('username', $field_types); + $this->assertContains('password', $field_types); + $this->assertContains('site_title', $field_types); + $this->assertContains('site_url', $field_types); + $this->assertContains('order_summary', $field_types); + $this->assertContains('payment', $field_types); + $this->assertContains('submit_button', $field_types); + } + + /** + * Test multi-step template structure. + */ + public function test_multi_step_template_has_correct_structure(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->use_template('multi-step'); + $settings = $checkout_form->get_settings(); + + // Multi-step should have 4 steps + $this->assertCount(4, $settings); + + $step_ids = array_column($settings, 'id'); + $this->assertContains('checkout', $step_ids); + $this->assertContains('site', $step_ids); + $this->assertContains('user', $step_ids); + $this->assertContains('payment', $step_ids); + + // User step should be guests_only + $user_step_key = array_search('user', $step_ids, true); + $this->assertEquals('guests_only', $settings[ $user_step_key ]['logged']); + } + + /** + * Test get_all_fields with steps that have no fields key. + */ + public function test_get_all_fields_with_missing_fields_key(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'step1', + 'name' => 'Step 1', + ], + [ + 'id' => 'step2', + 'name' => 'Step 2', + ], + ]; + + $checkout_form->set_settings($settings); + + $all_fields = $checkout_form->get_all_fields(); + $this->assertEquals([], $all_fields); + } + + /** + * Test get_all_fields_by_type returns empty array for non-matching type. + */ + public function test_get_all_fields_by_type_returns_empty_for_no_match(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $result = $checkout_form->get_all_fields_by_type('nonexistent_type'); + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test get_all_meta_fields method. + */ + public function test_get_all_meta_fields(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'fields' => [ + [ + 'id' => 'custom_text', + 'type' => 'text', + 'save_as' => 'customer_meta', + ], + [ + 'id' => 'custom_select', + 'type' => 'select', + 'save_as' => 'customer_meta', + ], + [ + 'id' => 'site_meta_field', + 'type' => 'text', + 'save_as' => 'site_meta', + ], + [ + 'id' => 'email', + 'type' => 'email', + ], + [ + 'id' => 'custom_color', + 'type' => 'color', + 'save_as' => 'customer_meta', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + // Default meta_type is 'customer_meta' + $meta_fields = $checkout_form->get_all_meta_fields(); + + // Should return text, select, and color fields with save_as = customer_meta + $field_ids = array_column($meta_fields, 'id'); + $this->assertContains('custom_text', $field_ids); + $this->assertContains('custom_select', $field_ids); + $this->assertContains('custom_color', $field_ids); + $this->assertNotContains('email', $field_ids); + $this->assertNotContains('site_meta_field', $field_ids); + } + + /** + * Test get_all_meta_fields with site_meta type. + */ + public function test_get_all_meta_fields_site_meta(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'fields' => [ + [ + 'id' => 'custom_text', + 'type' => 'text', + 'save_as' => 'customer_meta', + ], + [ + 'id' => 'site_field', + 'type' => 'textarea', + 'save_as' => 'site_meta', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $site_meta_fields = $checkout_form->get_all_meta_fields('site_meta'); + + $field_ids = array_column($site_meta_fields, 'id'); + $this->assertContains('site_field', $field_ids); + $this->assertNotContains('custom_text', $field_ids); + } + + /** + * Test get_all_meta_fields returns empty array when no meta fields exist. + */ + public function test_get_all_meta_fields_returns_empty_when_no_meta_fields(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $meta_fields = $checkout_form->get_all_meta_fields(); + $this->assertEmpty($meta_fields); + } + + /** + * Test field count across multiple steps. + */ + public function test_field_count_across_multiple_steps(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'step1', + 'fields' => [ + ['id' => 'f1', 'type' => 'email'], + ['id' => 'f2', 'type' => 'text'], + ], + ], + [ + 'id' => 'step2', + 'fields' => [ + ['id' => 'f3', 'type' => 'password'], + ['id' => 'f4', 'type' => 'site_title'], + ['id' => 'f5', 'type' => 'submit_button'], + ], + ], + [ + 'id' => 'step3', + 'fields' => [ + ['id' => 'f6', 'type' => 'payment'], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $this->assertEquals(6, $checkout_form->get_field_count()); + $this->assertEquals(3, $checkout_form->get_step_count()); + } + + /** + * Test get_field retrieves correct field from multi-step form. + */ + public function test_get_field_from_specific_step_in_multi_step(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'step1', + 'fields' => [ + ['id' => 'email', 'type' => 'email', 'name' => 'Email'], + ], + ], + [ + 'id' => 'step2', + 'fields' => [ + ['id' => 'site_title', 'type' => 'site_title', 'name' => 'Title'], + ['id' => 'site_url', 'type' => 'site_url', 'name' => 'URL'], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + // Get field from step2 + $field = $checkout_form->get_field('step2', 'site_url'); + $this->assertIsArray($field); + $this->assertEquals('site_url', $field['id']); + $this->assertEquals('site_url', $field['type']); + + // Ensure field from step1 is not returned for step2 + $wrong_field = $checkout_form->get_field('step2', 'email'); + $this->assertFalse($wrong_field); + } + + /** + * Test convert_steps_to_v2 with minimal step data. + */ + public function test_convert_steps_to_v2_basic(): void { + $old_steps = [ + 'begin-signup' => [ + 'name' => 'Begin Signup', + 'fields' => [], + ], + 'create-account' => [ + 'name' => 'Create Account', + 'fields' => [], + ], + 'account' => [ + 'name' => 'Account Info', + 'fields' => [ + 'submit' => [ + 'name' => 'Submit', + 'type' => 'submit', + ], + ], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + $this->assertIsArray($result); + + // begin-signup and create-account should be excluded + $step_ids = array_column($result, 'id'); + $this->assertNotContains('begin-signup', $step_ids); + $this->assertNotContains('create-account', $step_ids); + + // account step should be converted + $this->assertContains('account', $step_ids); + + // A payment step should always be appended + $this->assertContains('payment', $step_ids); + } + + /** + * Test convert_steps_to_v2 with user field conversions. + */ + public function test_convert_steps_to_v2_field_conversions(): void { + $old_steps = [ + 'account' => [ + 'name' => 'Account', + 'fields' => [ + 'user_name' => [ + 'name' => 'Username', + 'type' => 'text', + ], + 'user_email' => [ + 'name' => 'Email', + 'type' => 'email', + ], + 'user_pass' => [ + 'name' => 'Password', + 'type' => 'password', + ], + 'user_pass_conf' => [ + 'name' => 'Confirm Password', + 'type' => 'password', + ], + 'blog_title' => [ + 'name' => 'Blog Title', + 'type' => 'text', + ], + 'blogname' => [ + 'name' => 'Blog URL', + 'type' => 'text', + ], + ], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + // Find the 'account' step + $account_step = null; + foreach ($result as $step) { + if ('account' === $step['id']) { + $account_step = $step; + break; + } + } + + $this->assertNotNull($account_step); + + $field_ids = array_column($account_step['fields'], 'id'); + + // user_pass_conf should be skipped + $this->assertNotContains('user_pass_conf', $field_ids); + + // Check field type conversions + $fields_by_original_id = []; + foreach ($account_step['fields'] as $field) { + $fields_by_original_id[ $field['id'] ] = $field; + } + + // user_name should be converted to 'username' type + $this->assertArrayHasKey('user_name', $fields_by_original_id); + $this->assertEquals('username', $fields_by_original_id['user_name']['type']); + + // user_email should keep email type but get display_notices added + $this->assertArrayHasKey('user_email', $fields_by_original_id); + $this->assertFalse($fields_by_original_id['user_email']['display_notices']); + + // user_pass should become password type with password fields + $this->assertArrayHasKey('user_pass', $fields_by_original_id); + $this->assertEquals('password', $fields_by_original_id['user_pass']['type']); + $this->assertTrue($fields_by_original_id['user_pass']['password_confirm_field']); + + // blog_title should become site_title type + $this->assertArrayHasKey('blog_title', $fields_by_original_id); + $this->assertEquals('site_title', $fields_by_original_id['blog_title']['type']); + + // blogname should become site_url type + $this->assertArrayHasKey('blogname', $fields_by_original_id); + $this->assertEquals('site_url', $fields_by_original_id['blogname']['type']); + $this->assertTrue($fields_by_original_id['blogname']['display_url_preview']); + $this->assertTrue($fields_by_original_id['blogname']['required']); + } + + /** + * Test convert_steps_to_v2 skips url_preview and site_url fields. + */ + public function test_convert_steps_to_v2_skips_special_fields(): void { + $old_steps = [ + 'account' => [ + 'name' => 'Account', + 'fields' => [ + 'url_preview' => [ + 'name' => 'URL Preview', + 'type' => 'text', + ], + 'site_url' => [ + 'name' => 'Site URL', + 'type' => 'text', + ], + 'user_name' => [ + 'name' => 'Username', + 'type' => 'text', + ], + ], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + $account_step = null; + foreach ($result as $step) { + if ('account' === $step['id']) { + $account_step = $step; + break; + } + } + + $this->assertNotNull($account_step); + + $field_ids = array_column($account_step['fields'], 'id'); + + // url_preview and site_url (honeypot) should be skipped + $this->assertNotContains('url_preview', $field_ids); + $this->assertNotContains('site_url', $field_ids); + + // user_name should still be present + $this->assertContains('user_name', $field_ids); + } + + /** + * Test convert_steps_to_v2 removes handler, view, hidden keys. + */ + public function test_convert_steps_to_v2_removes_unnecessary_keys(): void { + $old_steps = [ + 'account' => [ + 'name' => 'Account', + 'handler' => 'some_handler', + 'view' => 'some_view', + 'hidden' => true, + 'fields' => [ + 'user_name' => [ + 'name' => 'Username', + 'type' => 'text', + ], + ], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + $account_step = null; + foreach ($result as $step) { + if ('account' === $step['id']) { + $account_step = $step; + break; + } + } + + $this->assertNotNull($account_step); + $this->assertArrayNotHasKey('handler', $account_step); + $this->assertArrayNotHasKey('view', $account_step); + $this->assertArrayNotHasKey('hidden', $account_step); + } + + /** + * Test convert_steps_to_v2 always appends a payment step. + */ + public function test_convert_steps_to_v2_appends_payment_step(): void { + $old_steps = [ + 'account' => [ + 'name' => 'Account', + 'fields' => [], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + // The last step should be the payment step + $last_step = end($result); + $this->assertEquals('payment', $last_step['id']); + + // Verify payment step has expected fields + $field_types = array_column($last_step['fields'], 'type'); + $this->assertContains('order_summary', $field_types); + $this->assertContains('billing_address', $field_types); + $this->assertContains('discount_code', $field_types); + $this->assertContains('payment', $field_types); + $this->assertContains('submit_button', $field_types); + } + + /** + * Test convert_steps_to_v2 with plan step. + */ + public function test_convert_steps_to_v2_with_plan_step(): void { + $old_steps = [ + 'plan' => [ + 'name' => 'Pick a Plan', + 'fields' => [ + 'existing_field' => [ + 'name' => 'Old Field', + 'type' => 'text', + ], + ], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + // Find the plan step + $plan_step = null; + foreach ($result as $step) { + if ('plan' === $step['id']) { + $plan_step = $step; + break; + } + } + + $this->assertNotNull($plan_step); + + // Plan step should have a pricing_table field + $field_types = array_column($plan_step['fields'], 'type'); + $this->assertContains('pricing_table', $field_types); + } + + /** + * Test convert_steps_to_v2 excludes template step when no templates configured. + */ + public function test_convert_steps_to_v2_excludes_template_when_empty(): void { + $old_steps = [ + 'template' => [ + 'name' => 'Choose Template', + 'fields' => [], + ], + ]; + + // No templates in old_settings + $result = Checkout_Form::convert_steps_to_v2($old_steps, []); + + $step_ids = array_column($result, 'id'); + $this->assertNotContains('template', $step_ids); + } + + /** + * Test convert_steps_to_v2 submit field becomes submit_button in account step. + */ + public function test_convert_steps_to_v2_submit_field_renamed_in_account(): void { + $old_steps = [ + 'account' => [ + 'name' => 'Account', + 'fields' => [ + 'submit' => [ + 'name' => 'Submit', + 'type' => 'submit', + ], + ], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + $account_step = null; + foreach ($result as $step) { + if ('account' === $step['id']) { + $account_step = $step; + break; + } + } + + $this->assertNotNull($account_step); + + $submit_field = null; + foreach ($account_step['fields'] as $field) { + if ('submit' === $field['id']) { + $submit_field = $field; + break; + } + } + + $this->assertNotNull($submit_field); + $this->assertEquals('submit_button', $submit_field['type']); + // In account step, name should be changed to 'Continue to the Next Step' + $this->assertEquals('Continue to the Next Step', $submit_field['name']); + } + + /** + * Test convert_steps_to_v2 with plan step period options. + */ + public function test_convert_steps_to_v2_plan_with_period_options(): void { + $old_steps = [ + 'plan' => [ + 'name' => 'Pick a Plan', + 'fields' => [], + ], + ]; + + $old_settings = [ + 'enable_price_1' => true, + 'enable_price_3' => true, + 'enable_price_12' => true, + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps, $old_settings); + + $plan_step = null; + foreach ($result as $step) { + if ('plan' === $step['id']) { + $plan_step = $step; + break; + } + } + + $this->assertNotNull($plan_step); + + // With multiple period options enabled, should have a period_selection field + $field_types = array_column($plan_step['fields'], 'type'); + $this->assertContains('period_selection', $field_types); + $this->assertContains('pricing_table', $field_types); + } + + /** + * Test convert_steps_to_v2 plan with single period option does not add period selector. + */ + public function test_convert_steps_to_v2_plan_with_single_period(): void { + $old_steps = [ + 'plan' => [ + 'name' => 'Pick a Plan', + 'fields' => [], + ], + ]; + + $old_settings = [ + 'enable_price_1' => true, + 'enable_price_3' => false, + 'enable_price_12' => false, + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps, $old_settings); + + $plan_step = null; + foreach ($result as $step) { + if ('plan' === $step['id']) { + $plan_step = $step; + break; + } + } + + $this->assertNotNull($plan_step); + + // With only one period option, should NOT have period_selection field + $field_types = array_column($plan_step['fields'], 'type'); + $this->assertNotContains('period_selection', $field_types); + $this->assertContains('pricing_table', $field_types); + } + + /** + * Test settings with JSON-encoded string (non-serialized). + */ + public function test_settings_with_non_serialized_string(): void { + $checkout_form = new Checkout_Form(); + + // An arbitrary string that is not a valid PHP serialized format + $checkout_form->set_settings('not-serialized-string'); + + // get_settings should handle this gracefully (the maybe_unserialize returns the string as-is) + $settings = $checkout_form->get_settings(); + $this->assertEquals('not-serialized-string', $settings); + } + + /** + * Test get_all_fields with single step single field. + */ + public function test_get_all_fields_single_step_single_field(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'step1', + 'fields' => [ + ['id' => 'email', 'type' => 'email'], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $all_fields = $checkout_form->get_all_fields(); + $this->assertCount(1, $all_fields); + $this->assertEquals('email', $all_fields[0]['id']); + } + + /** + * Test get_all_fields_by_type with empty fields. + */ + public function test_get_all_fields_by_type_empty_fields(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'fields' => [], + ], + ]; + + $checkout_form->set_settings($settings); + + $result = $checkout_form->get_all_fields_by_type('text'); + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Helper to build valid checkout form settings with all required field types. + * + * @param array $extra_fields Additional fields to include in the checkout step. + * @return array + */ + private function get_valid_settings(array $extra_fields = []): array { + $fields = [ + ['id' => 'username', 'type' => 'username'], + ['id' => 'password', 'type' => 'password'], + ['id' => 'email_address', 'type' => 'email'], + ['id' => 'site_title', 'type' => 'site_title'], + ['id' => 'site_url', 'type' => 'site_url'], + ['id' => 'billing_address', 'type' => 'billing_address'], + ['id' => 'order_summary', 'type' => 'order_summary'], + ['id' => 'payment', 'type' => 'payment'], + ['id' => 'submit', 'type' => 'submit_button'], + ]; + + $fields = array_merge($fields, $extra_fields); + + return [ + [ + 'id' => 'checkout', + 'name' => 'Checkout', + 'fields' => $fields, + ], + ]; + } + + /** + * Test creating a checkout form via wu_create_checkout_form and persisting. + */ + public function test_create_checkout_form_via_helper_function(): void { + $settings = $this->get_valid_settings([ + ['id' => 'email', 'type' => 'email'], + ]); + + $checkout_form = wu_create_checkout_form([ + 'name' => 'Persisted Form', + 'slug' => 'persisted-form', + 'settings' => $settings, + ]); + + $this->assertNotWPError($checkout_form); + $this->assertInstanceOf(Checkout_Form::class, $checkout_form); + $this->assertGreaterThan(0, $checkout_form->get_id()); + $this->assertEquals('Persisted Form', $checkout_form->get_name()); + $this->assertEquals('persisted-form', $checkout_form->get_slug()); + } + + /** + * Test fetching a persisted checkout form by ID. + */ + public function test_get_checkout_form_by_id(): void { + $checkout_form = wu_create_checkout_form([ + 'name' => 'Fetch Test', + 'slug' => 'fetch-test-form', + ]); + + $this->assertNotWPError($checkout_form); + + $fetched = wu_get_checkout_form($checkout_form->get_id()); + + $this->assertInstanceOf(Checkout_Form::class, $fetched); + $this->assertEquals('Fetch Test', $fetched->get_name()); + $this->assertEquals('fetch-test-form', $fetched->get_slug()); + } + + /** + * Test fetching a persisted checkout form by slug. + */ + public function test_get_checkout_form_by_slug(): void { + $checkout_form = wu_create_checkout_form([ + 'name' => 'Slug Test', + 'slug' => 'slug-test-form', + ]); + + $this->assertNotWPError($checkout_form); + + $fetched = wu_get_checkout_form_by_slug('slug-test-form'); + + $this->assertInstanceOf(Checkout_Form::class, $fetched); + $this->assertEquals('Slug Test', $fetched->get_name()); + } + + /** + * Test that settings are preserved after save and fetch. + */ + public function test_settings_persist_after_save(): void { + $settings = $this->get_valid_settings([ + ['id' => 'field1', 'type' => 'email'], + ['id' => 'field2', 'type' => 'text'], + ]); + + $checkout_form = wu_create_checkout_form([ + 'name' => 'Settings Persist Test', + 'slug' => 'settings-persist-test', + 'settings' => $settings, + ]); + + $this->assertNotWPError($checkout_form); + + $fetched = wu_get_checkout_form($checkout_form->get_id()); + + $fetched_settings = $fetched->get_settings(); + $this->assertCount(1, $fetched_settings); + $this->assertEquals('checkout', $fetched_settings[0]['id']); + + // 9 required fields + 2 extra fields = 11 + $this->assertCount(11, $fetched_settings[0]['fields']); + } + + /** + * Test that allowed countries persist after save. + */ + public function test_allowed_countries_persist_after_save(): void { + $checkout_form = wu_create_checkout_form([ + 'name' => 'Country Persist Test', + 'slug' => 'country-persist-test', + 'allowed_countries' => ['US', 'CA', 'GB'], + ]); + + $this->assertNotWPError($checkout_form); + + $fetched = wu_get_checkout_form($checkout_form->get_id()); + + $this->assertTrue($fetched->has_country_lock()); + $countries = $fetched->get_allowed_countries(); + $this->assertContains('US', $countries); + $this->assertContains('CA', $countries); + $this->assertContains('GB', $countries); + } + + /** + * Test meta constants are correct. + */ + public function test_meta_constants(): void { + $this->assertEquals('wu_thank_you_page_id', Checkout_Form::META_THANK_YOU_PAGE_ID); + $this->assertEquals('wu_conversion_snippets', Checkout_Form::META_CONVERSION_SNIPPETS); + } + + /** + * Test template value is null by default. + */ + public function test_template_default_is_null(): void { + $checkout_form = new Checkout_Form(); + + $this->assertNull($checkout_form->get_template()); + } + + /** + * Test step count returns zero for non-array settings. + */ + public function test_step_count_with_string_settings(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_settings('invalid-string'); + + // get_step_count calls get_settings which returns the string, + // then checks is_array, so should return 0 + $this->assertEquals(0, $checkout_form->get_step_count()); + } + + /** + * Test get_all_fields returns empty array with non-array settings value. + */ + public function test_get_all_fields_with_non_array_settings(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_settings('string-value'); + + $result = $checkout_form->get_all_fields(); + $this->assertEquals([], $result); + } + + /** + * Test validation rules contain slug with unique constraint including form ID. + */ + public function test_validation_rules_slug_unique_includes_id(): void { + $checkout_form = wu_create_checkout_form([ + 'name' => 'Validation Test', + 'slug' => 'validation-test-form', + ]); + + $this->assertNotWPError($checkout_form); + + $rules = $checkout_form->validation_rules(); + + // The slug rule should include the form's ID for unique check + $this->assertStringContainsString((string) $checkout_form->get_id(), $rules['slug']); + } + + /** + * Test get_steps_to_show with empty settings returns empty array. + */ + public function test_get_steps_to_show_with_empty_settings(): void { + $checkout_form = new Checkout_Form(); + + $steps = $checkout_form->get_steps_to_show(); + $this->assertIsArray($steps); + $this->assertEmpty($steps); + } + + /** + * Test get_steps_to_show with step missing logged key defaults to always. + */ + public function test_get_steps_to_show_defaults_logged_to_always(): void { + wp_set_current_user(0); + + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'no_logged_key', + 'fields' => [ + ['id' => 'email', 'type' => 'email'], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $steps = $checkout_form->get_steps_to_show(); + + // Step without 'logged' key should default to 'always' and be shown + $step_ids = array_column($steps, 'id'); + $this->assertContains('no_logged_key', $step_ids); + } + + /** + * Test active status with string values. + */ + public function test_active_status_with_string_values(): void { + $checkout_form = new Checkout_Form(); + + $checkout_form->set_active('yes'); + $this->assertTrue($checkout_form->is_active()); + + $checkout_form->set_active(''); + $this->assertFalse($checkout_form->is_active()); + } + + /** + * Test querying checkout forms. + */ + public function test_query_checkout_forms(): void { + wu_create_checkout_form([ + 'name' => 'Query Form 1', + 'slug' => 'query-form-1', + ]); + + wu_create_checkout_form([ + 'name' => 'Query Form 2', + 'slug' => 'query-form-2', + ]); + + $forms = wu_get_checkout_forms([ + 'search' => 'Query Form', + ]); + + $this->assertIsArray($forms); + $this->assertGreaterThanOrEqual(2, count($forms)); + } + + /** + * Test wu_get_checkout_form returns false for non-existent ID. + */ + public function test_get_checkout_form_returns_false_for_invalid_id(): void { + $result = wu_get_checkout_form(999999); + $this->assertFalse($result); + } + + /** + * Test wu_get_checkout_form_by_slug returns false for empty slug. + */ + public function test_get_checkout_form_by_slug_returns_false_for_empty(): void { + $result = wu_get_checkout_form_by_slug(''); + $this->assertFalse($result); + } + + /** + * Test wu_get_checkout_form_by_slug with special slug 'wu-checkout'. + */ + public function test_get_checkout_form_by_slug_wu_checkout(): void { + $result = wu_get_checkout_form_by_slug('wu-checkout'); + + // Without a current membership, returns an empty checkout form + $this->assertInstanceOf(Checkout_Form::class, $result); + } + + /** + * Test wu_get_checkout_form_by_slug with special slug 'wu-add-new-site'. + */ + public function test_get_checkout_form_by_slug_wu_add_new_site(): void { + $result = wu_get_checkout_form_by_slug('wu-add-new-site'); + + // Without a current membership, returns an empty checkout form + $this->assertInstanceOf(Checkout_Form::class, $result); + } + + /** + * Test wu_get_checkout_form_by_slug with special slug 'wu-finish-checkout'. + */ + public function test_get_checkout_form_by_slug_wu_finish_checkout(): void { + $result = wu_get_checkout_form_by_slug('wu-finish-checkout'); + + // Without a current payment, returns an empty checkout form + $this->assertInstanceOf(Checkout_Form::class, $result); + } + + /** + * Test multi-step template field count is greater than single step. + */ + public function test_multi_step_has_more_fields_than_single_step(): void { + $single = new Checkout_Form(); + $single->use_template('single-step'); + + $multi = new Checkout_Form(); + $multi->use_template('multi-step'); + + $this->assertGreaterThan($single->get_step_count(), $multi->get_step_count()); + } + + /** + * Test get_all_fields_by_type with array input for types. + */ + public function test_get_all_fields_by_type_with_array_input(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'fields' => [ + ['id' => 'email', 'type' => 'email'], + ['id' => 'pass', 'type' => 'password'], + ['id' => 'user', 'type' => 'username'], + ['id' => 'title', 'type' => 'site_title'], + ['id' => 'url', 'type' => 'site_url'], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + // Get site-related fields + $site_fields = $checkout_form->get_all_fields_by_type(['site_title', 'site_url']); + $this->assertCount(2, $site_fields); + + // Get all field types at once + $all_types = $checkout_form->get_all_fields_by_type(['email', 'password', 'username', 'site_title', 'site_url']); + $this->assertCount(5, $all_types); + } + + /** + * Test that conversion snippets with multiline content are preserved. + */ + public function test_conversion_snippets_multiline(): void { + $checkout_form = new Checkout_Form(); + + $snippets = ""; + $checkout_form->set_conversion_snippets($snippets); + $this->assertEquals($snippets, $checkout_form->get_conversion_snippets()); + } + + /** + * Test allowed countries with serialized string containing backslashes. + */ + public function test_allowed_countries_with_slashed_serialized_string(): void { + $checkout_form = new Checkout_Form(); + + $countries = ['US', 'CA']; + $serialized = addslashes(serialize($countries)); + + $checkout_form->set_allowed_countries($serialized); + + $result = $checkout_form->get_allowed_countries(); + $this->assertEquals($countries, $result); + } + + /** + * Test settings with slashed serialized string. + */ + public function test_settings_with_slashed_serialized_string(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'step1', + 'fields' => [], + ], + ]; + + $slashed = addslashes(serialize($settings)); + $checkout_form->set_settings($slashed); + + $result = $checkout_form->get_settings(); + $this->assertEquals($settings, $result); + } + + /** + * Test convert_steps_to_v2 with submit field in non-account step. + */ + public function test_convert_steps_to_v2_submit_in_non_account_step(): void { + $old_steps = [ + 'other' => [ + 'name' => 'Other Step', + 'fields' => [ + 'submit' => [ + 'name' => 'Submit Form', + 'type' => 'submit', + ], + ], + ], + ]; + + $result = Checkout_Form::convert_steps_to_v2($old_steps); + + $other_step = null; + foreach ($result as $step) { + if ('other' === $step['id']) { + $other_step = $step; + break; + } + } + + $this->assertNotNull($other_step); + + $submit_field = null; + foreach ($other_step['fields'] as $field) { + if ('submit' === $field['id']) { + $submit_field = $field; + break; + } + } + + $this->assertNotNull($submit_field); + $this->assertEquals('submit_button', $submit_field['type']); + // In non-account step, name should remain as is + $this->assertEquals('Submit Form', $submit_field['name']); + } + + /** + * Test checkout form with blank/empty settings returns zero counts. + */ + public function test_empty_form_returns_zero_counts(): void { + $checkout_form = new Checkout_Form(); + + $this->assertEquals(0, $checkout_form->get_step_count()); + $this->assertEquals(0, $checkout_form->get_field_count()); + } + + /** + * Test get_field returns full field data including extra attributes. + */ + public function test_get_field_returns_extra_attributes(): void { + $checkout_form = new Checkout_Form(); + + $settings = [ + [ + 'id' => 'checkout', + 'fields' => [ + [ + 'id' => 'email', + 'type' => 'email', + 'name' => 'Email Address', + 'required' => true, + 'placeholder' => 'you@example.com', + 'tooltip' => 'Enter your email', + ], + ], + ], + ]; + + $checkout_form->set_settings($settings); + + $field = $checkout_form->get_field('checkout', 'email'); + + $this->assertEquals('email', $field['id']); + $this->assertEquals('Email Address', $field['name']); + $this->assertTrue($field['required']); + $this->assertEquals('you@example.com', $field['placeholder']); + $this->assertEquals('Enter your email', $field['tooltip']); + } } diff --git a/tests/WP_Ultimo/Models/Customer_Test.php b/tests/WP_Ultimo/Models/Customer_Test.php index 0a355019..a0322528 100644 --- a/tests/WP_Ultimo/Models/Customer_Test.php +++ b/tests/WP_Ultimo/Models/Customer_Test.php @@ -386,4 +386,1895 @@ public function test_to_search_results(): void { $this->assertEquals('searchuser', $search_results['user_login']); $this->assertEquals('search@example.com', $search_results['user_email']); } + + // --------------------------------------------------------------- + // CRUD via helper functions + // --------------------------------------------------------------- + + /** + * Helper: create a WP user and return its ID. + * + * @param string $login User login. + * @param string $email User email. + * @return int + */ + private function make_user(string $login = '', string $email = ''): int { + + $login = $login ?: 'user_' . wp_generate_uuid4(); + $email = $email ?: $login . '@example.com'; + + return self::factory()->user->create( + [ + 'user_login' => $login, + 'user_email' => $email, + ] + ); + } + + /** + * Helper: create a saved customer via wu_create_customer. + * + * @param array $overrides Overrides to pass. + * @return Customer + */ + private function make_customer(array $overrides = []): Customer { + + $user_id = $this->make_user(); + + $defaults = [ + 'user_id' => $user_id, + 'email_verification' => 'none', + ]; + + $customer = wu_create_customer(array_merge($defaults, $overrides)); + + $this->assertNotWPError($customer); + $this->assertInstanceOf(Customer::class, $customer); + + return $customer; + } + + /** + * Test wu_create_customer creates and persists a customer. + */ + public function test_wu_create_customer_persists(): void { + + $customer = $this->make_customer(); + + $this->assertGreaterThan(0, $customer->get_id()); + $this->assertEquals('customer', $customer->get_type()); + } + + /** + * Test wu_get_customer retrieves a persisted customer by ID. + */ + public function test_wu_get_customer_by_id(): void { + + $customer = $this->make_customer(); + + $fetched = wu_get_customer($customer->get_id()); + + $this->assertInstanceOf(Customer::class, $fetched); + $this->assertEquals($customer->get_id(), $fetched->get_id()); + $this->assertEquals($customer->get_user_id(), $fetched->get_user_id()); + } + + /** + * Test wu_get_customer returns false for non-existent ID. + */ + public function test_wu_get_customer_returns_false_for_missing(): void { + + $this->assertFalse(wu_get_customer(999999)); + } + + /** + * Test wu_get_customer_by_user_id retrieves customer by WP user ID. + */ + public function test_wu_get_customer_by_user_id(): void { + + $customer = $this->make_customer(); + + $fetched = wu_get_customer_by_user_id($customer->get_user_id()); + + $this->assertInstanceOf(Customer::class, $fetched); + $this->assertEquals($customer->get_id(), $fetched->get_id()); + } + + /** + * Test wu_get_customer_by_user_id returns false for unknown user. + */ + public function test_wu_get_customer_by_user_id_returns_false_for_unknown(): void { + + $this->assertFalse(wu_get_customer_by_user_id(999999)); + } + + /** + * Test customer deletion. + */ + public function test_customer_delete(): void { + + $customer = $this->make_customer(); + $id = $customer->get_id(); + + $this->assertInstanceOf(Customer::class, wu_get_customer($id)); + + $result = $customer->delete(); + + $this->assertNotEmpty($result); + $this->assertFalse(wu_get_customer($id)); + } + + /** + * Test customer update after save. + */ + public function test_customer_update(): void { + + $customer = $this->make_customer(['email_verification' => 'none']); + + $customer->set_email_verification('verified'); + $customer->save(); + + $fetched = wu_get_customer($customer->get_id()); + + $this->assertEquals('verified', $fetched->get_email_verification()); + } + + /** + * Test creating customer with existing email reuses WP user. + */ + public function test_wu_create_customer_with_existing_user(): void { + + $user_id = $this->make_user('existinguser', 'existing@example.com'); + + $customer = wu_create_customer( + [ + 'user_id' => $user_id, + 'email_verification' => 'none', + ] + ); + + $this->assertNotWPError($customer); + $this->assertEquals($user_id, $customer->get_user_id()); + } + + // --------------------------------------------------------------- + // Getter / Setter pairs + // --------------------------------------------------------------- + + /** + * Test user_id getter returns absint. + */ + public function test_get_user_id_returns_absint(): void { + + $customer = new Customer(); + $customer->set_user_id(-5); + + $this->assertEquals(5, $customer->get_user_id()); + } + + /** + * Test user_id getter for zero when unset. + */ + public function test_get_user_id_defaults_to_zero(): void { + + $customer = new Customer(); + + $this->assertEquals(0, $customer->get_user_id()); + } + + /** + * Test date_registered getter and setter. + */ + public function test_date_registered_getter_setter(): void { + + $customer = new Customer(); + $date = '2024-06-15 12:00:00'; + + $customer->set_date_registered($date); + + $this->assertEquals($date, $customer->get_date_registered()); + } + + /** + * Test last_login getter and setter with various dates. + */ + public function test_last_login_various_dates(): void { + + $customer = new Customer(); + + $customer->set_last_login('2024-12-31 23:59:59'); + $this->assertEquals('2024-12-31 23:59:59', $customer->get_last_login()); + + $customer->set_last_login('0000-00-00 00:00:00'); + $this->assertEquals('0000-00-00 00:00:00', $customer->get_last_login()); + } + + /** + * Test type getter and setter. + */ + public function test_type_getter_setter(): void { + + $customer = new Customer(); + + $customer->set_type('customer'); + $this->assertEquals('customer', $customer->get_type()); + } + + /** + * Test VIP getter returns boolean. + */ + public function test_vip_returns_boolean_true(): void { + + $customer = new Customer(); + $customer->set_vip(1); + + $this->assertTrue($customer->is_vip()); + $this->assertIsBool($customer->is_vip()); + } + + /** + * Test VIP setter with falsy value. + */ + public function test_vip_with_falsy_value(): void { + + $customer = new Customer(); + $customer->set_vip(0); + + $this->assertFalse($customer->is_vip()); + } + + /** + * Test signup form default value. + */ + public function test_signup_form_default(): void { + + $customer = new Customer(); + + $this->assertEquals('by-admin', $customer->get_signup_form()); + } + + /** + * Test signup form setter and getter. + */ + public function test_signup_form_setter_getter(): void { + + $customer = new Customer(); + $customer->set_signup_form('my-custom-form'); + + $this->assertEquals('my-custom-form', $customer->get_signup_form()); + } + + /** + * Test network_id getter and setter. + */ + public function test_network_id_getter_setter(): void { + + $customer = new Customer(); + + $this->assertNull($customer->get_network_id()); + + $customer->set_network_id(5); + $this->assertEquals(5, $customer->get_network_id()); + + $customer->set_network_id(null); + $this->assertNull($customer->get_network_id()); + } + + /** + * Test network_id getter returns absint for positive values. + */ + public function test_network_id_returns_absint(): void { + + $customer = new Customer(); + $customer->set_network_id(42); + + $this->assertSame(42, $customer->get_network_id()); + } + + /** + * Test network_id zero is treated as null. + */ + public function test_network_id_zero_is_null(): void { + + $customer = new Customer(); + $customer->set_network_id(0); + + $this->assertNull($customer->get_network_id()); + } + + // --------------------------------------------------------------- + // IP address edge cases + // --------------------------------------------------------------- + + /** + * Test set_ips with serialized string. + */ + public function test_set_ips_with_serialized_string(): void { + + $customer = new Customer(); + $ips = ['1.1.1.1', '2.2.2.2']; + + $customer->set_ips(maybe_serialize($ips)); + + $this->assertEquals($ips, $customer->get_ips()); + } + + /** + * Test add_ip sanitizes input. + */ + public function test_add_ip_sanitizes_input(): void { + + $customer = new Customer(); + $customer->set_ips([]); + $customer->add_ip(''); + + $ips = $customer->get_ips(); + + $this->assertCount(1, $ips); + $this->assertStringNotContainsString('My Site'); + $result = $this->site->get_title(); + $this->assertStringNotContainsString('