@@ -68,6 +117,7 @@ function ( $a, $b ) {
$edac_slug = $edac_settings_tab_item['slug'] ? $edac_settings_tab_item['slug'] : null;
$edac_query_var = $edac_slug ? '&tab=' . $edac_slug : '';
$edac_label = $edac_settings_tab_item['label'];
+ $edac_badge = $edac_settings_tab_item['badge'] ?? '';
?>
- nav-tab-active">
+ nav-tab-active">
+
+
+
+
+
';
diff --git a/src/admin/sass/accessibility-checker-admin.scss b/src/admin/sass/accessibility-checker-admin.scss
index 9f123733d..16bd13099 100644
--- a/src/admin/sass/accessibility-checker-admin.scss
+++ b/src/admin/sass/accessibility-checker-admin.scss
@@ -719,6 +719,205 @@
}
}
+.nav-tab-wrapper .nav-tab {
+ .edac-settings-tab__label,
+ .edac-settings-tab__badge {
+ display: inline-flex;
+ align-items: center;
+ }
+
+ .edac-settings-tab__badge {
+ margin-left: 8px;
+ padding: 2px 12px;
+ border-radius: 999px;
+ background: #ffcc17;
+ color: #101828;
+ font-size: 0.875em;
+ font-weight: 600;
+ line-height: 1.6;
+ }
+}
+
+.edac-reports-page {
+ margin-top: 24px;
+ display: grid;
+ gap: 16px;
+
+ * {
+ box-sizing: border-box;
+ }
+
+ @include helpers.breakpoint(lg) {
+ grid-template-columns: minmax(0, 1fr) 320px;
+ align-items: start;
+ }
+
+ &__main {
+ display: grid;
+ gap: 24px;
+
+ min-width: 0;
+ }
+
+ &__panel {
+
+ h2 {
+ margin: 0 0 24px;
+ }
+ }
+
+ &__intro {
+ max-width: 980px;
+ margin: 0 0 32px;
+ }
+
+ &__single-action {
+ margin-top: 24px;
+ }
+
+ &__button {
+ min-height: 52px;
+ padding: 0 20px;
+ border-radius: 8px;
+ }
+
+ &__button--secondary {
+ color: #3f5bf6;
+ border-color: #3f5bf6;
+ background: #fff;
+ }
+
+ &__license-input,
+ &__license-mask {
+ display: block;
+ width: 100%;
+ max-width: none;
+ margin: 0 0 20px;
+ padding: 18px 20px;
+ border: 1px solid #b5bcc4;
+ border-radius: 0;
+ background: #fff;
+ }
+
+ &__license-mask {
+ color: #1d2327;
+ }
+
+ &__legal {
+ margin: 24px 0 0;
+ }
+}
+
+.edac-reports-grid {
+ display: grid;
+ gap: 13px;
+
+ &--two {
+ @include helpers.breakpoint(md) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ }
+}
+
+.edac-reports-card {
+ padding: 26px 28px 30px;
+ border: 1px solid #d7dce1;
+ border-radius: 10px;
+ background: #fff;
+
+ h3 {
+ margin: 0 0 18px;
+ }
+
+ ul {
+ margin: 0 0 20px 20px;
+ list-style-type: disc;
+ }
+
+ &__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ &__status {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #117d11;
+ flex: 0 0 auto;
+ }
+
+ &__status-icon {
+ display: inline-flex;
+ width: 27px;
+ height: 27px;
+ }
+
+ &__status-icon svg {
+ display: inline-flex;
+ width: 27px;
+ height: 27px;
+ }
+
+ &__status--warning {
+ border-color: #d17b00;
+ color: #d17b00;
+ }
+
+ &__meta {
+ display: grid;
+ gap: 4px;
+ margin: 20px 0 28px;
+ }
+}
+
+.edac-reports-stat {
+ text-align: center;
+ padding-top: 28px;
+
+ &__label {
+ margin-bottom: 10px;
+ color: #1d2327;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1.2;
+ }
+
+ &__value {
+ color: #1d2327;
+ font-size: 36px;
+ font-weight: 500;
+ line-height: 1.05;
+ }
+
+ &__caption {
+ margin-top: 12px;
+ color: #1d2327;
+ font-size: 20px;
+ font-weight: 400;
+ line-height: 1.2;
+ }
+}
+
+.edac-reports-preview {
+ display: none;
+ min-width: 0;
+
+ @include helpers.breakpoint(lg) {
+ display: block;
+ width: 320px;
+ }
+
+ &__image {
+ display: block;
+ width: 100%;
+ height: auto;
+ margin-bottom: -15px;
+ }
+}
+
.ac-simplified-summary {
max-width: 800px;
margin-left: auto;
@@ -732,6 +931,10 @@
.edac-settings {
max-width: 800px;
+ &--reports {
+ max-width: 1390px;
+ }
+
&.pro-callout-wrapper {
max-width: fit-content;
}
@@ -745,6 +948,7 @@
padding: 15px;
border: solid 1px variables.$color-gray-light;
margin-top: 20px;
+ border-radius: 10px;
}
&-general {
@@ -794,6 +998,15 @@
}
}
+.edac-settings--reports {
+
+ .edac-reports-page__legal {
+ @include helpers.breakpoint(lg) {
+ grid-column: 1 / -1;
+ }
+ }
+}
+
.edac-welcome {
background-color: variables.$color-white;
border: 1px solid variables.$color-gray-light;
diff --git a/tests/phpunit/Admin/AccessibilityReportsPageTest.php b/tests/phpunit/Admin/AccessibilityReportsPageTest.php
new file mode 100644
index 000000000..7ab715234
--- /dev/null
+++ b/tests/phpunit/Admin/AccessibilityReportsPageTest.php
@@ -0,0 +1,161 @@
+page = new AccessibilityReportsPage( 'manage_options' );
+
+ delete_option( 'edac_license_status' );
+ delete_option( 'edacp_license_status' );
+ delete_option( 'edac_site_id' );
+ delete_option( 'edac_fallback_active' );
+ delete_option( 'edacp_enable_archive_scanning' );
+ delete_option( 'edac_next_collection' );
+ }
+
+ /**
+ * Clean up test state.
+ */
+ public function tearDown(): void {
+ delete_option( 'edac_license_status' );
+ delete_option( 'edacp_license_status' );
+ delete_option( 'edac_site_id' );
+ delete_option( 'edac_fallback_active' );
+ delete_option( 'edacp_enable_archive_scanning' );
+ delete_option( 'edac_next_collection' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Invoke a private method on the reports page.
+ *
+ * @param string $method_name Method name.
+ * @param array $arguments Arguments.
+ * @return mixed
+ * @throws ReflectionException If reflection fails.
+ */
+ private function invoke_private_method( string $method_name, array $arguments = [] ) {
+ $reflection = new ReflectionClass( AccessibilityReportsPage::class );
+ $method = $reflection->getMethod( $method_name );
+ $method->setAccessible( true );
+
+ return $method->invokeArgs( $this->page, $arguments );
+ }
+
+ /**
+ * Invoke a private static method on the reports page.
+ *
+ * @param string $method_name Method name.
+ * @param array $arguments Arguments.
+ * @return mixed
+ * @throws ReflectionException If reflection fails.
+ */
+ private function invoke_private_static_method( string $method_name, array $arguments = [] ) {
+ $reflection = new ReflectionClass( AccessibilityReportsPage::class );
+ $method = $reflection->getMethod( $method_name );
+ $method->setAccessible( true );
+
+ return $method->invokeArgs( null, $arguments );
+ }
+
+ /**
+ * Ensures valid Pro remains authoritative when it is installed and connected.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_license_context_uses_pro_when_pro_is_valid() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_license_context',
+ [ true, 'valid', 'valid', 'site-123', false ]
+ );
+
+ $this->assertTrue( $context['has_pro_plugin'] );
+ $this->assertTrue( $context['is_pro'] );
+ $this->assertSame( 'valid', $context['status'] );
+ $this->assertTrue( $context['is_connected'] );
+ }
+
+ /**
+ * Ensures the reports page falls back to free state when Pro is installed but no longer valid.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_license_context_falls_back_to_free_when_pro_is_invalid() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_license_context',
+ [ true, 'expired', 'valid', 'site-123', false ]
+ );
+
+ $this->assertTrue( $context['has_pro_plugin'] );
+ $this->assertFalse( $context['is_pro'] );
+ $this->assertSame( 'valid', $context['status'] );
+ $this->assertTrue( $context['is_connected'] );
+ }
+
+ /**
+ * Ensures taxonomy coverage only shows full coverage when the effective license is Pro.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_get_taxonomy_coverage_counts_requires_effective_pro_license() {
+ update_option( 'edacp_enable_archive_scanning', 1 );
+
+ $free_counts = $this->invoke_private_method( 'get_taxonomy_coverage_counts', [ false ] );
+ $pro_counts = $this->invoke_private_method( 'get_taxonomy_coverage_counts', [ true ] );
+
+ $this->assertSame( 0, $free_counts['checked'] );
+ $this->assertGreaterThanOrEqual( $free_counts['checked'], $free_counts['total'] );
+ $this->assertSame( $pro_counts['total'], $pro_counts['checked'] );
+ }
+
+ /**
+ * Ensures fallback marker does not disconnect reports when Free is valid and the site remains enrolled.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_license_context_keeps_free_fallback_connected_when_site_id_exists() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_license_context',
+ [ true, 'expired', 'valid', 'site-123', true ]
+ );
+
+ $this->assertFalse( $context['is_pro'] );
+ $this->assertSame( 'valid', $context['status'] );
+ $this->assertTrue( $context['is_connected'] );
+ }
+
+ /**
+ * Ensures stored next collection date is preferred over local fallback estimate.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_get_next_collection_date_uses_stored_value_when_available() {
+ update_option( 'edac_next_collection', '2030-01-15' );
+
+ $next_collection = $this->invoke_private_method( 'get_next_collection_date' );
+
+ $this->assertSame( '2030-01-15', $next_collection );
+ }
+}
diff --git a/tests/phpunit/Admin/ConnectedServicesPageTest.php b/tests/phpunit/Admin/ConnectedServicesPageTest.php
new file mode 100644
index 000000000..67649f263
--- /dev/null
+++ b/tests/phpunit/Admin/ConnectedServicesPageTest.php
@@ -0,0 +1,328 @@
+page = new ConnectedServicesPage( 'manage_options' );
+
+ delete_option( 'edac_license_status' );
+ delete_option( 'edacp_license_status' );
+ delete_option( 'edac_site_id' );
+ delete_option( 'edac_license_error' );
+ delete_option( 'edacp_license_error' );
+ delete_option( 'edac_fallback_active' );
+ }
+
+ /**
+ * Clean up test state.
+ */
+ public function tearDown(): void {
+ delete_option( 'edac_license_status' );
+ delete_option( 'edacp_license_status' );
+ delete_option( 'edac_site_id' );
+ delete_option( 'edac_license_error' );
+ delete_option( 'edacp_license_error' );
+ delete_option( 'edac_fallback_active' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Invoke a private static method on the connected services page.
+ *
+ * @param string $method_name Method name.
+ * @param array $arguments Arguments.
+ * @return mixed
+ * @throws ReflectionException If reflection fails.
+ */
+ private function invoke_private_static_method( string $method_name, array $arguments = [] ) {
+ $reflection = new ReflectionClass( ConnectedServicesPage::class );
+ $method = $reflection->getMethod( $method_name );
+ $method->setAccessible( true );
+
+ return $method->invokeArgs( null, $arguments );
+ }
+
+ /**
+ * Invoke a private method on the connected services page.
+ *
+ * @param string $method_name Method name.
+ * @param array $arguments Arguments.
+ * @return mixed
+ * @throws ReflectionException If reflection fails.
+ */
+ private function invoke_private_method( string $method_name, array $arguments = [] ) {
+ $reflection = new ReflectionClass( ConnectedServicesPage::class );
+ $method = $reflection->getMethod( $method_name );
+ $method->setAccessible( true );
+
+ return $method->invokeArgs( $this->page, $arguments );
+ }
+
+ /**
+ * Ensures valid Pro remains authoritative when it is installed and connected.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_license_context_uses_pro_when_pro_is_valid() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_license_context',
+ [ true, 'valid', 'valid', 'site-123', false ]
+ );
+
+ $this->assertTrue( $context['has_pro_plugin'] );
+ $this->assertTrue( $context['is_pro'] );
+ $this->assertSame( 'valid', $context['status'] );
+ $this->assertTrue( $context['is_connected'] );
+ }
+
+ /**
+ * Ensures fallback to free state when Pro is installed but no longer valid.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_license_context_falls_back_to_free_when_pro_is_invalid() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_license_context',
+ [ true, 'expired', 'valid', 'site-123', false ]
+ );
+
+ $this->assertTrue( $context['has_pro_plugin'] );
+ $this->assertFalse( $context['is_pro'] );
+ $this->assertSame( 'valid', $context['status'] );
+ $this->assertTrue( $context['is_connected'] );
+ }
+
+ /**
+ * Ensures notice context uses free error and reports tab when Pro is not effective.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_error_context_uses_free_when_effective_license_is_not_pro() {
+ update_option( 'edac_license_error', 'invalid' );
+ update_option( 'edacp_license_error', 'expired' );
+
+ $context = $this->invoke_private_static_method( 'resolve_error_context', [ false ] );
+
+ $this->assertSame( 'invalid', $context['error'] );
+ $this->assertSame( 'accessibility-reports', $context['tab'] );
+ }
+
+ /**
+ * Ensures notice context uses Pro error and license tab when Pro is effective.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_error_context_uses_pro_when_effective_license_is_pro() {
+ update_option( 'edac_license_error', 'invalid' );
+ update_option( 'edacp_license_error', 'expired' );
+
+ $context = $this->invoke_private_static_method( 'resolve_error_context', [ true ] );
+
+ $this->assertSame( 'expired', $context['error'] );
+ $this->assertSame( 'license', $context['tab'] );
+ }
+
+ /**
+ * Ensures dynamic error context follows effective license authority.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_get_error_context_follows_effective_license_context() {
+ update_option( 'edac_license_status', 'valid' );
+ update_option( 'edacp_license_status', 'expired' );
+ update_option( 'edac_site_id', 'site-123' );
+ update_option( 'edac_license_error', 'invalid' );
+ update_option( 'edacp_license_error', 'expired' );
+
+ $context = $this->invoke_private_method( 'get_error_context' );
+
+ $this->assertSame( 'invalid', $context['error'] );
+ $this->assertSame( 'accessibility-reports', $context['tab'] );
+ }
+
+ /**
+ * Ensures fallback marker does not disconnect reports when Free is valid and the site remains connected.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_license_context_keeps_free_fallback_connected_when_site_id_exists() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_license_context',
+ [ true, 'expired', 'valid', 'site-123', true ]
+ );
+
+ $this->assertFalse( $context['is_pro'] );
+ $this->assertSame( 'valid', $context['status'] );
+ $this->assertTrue( $context['is_connected'] );
+ }
+
+ /**
+ * Ensures degraded notice context is shown when Pro is invalid and Free is valid.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_degraded_notice_context_connected_mode() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_degraded_notice_context',
+ [ true, false, 'expired', 'valid', true, false ]
+ );
+
+ $this->assertTrue( $context['show'] );
+ $this->assertSame( 'connected', $context['mode'] );
+ }
+
+ /**
+ * Ensures degraded notice context switches to reconnect mode while fallback is active.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_degraded_notice_context_reconnect_mode_during_fallback() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_degraded_notice_context',
+ [ true, false, 'expired', 'valid', false, true ]
+ );
+
+ $this->assertTrue( $context['show'] );
+ $this->assertSame( 'reconnect', $context['mode'] );
+ }
+
+ /**
+ * Ensures degraded notice does not show when Pro is still authoritative.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_resolve_degraded_notice_context_hidden_when_pro_is_valid() {
+ $context = $this->invoke_private_static_method(
+ 'resolve_degraded_notice_context',
+ [ true, true, 'valid', 'valid', true, false ]
+ );
+
+ $this->assertFalse( $context['show'] );
+ $this->assertSame( '', $context['mode'] );
+ }
+
+ /**
+ * Ensures degraded notice message explains connected degraded mode.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_get_degraded_notice_message_connected_mode() {
+ update_option( 'edacp_license_status', 'expired' );
+ update_option( 'edac_fallback_active', false );
+
+ $message = $this->invoke_private_method(
+ 'get_degraded_notice_message',
+ [
+ [
+ 'has_pro_plugin' => true,
+ 'is_pro' => false,
+ 'status' => 'valid',
+ 'is_connected' => true,
+ ],
+ ]
+ );
+
+ $this->assertIsString( $message );
+ $this->assertStringContainsString( 'Free email reports', $message );
+ }
+
+ /**
+ * Ensures degraded notice message explains reconnect mode during fallback.
+ *
+ * @throws ReflectionException If reflection fails.
+ */
+ public function test_get_degraded_notice_message_reconnect_mode() {
+ update_option( 'edacp_license_status', 'expired' );
+ update_option( 'edac_fallback_active', true );
+
+ $message = $this->invoke_private_method(
+ 'get_degraded_notice_message',
+ [
+ [
+ 'has_pro_plugin' => true,
+ 'is_pro' => false,
+ 'status' => 'valid',
+ 'is_connected' => false,
+ ],
+ ]
+ );
+
+ $this->assertIsString( $message );
+ $this->assertStringContainsString( 'not currently connected', $message );
+ }
+
+ /**
+ * Ensures free connected services renderer ignores the Pro license tab.
+ */
+ public function test_maybe_render_tab_content_does_not_render_on_license_tab() {
+ ob_start();
+ $this->page->maybe_render_tab_content( 'license' );
+ $output = ob_get_clean();
+
+ $this->assertSame( '', $output );
+ }
+
+ /**
+ * Ensures degraded-state notice can be injected into the Pro license page hook.
+ */
+ public function test_render_pro_license_degraded_notice_outputs_when_pro_is_invalid_and_free_is_valid() {
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', 'test-pro-version' );
+ }
+
+ update_option( 'edacp_license_status', 'expired' );
+ update_option( 'edac_license_status', 'valid' );
+ update_option( 'edac_site_id', 'site-123' );
+ update_option( 'edac_fallback_active', false );
+
+ ob_start();
+ $this->page->render_pro_license_degraded_notice();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'Free email reports', $output );
+ $this->assertStringNotContainsString( 'Pro License Degraded to Free', $output );
+ }
+
+ /**
+ * Ensures connected services shows a connected-as-free state instead of the free license key form during degraded fallback.
+ */
+ public function test_render_page_shows_connected_as_free_in_degraded_connected_state() {
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', 'test-pro-version' );
+ }
+
+ update_option( 'edacp_license_status', 'expired' );
+ update_option( 'edac_license_status', 'valid' );
+ update_option( 'edac_site_id', 'site-123' );
+ update_option( 'edac_fallback_active', true );
+
+ ob_start();
+ $this->page->render_page();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'Connected as Free', $output );
+ $this->assertStringNotContainsString( 'Free License Key', $output );
+ }
+}
diff --git a/tests/phpunit/includes/classes/MyDot/ConnectorTest.php b/tests/phpunit/includes/classes/MyDot/ConnectorTest.php
new file mode 100644
index 000000000..f6f21d32d
--- /dev/null
+++ b/tests/phpunit/includes/classes/MyDot/ConnectorTest.php
@@ -0,0 +1,656 @@
+getMethod( $method_name );
+ $method->setAccessible( true );
+
+ return $method->invokeArgs( null, $arguments );
+ }
+
+ /**
+ * Build a mocked HTTP response payload for pre_http_request.
+ *
+ * @param array $body Response body data.
+ * @return Closure
+ */
+ private function mock_http_response( array $body ) {
+ return function () use ( $body ) {
+ return [
+ 'headers' => [],
+ 'body' => wp_json_encode( $body ),
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ ];
+ };
+ }
+
+ /**
+ * Ensures the free product ID is exposed correctly.
+ */
+ public function test_get_free_product_id_returns_expected_id() {
+ $this->assertSame( 1666, Connector::get_free_product_id() );
+ }
+
+ /**
+ * Ensures metadata is stored as a single array option for free licenses.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_store_license_metadata_saves_single_array_option_for_free_license() {
+ $license_data = (object) [
+ 'item_id' => 1666,
+ 'item_name' => 'Accessibility Checker Free',
+ 'license_limit' => 1,
+ 'expires' => '2026-12-31 00:00:00',
+ 'site_count' => '1',
+ 'activations_left' => '0',
+ ];
+
+ $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] );
+
+ $metadata = get_option( 'edac_license_metadata' );
+
+ $this->assertIsArray( $metadata );
+ $this->assertSame( 'free', $metadata['type'] );
+ $this->assertSame( 'single-site', $metadata['level'] );
+ $this->assertSame( 1666, $metadata['item_id'] );
+ $this->assertSame( 'Accessibility Checker Free', $metadata['item_name'] );
+ $this->assertSame( '2026-12-31 00:00:00', $metadata['expires'] );
+ $this->assertSame( '1', $metadata['license_limit'] );
+ $this->assertSame( '1', $metadata['site_count'] );
+ $this->assertSame( '0', $metadata['activations_left'] );
+ $this->assertIsInt( $metadata['last_response_at'] );
+ // Verify individual keys are NOT scattered across separate wp_options.
+ $this->assertFalse( get_option( 'edac_license_type', false ) );
+ $this->assertFalse( get_option( 'edac_license_item_id', false ) );
+ }
+
+ /**
+ * Ensures EDD license_limit of 0 (no activation cap) maps to 'unlimited', not 'single-site'.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_store_license_metadata_treats_zero_limit_as_unlimited() {
+ $license_data = (object) [
+ 'item_id' => 1666,
+ 'item_name' => 'Accessibility Checker Free',
+ 'license_limit' => 0, // EDD uses 0 to mean no limit.
+ ];
+
+ $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] );
+
+ $metadata = get_option( 'edac_license_metadata' );
+
+ $this->assertSame( 'unlimited', $metadata['level'] );
+ }
+
+ /**
+ * Ensures multi-site level is inferred correctly for limits > 1.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_store_license_metadata_treats_limit_above_one_as_multi_site() {
+ $license_data = (object) [
+ 'item_id' => 1666,
+ 'item_name' => 'Accessibility Checker Free',
+ 'license_limit' => 5,
+ ];
+
+ $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] );
+
+ $metadata = get_option( 'edac_license_metadata' );
+
+ $this->assertSame( 'multi-site', $metadata['level'] );
+ }
+
+ /**
+ * Ensures Pro item IDs can still be inferred from the filtered product ID.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_store_license_metadata_infers_pro_type_from_filtered_product_id() {
+ add_filter(
+ 'edac_pro_product_id',
+ static function () {
+ return 24;
+ }
+ );
+
+ $license_data = (object) [
+ 'item_id' => 24,
+ 'item_name' => 'Accessibility Checker',
+ 'license_limit' => '5',
+ ];
+
+ $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] );
+
+ $metadata = get_option( 'edac_license_metadata' );
+
+ $this->assertSame( 'pro', $metadata['type'] );
+ $this->assertSame( 'multi-site', $metadata['level'] );
+ $this->assertSame( 24, $metadata['item_id'] );
+ }
+
+ /**
+ * Ensures fallback inference still works when only item name identifies the license.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_store_license_metadata_falls_back_to_item_name_and_lifetime_level() {
+ $license_data = (object) [
+ 'item_id' => 0,
+ 'item_name' => 'Accessibility Checker Pro',
+ 'license_limit' => 'lifetime',
+ ];
+
+ $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] );
+
+ $metadata = get_option( 'edac_license_metadata' );
+
+ $this->assertSame( 'pro', $metadata['type'] );
+ $this->assertSame( 'lifetime', $metadata['level'] );
+ }
+
+ /**
+ * Ensures clearing metadata removes the single option.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_clear_stored_license_metadata_deletes_single_option() {
+ update_option(
+ 'edac_license_metadata',
+ [
+ 'type' => 'free',
+ ]
+ );
+
+ $this->invoke_private_static_method( 'clear_stored_license_metadata' );
+
+ $this->assertFalse( get_option( 'edac_license_metadata', false ) );
+ }
+
+ /**
+ * Ensures the public periodic license check stores metadata in the single option.
+ */
+ public function test_periodic_check_license_updates_status_and_metadata_option() {
+ $connector = new Connector();
+ $filter = $this->mock_http_response(
+ [
+ 'license' => 'valid',
+ 'item_id' => 1666,
+ 'item_name' => 'Accessibility Checker Free',
+ 'license_limit' => '1',
+ 'expires' => '2026-12-31 00:00:00',
+ 'site_count' => '1',
+ 'activations_left' => '0',
+ ]
+ );
+
+ update_option( 'edacp_license_key', 'free-license-key' );
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $connector->periodic_check_license();
+
+ $metadata = get_option( 'edac_license_metadata' );
+
+ $this->assertSame( 'valid', get_option( 'edac_license_status' ) );
+ $this->assertIsArray( $metadata );
+ $this->assertSame( 'free', $metadata['type'] );
+ $this->assertSame( 1666, $metadata['item_id'] );
+ $this->assertSame( 'single-site', $metadata['level'] );
+ $this->assertFalse( get_option( 'edac_fallback_active', false ) );
+ }
+
+ /**
+ * Ensures free plugin's activate_license() does not run when Pro is installed with valid license.
+ *
+ * This prevents the free activation form from overwriting Pro license state.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_activate_license_bails_when_pro_is_active_with_valid_license() {
+ // Simulate Pro plugin installed with valid license.
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', '1.19.0' );
+ }
+ update_option( 'edacp_license_status', 'valid' );
+ update_option( 'edacp_license_key', 'free-license-key' );
+
+ // Create a new Connector instance and call activate_license via reflection.
+ $connector = new Connector();
+ $reflection = new ReflectionClass( Connector::class );
+ $method = $reflection->getMethod( 'activate_license' );
+ $method->setAccessible( true );
+
+ // Invoke activate_license.
+ $method->invoke( $connector );
+
+ // Verify error was set and no HTTP request was attempted.
+ $error = get_option( 'edac_license_error' );
+ $this->assertNotEmpty( $error );
+ $this->assertStringContainsString( 'Pro license is active', $error );
+
+ // Verify metadata was NOT updated (no HTTP call happened).
+ $this->assertFalse( get_option( 'edac_license_metadata', false ) );
+ }
+
+ /**
+ * Ensures free plugin's periodic_check_license resumes when Pro is defined but license is not valid.
+ *
+ * This enables automatic fallback from Pro to free when Pro's license expires or is disabled.
+ */
+ public function test_periodic_check_license_resumes_when_pro_license_becomes_invalid() {
+ // Simulate Pro plugin installed but with expired license.
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', '1.19.0' );
+ }
+ update_option( 'edacp_license_status', 'expired' ); // Pro license is no longer valid.
+ update_option( 'edacp_license_key', 'free-license-key' );
+
+ $connector = new Connector();
+ $filter = $this->mock_http_response(
+ [
+ 'license' => 'valid',
+ 'item_id' => 1666,
+ 'item_name' => 'Accessibility Checker Free',
+ 'license_limit' => 1,
+ 'expires' => '2026-12-31 00:00:00',
+ 'site_count' => '1',
+ 'activations_left' => '0',
+ ]
+ );
+
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ // Call periodic_check_license — it should NOT bail even though Pro is defined.
+ $connector->periodic_check_license();
+
+ $metadata = get_option( 'edac_license_metadata' );
+
+ // Verify free plugin checked and stored metadata (type: 'free', not Pro).
+ $this->assertIsArray( $metadata );
+ $this->assertSame( 'free', $metadata['type'] );
+ $this->assertSame( 1666, $metadata['item_id'] );
+ $this->assertSame( 'valid', get_option( 'edac_license_status' ) );
+ }
+
+ /**
+ * Ensures free plugin's deactivate_license clears all expected options.
+ *
+ * @throws ReflectionException If the method cannot be reflected.
+ */
+ public function test_deactivate_license_clears_all_options() {
+ // Set up initial state with all license-related options.
+ update_option( 'edacp_license_key', 'test-key' );
+ update_option( 'edacp_license_status', 'valid' );
+ update_option( 'edacp_license_error', 'test-pro-error' );
+ update_option( 'edac_license_status', 'valid' );
+ update_option( 'edac_license_error', 'test-error' );
+ update_option( 'edac_license_metadata', [ 'type' => 'free' ] );
+ update_option( 'edac_site_id', 'test-site-id' );
+ update_option( 'edac_jwt_public_key', 'test-public-key' );
+ update_option( 'edac_collection_interval_days', '7' );
+ update_option( 'edac_next_collection', '2026-04-22' );
+ update_option( 'edac_fallback_active', 1 );
+
+ // Deactivate via reflection.
+ $connector = new Connector();
+ $reflection = new ReflectionClass( Connector::class );
+ $method = $reflection->getMethod( 'deactivate_license' );
+ $method->setAccessible( true );
+ $method->invoke( $connector );
+
+ // Verify all options are cleared.
+ $this->assertFalse( get_option( 'edacp_license_key', false ) );
+ $this->assertFalse( get_option( 'edacp_license_status', false ) );
+ $this->assertFalse( get_option( 'edacp_license_error', false ) );
+ $this->assertFalse( get_option( 'edac_license_status', false ) );
+ $this->assertFalse( get_option( 'edac_license_error', false ) );
+ $this->assertFalse( get_option( 'edac_license_metadata', false ) );
+ $this->assertFalse( get_option( 'edac_site_id', false ) );
+ $this->assertFalse( get_option( 'edac_jwt_public_key', false ) );
+ $this->assertFalse( get_option( 'edac_collection_interval_days', false ) );
+ $this->assertFalse( get_option( 'edac_next_collection', false ) );
+ $this->assertFalse( get_option( 'edac_fallback_active', false ) );
+ }
+
+ /**
+ * Ensures that when Pro license expires and free resumes, no automatic enrollment occurs.
+ *
+ * The free plugin should resume checking the license, but NOT automatically
+ * register the site for email reports without explicit user action.
+ */
+ public function test_periodic_check_license_fallback_does_not_auto_enroll() {
+ // Simulate Pro installed but expired.
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', '1.19.0' );
+ }
+ update_option( 'edacp_license_status', 'expired' );
+ update_option( 'edacp_license_key', 'free-license-key' );
+
+ // Mock HTTP response.
+ $filter = $this->mock_http_response(
+ [
+ 'license' => 'valid',
+ 'item_id' => 1666,
+ 'item_name' => 'Accessibility Checker Free',
+ 'license_limit' => 1,
+ 'expires' => '2026-12-31 00:00:00',
+ 'site_count' => '1',
+ 'activations_left' => '0',
+ ]
+ );
+
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $connector = new Connector();
+
+
+ $connector->periodic_check_license();
+
+ // Verify metadata was stored (license check succeeded).
+ $metadata = get_option( 'edac_license_metadata' );
+ $this->assertIsArray( $metadata );
+ $this->assertSame( 'free', $metadata['type'] );
+
+ // Verify that site ID is still empty (no enrollment happened).
+ $site_id = get_option( 'edac_site_id', false );
+ $this->assertFalse( $site_id, 'Site should not be auto-registered during fallback' );
+ }
+
+ /**
+ * Ensures fallback marker is cleared when free periodic check validates successfully.
+ */
+ public function test_periodic_check_license_clears_fallback_marker_when_free_becomes_valid() {
+ update_option( 'edac_fallback_active', 1 );
+ update_option( 'edacp_license_key', 'free-license-key' );
+
+ $filter = $this->mock_http_response(
+ [
+ 'license' => 'valid',
+ 'item_id' => 1666,
+ 'item_name' => 'Accessibility Checker Free',
+ 'license_limit' => '1',
+ 'expires' => '2026-12-31 00:00:00',
+ 'site_count' => '1',
+ 'activations_left' => '0',
+ ]
+ );
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $connector = new Connector();
+ $connector->periodic_check_license();
+
+ $this->assertFalse( get_option( 'edac_fallback_active', false ) );
+ }
+
+ /**
+ * Ensures Pro activation hook refreshes existing registration context.
+ */
+ public function test_handle_pro_license_activation_refreshes_registration_when_already_connected() {
+ update_option( 'edac_site_id', 'existing-site-id' );
+ update_option( 'edacp_license_key', 'pro-license-key' );
+
+ add_filter(
+ 'pre_http_request',
+ function ( $preempt, $args, $url ) {
+ if ( false === strpos( $url, '/wp-json/myed-email-reports/v1/register-site' ) ) {
+ return $preempt;
+ }
+
+ $body = json_decode( $args['body'], true );
+ if ( empty( $body['license_key'] ) || 'pro-license-key' !== $body['license_key'] ) {
+ return [
+ 'headers' => [],
+ 'body' => wp_json_encode(
+ [
+ 'success' => false,
+ 'message' => 'Unexpected request body',
+ ]
+ ),
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ ];
+ }
+
+ return [
+ 'headers' => [],
+ 'body' => wp_json_encode(
+ [
+ 'success' => true,
+ 'data' => [
+ 'site_id' => 'pro-site-id',
+ 'jwt_public_key' => 'pro-public-key',
+ 'collection_interval_days' => '7',
+ 'next_collection' => '2030-01-01',
+ ],
+ ]
+ ),
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ ];
+ },
+ 10,
+ 3
+ );
+
+ $connector = new Connector();
+ $connector->handle_pro_license_activation( 'pro-license-key', home_url(), (object) [ 'license' => 'valid' ] );
+
+ $this->assertSame( 'pro-site-id', get_option( 'edac_site_id' ) );
+ $this->assertSame( 'pro-public-key', get_option( 'edac_jwt_public_key' ) );
+ $this->assertSame( '7', get_option( 'edac_collection_interval_days' ) );
+ $this->assertSame( '2030-01-01', get_option( 'edac_next_collection' ) );
+ }
+
+ /**
+ * Ensures missing-data unregistration preserves active Pro license state.
+ */
+ public function test_handle_site_unregistration_preserves_active_pro_license_when_registration_data_is_missing() {
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', '1.19.0' );
+ }
+
+ update_option( 'edacp_license_key', 'test-key' );
+ update_option( 'edacp_license_status', 'valid' );
+ update_option( 'edacp_license_error', 'test-pro-error' );
+ update_option( 'edac_license_status', 'valid' );
+ update_option( 'edac_site_id', '' );
+
+ $connector = new Connector();
+ $connector->handle_site_unregistration();
+
+ $this->assertSame( 'test-key', get_option( 'edacp_license_key' ) );
+ $this->assertSame( 'valid', get_option( 'edacp_license_status' ) );
+ $this->assertSame( 'test-pro-error', get_option( 'edacp_license_error' ) );
+ $this->assertFalse( get_option( 'edac_site_id', false ) );
+ }
+
+ /**
+ * Ensures free-authority unregistration clears the shared key even when data is missing.
+ */
+ public function test_handle_site_unregistration_clears_free_license_state_when_registration_data_is_missing() {
+ update_option( 'edacp_license_key', 'free-key' );
+ update_option( 'edac_license_status', 'valid' );
+ update_option( 'edac_site_id', '' );
+
+ $connector = new Connector();
+ $connector->handle_site_unregistration();
+
+ $this->assertFalse( get_option( 'edacp_license_key', false ) );
+ $this->assertFalse( get_option( 'edac_license_status', false ) );
+ }
+
+ /**
+ * Ensures active Pro license is preserved even when remote unregistration fails.
+ */
+ public function test_handle_site_unregistration_preserves_active_pro_license_on_remote_failure() {
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', '1.19.0' );
+ }
+
+ update_option( 'edacp_license_key', 'pro-key' );
+ update_option( 'edacp_license_status', 'valid' );
+ update_option( 'edac_site_id', 'existing-site-id' );
+ update_option( 'edac_jwt_public_key', 'public-key' );
+
+ add_filter(
+ 'pre_http_request',
+ function ( $preempt, $args, $url ) {
+ if ( false === strpos( $url, '/wp-json/myed-email-reports/v1/unregister-site' ) ) {
+ return $preempt;
+ }
+
+ return [
+ 'headers' => [],
+ 'body' => wp_json_encode(
+ [
+ 'success' => false,
+ 'message' => 'Remote failure',
+ ]
+ ),
+ 'response' => [
+ 'code' => 500,
+ 'message' => 'Server Error',
+ ],
+ ];
+ },
+ 10,
+ 3
+ );
+
+ $connector = new Connector();
+ $connector->handle_site_unregistration();
+
+ $this->assertSame( 'pro-key', get_option( 'edacp_license_key' ) );
+ $this->assertSame( 'valid', get_option( 'edacp_license_status' ) );
+ $this->assertFalse( get_option( 'edac_site_id', false ) );
+ $this->assertFalse( get_option( 'edac_jwt_public_key', false ) );
+ }
+
+ /**
+ * Ensures hook-provided license key is used for unregistration when option key is missing.
+ */
+ public function test_handle_site_unregistration_uses_hook_license_when_stored_key_missing() {
+ if ( ! defined( 'EDACP_VERSION' ) ) {
+ define( 'EDACP_VERSION', '1.19.0' );
+ }
+
+ update_option( 'edacp_license_status', 'valid' );
+ update_option( 'edac_site_id', 'existing-site-id' );
+
+ add_filter(
+ 'pre_http_request',
+ function ( $preempt, $args, $url ) {
+ if ( false === strpos( $url, '/wp-json/myed-email-reports/v1/unregister-site' ) ) {
+ return $preempt;
+ }
+
+ $body = json_decode( $args['body'], true );
+ if ( empty( $body['license_key'] ) || 'hook-key' !== $body['license_key'] ) {
+ return [
+ 'headers' => [],
+ 'body' => wp_json_encode(
+ [
+ 'success' => false,
+ 'message' => 'Unexpected license key',
+ ]
+ ),
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ ];
+ }
+
+ return [
+ 'headers' => [],
+ 'body' => wp_json_encode(
+ [
+ 'success' => true,
+ 'data' => [],
+ ]
+ ),
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ ];
+ },
+ 10,
+ 3
+ );
+
+ $connector = new Connector();
+ $connector->handle_site_unregistration( 'hook-key', home_url(), (object) [ 'license' => 'deactivated' ] );
+
+ $this->assertFalse( get_option( 'edac_site_id', false ) );
+ }
+}