diff --git a/includes/scanner/class-pr-core-verification-scanner.php b/includes/scanner/class-pr-core-verification-scanner.php index 1d5dafe..6c9201b 100644 --- a/includes/scanner/class-pr-core-verification-scanner.php +++ b/includes/scanner/class-pr-core-verification-scanner.php @@ -45,20 +45,8 @@ class PR_Core_Verification_Scanner { continue; } - // Compute days since verification. - $days_since = ( time() - strtotime( $last_verified ) ) / DAY_IN_SECONDS; - - // Determine threshold based on velocity. - $threshold = match ( $velocity ) { - 'high' => (int) get_option( 'pr_core_high_velocity_threshold', self::HIGH_VELOCITY_THRESHOLD ), - 'low' => self::LOW_VELOCITY_THRESHOLD, - default => (int) get_option( 'pr_core_default_threshold', self::DEFAULT_THRESHOLD_DAYS ), - }; - - // Compute status: current if < 90%, due if < 100%, overdue if >= 100%. - $status = $days_since < ( $threshold * 0.9 ) - ? 'current' - : ( $days_since < $threshold ? 'due' : 'overdue' ); + // Delegate to compute_status() — keeps logic testable without DB calls. + $status = self::compute_status( $last_verified, $velocity ); update_post_meta( $dto->id, '_pr_verification_status', $status ); @@ -78,6 +66,33 @@ class PR_Core_Verification_Scanner { } } + + /** + * Compute verification status for a single peptide. + * + * Extracted for direct unit-testability without database or WP-cron dependencies. + * + * @param string $last_verified YYYY-MM-DD date of last source verification. Empty = overdue. + * @param string $velocity Velocity tier: high | medium | low. + * @return string Status: current | due | overdue. + */ + public static function compute_status( string $last_verified, string $velocity ): string { + if ( empty( $last_verified ) ) { + return 'overdue'; + } + + $days_since = ( time() - strtotime( $last_verified ) ) / DAY_IN_SECONDS; + $threshold = match ( $velocity ) { + 'high' => (int) get_option( 'pr_core_high_velocity_threshold', self::HIGH_VELOCITY_THRESHOLD ), + 'low' => self::LOW_VELOCITY_THRESHOLD, + default => (int) get_option( 'pr_core_default_threshold', self::DEFAULT_THRESHOLD_DAYS ), + }; + + return $days_since < ( $threshold * 0.9 ) + ? 'current' + : ( $days_since < $threshold ? 'due' : 'overdue' ); + } + /** * Log scan summary to WP option, keeping last N entries. * diff --git a/tests/unit/test-verification-scanner.php b/tests/unit/test-verification-scanner.php index 20cccee..369dd67 100644 --- a/tests/unit/test-verification-scanner.php +++ b/tests/unit/test-verification-scanner.php @@ -1,134 +1,128 @@ = 90% and < 100% of threshold → due. + * - Days >= threshold → overdue. + * - Boundary: exactly at 90% → due (not current). + * - high velocity applies 60-day threshold from option. + * - low velocity applies 365-day constant (not option). + * - medium/default applies 180-day threshold from option. + * + * @package PeptideRepoCore + */ + declare(strict_types=1); -/** - * Tests for PR_Core_Verification_Scanner. - * - * What: Unit tests for status computation logic. - * Dependencies: PHPUnit, WordPress test utilities. - * - * Mock strategy: Zero real DB calls — mock get_option() and update_post_meta(). - */ -class Test_Verification_Scanner extends \WP_UnitTestCase { +require_once __DIR__ . '/../bootstrap.php'; - /** - * Test current status when days_since < threshold * 0.9. - */ - public function test_status_current(): void { - // Days since = 160, threshold = 180, 90% = 162. - // 160 < 162 => current. - $last_verified = gmdate( 'Y-m-d H:i:s', time() - ( 160 * DAY_IN_SECONDS ) ); +// ── Additional stubs needed by the scanner ────────────────────────────── - // Mock the repository and post meta. - $dto = $this->create_mock_dto( 1, $last_verified, 'medium' ); +if ( ! defined( 'DAY_IN_SECONDS' ) ) { + define( 'DAY_IN_SECONDS', 86400 ); +} - $status = $this->compute_status( $dto, 180, 'medium' ); - $this->assertEquals( 'current', $status ); - } +/** @var array $GLOBALS['pr_core_options'] Mocked option store. */ +$GLOBALS['pr_core_options'] = []; - /** - * Test due status when threshold * 0.9 <= days_since < threshold. - */ - public function test_status_due(): void { - // Days since = 170, threshold = 180, 90% = 162. - // 162 <= 170 < 180 => due. - $last_verified = gmdate( 'Y-m-d H:i:s', time() - ( 170 * DAY_IN_SECONDS ) ); - - $dto = $this->create_mock_dto( 2, $last_verified, 'medium' ); - $status = $this->compute_status( $dto, 180, 'medium' ); - $this->assertEquals( 'due', $status ); - } - - /** - * Test overdue status when days_since >= threshold. - */ - public function test_status_overdue(): void { - // Days since = 190, threshold = 180. - // 190 >= 180 => overdue. - $last_verified = gmdate( 'Y-m-d H:i:s', time() - ( 190 * DAY_IN_SECONDS ) ); - - $dto = $this->create_mock_dto( 3, $last_verified, 'medium' ); - $status = $this->compute_status( $dto, 180, 'medium' ); - $this->assertEquals( 'overdue', $status ); - } - - /** - * Test empty verified date => overdue. - */ - public function test_empty_verified_date_is_overdue(): void { - $dto = $this->create_mock_dto( 4, '', 'medium' ); - $status = $this->compute_status( $dto, 180, 'medium', true ); - $this->assertEquals( 'overdue', $status ); - } - - /** - * Test high velocity threshold. - */ - public function test_high_velocity_threshold(): void { - // Days since = 70, high threshold = 60, 90% = 54. - // 54 <= 70 => due (not current even with high velocity). - $last_verified = gmdate( 'Y-m-d H:i:s', time() - ( 70 * DAY_IN_SECONDS ) ); - - $dto = $this->create_mock_dto( 5, $last_verified, 'high' ); - $status = $this->compute_status( $dto, 60, 'high' ); - $this->assertEquals( 'due', $status ); - } - - /** - * Test low velocity threshold. - */ - public function test_low_velocity_threshold(): void { - // Days since = 350, low threshold = 365, 90% = 328.5. - // 328.5 < 350 < 365 => due. - $last_verified = gmdate( 'Y-m-d H:i:s', time() - ( 350 * DAY_IN_SECONDS ) ); - - $dto = $this->create_mock_dto( 6, $last_verified, 'low' ); - $status = $this->compute_status( $dto, 365, 'low' ); - $this->assertEquals( 'due', $status ); - } - - /** - * Create a mock DTO. - * - * @param int $id Post ID. - * @param string $last_verified Verified date (or empty). - * @param string $velocity Velocity level. - * @return object Mock DTO. - */ - private function create_mock_dto( int $id, string $last_verified, string $velocity ): object { - $dto = new \stdClass(); - $dto->id = $id; - $dto->title = "Test Peptide $id"; - $dto->excerpt = ''; - $dto->content = ''; - - // Store in post meta for the computation. - update_post_meta( $id, '_pr_last_source_verified', $last_verified ); - update_post_meta( $id, '_pr_verification_velocity', $velocity ); - - return $dto; - } - - /** - * Compute status using the scanner logic inline (no DB calls beyond meta). - * - * @param object $dto Peptide DTO. - * @param int $threshold Threshold in days. - * @param string $velocity Velocity level. - * @param bool $empty_date Whether date is empty. - * @return string Status. - */ - private function compute_status( object $dto, int $threshold, string $velocity, bool $empty_date = false ): string { - $last_verified = get_post_meta( $dto->id, '_pr_last_source_verified', true ); - - if ( $empty_date || empty( $last_verified ) ) { - return 'overdue'; - } - - $days_since = ( time() - strtotime( $last_verified ) ) / DAY_IN_SECONDS; - - return $days_since < ( $threshold * 0.9 ) - ? 'current' - : ( $days_since < $threshold ? 'due' : 'overdue' ); +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $key, $default = false ) { + return $GLOBALS['pr_core_options'][ $key ] ?? $default; } } + +// ── Load the scanner class ────────────────────────────────────────────── + +require_once __DIR__ . '/../../includes/scanner/class-pr-core-verification-scanner.php'; + +// ── Helper: build a date N days ago ──────────────────────────────────── + +function days_ago( int $days ): string { + return gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); +} + +echo "== PR_Core_Verification_Scanner::compute_status() unit tests ==\n\n"; + +// ── Empty last_verified ───────────────────────────────────────────────── + +echo "Empty last_verified:\n"; +pr_assert_equals( + 'overdue', + PR_Core_Verification_Scanner::compute_status( '', 'medium' ), + 'empty string → overdue' +); + +// ── Medium velocity (default 180-day threshold) ───────────────────────── + +echo "\nMedium velocity (threshold=180, option default):\n"; +$GLOBALS['pr_core_options'] = []; + +// 160 days ago → 160 < 162 (90% of 180) → current. +pr_assert_equals( 'current', PR_Core_Verification_Scanner::compute_status( days_ago( 160 ), 'medium' ), '160 days → current' ); + +// 163 days ago → 163 >= 162 and < 180 → due. +pr_assert_equals( 'due', PR_Core_Verification_Scanner::compute_status( days_ago( 163 ), 'medium' ), '163 days → due' ); + +// 180 days ago → 180 >= 180 → overdue. +pr_assert_equals( 'overdue', PR_Core_Verification_Scanner::compute_status( days_ago( 180 ), 'medium' ), '180 days → overdue' ); + +// 200 days ago → overdue. +pr_assert_equals( 'overdue', PR_Core_Verification_Scanner::compute_status( days_ago( 200 ), 'medium' ), '200 days → overdue' ); + +// ── 90% boundary exactness ────────────────────────────────────────────── + +echo "\n90% boundary (threshold=100):\n"; +$GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 100 ]; + +// 89 days → 89 < 90 (90% of 100) → current. +pr_assert_equals( 'current', PR_Core_Verification_Scanner::compute_status( days_ago( 89 ), 'medium' ), '89 days (threshold 100) → current' ); + +// 90 days → 90 >= 90 and < 100 → due. +pr_assert_equals( 'due', PR_Core_Verification_Scanner::compute_status( days_ago( 90 ), 'medium' ), '90 days (threshold 100) → due (at boundary)' ); + +// 100 days → 100 >= 100 → overdue. +pr_assert_equals( 'overdue', PR_Core_Verification_Scanner::compute_status( days_ago( 100 ), 'medium' ), '100 days (threshold 100) → overdue' ); + +// ── High velocity (60-day threshold from option) ──────────────────────── + +echo "\nHigh velocity (threshold=60 from option):\n"; +$GLOBALS['pr_core_options'] = [ 'pr_core_high_velocity_threshold' => 60 ]; + +// 50 days → 50 < 54 (90% of 60) → current. +pr_assert_equals( 'current', PR_Core_Verification_Scanner::compute_status( days_ago( 50 ), 'high' ), 'high: 50 days → current' ); + +// 55 days → 55 >= 54, < 60 → due. +pr_assert_equals( 'due', PR_Core_Verification_Scanner::compute_status( days_ago( 55 ), 'high' ), 'high: 55 days → due' ); + +// 61 days → overdue. +pr_assert_equals( 'overdue', PR_Core_Verification_Scanner::compute_status( days_ago( 61 ), 'high' ), 'high: 61 days → overdue' ); + +// ── Low velocity (365-day constant — ignores option) ──────────────────── + +echo "\nLow velocity (threshold=365, constant, ignores option):\n"; +$GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 999 ]; // Should be ignored for low. + +// 300 days → 300 < 328.5 (90% of 365) → current. +pr_assert_equals( 'current', PR_Core_Verification_Scanner::compute_status( days_ago( 300 ), 'low' ), 'low: 300 days → current' ); + +// 340 days → 340 >= 328.5, < 365 → due. +pr_assert_equals( 'due', PR_Core_Verification_Scanner::compute_status( days_ago( 340 ), 'low' ), 'low: 340 days → due' ); + +// 366 days → overdue. +pr_assert_equals( 'overdue', PR_Core_Verification_Scanner::compute_status( days_ago( 366 ), 'low' ), 'low: 366 days → overdue' ); + +// ── Unknown velocity falls through to medium/default ──────────────────── + +echo "\nUnknown velocity (falls to default/medium):\n"; +$GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 180 ]; +pr_assert_equals( 'current', PR_Core_Verification_Scanner::compute_status( days_ago( 100 ), 'unknown' ), 'unknown velocity: 100 days → current (uses default 180)' ); + +// ── Summary ───────────────────────────────────────────────────────────── + +exit( pr_test_summary() ); diff --git a/tests/unit/test-verification-settings.php b/tests/unit/test-verification-settings.php index eb6cb29..b3cf0f6 100644 --- a/tests/unit/test-verification-settings.php +++ b/tests/unit/test-verification-settings.php @@ -1,93 +1,131 @@ assertNotFalse( $scheduled ); - - // Change the cadence (triggers reschedule via hook). - update_option( 'pr_core_scan_cadence', 'daily' ); - - // The reschedule_cron method should have cleared and rescheduled. - // (In a real test, this would be tested via the hook — here we verify the logic.) - PR_Core_Settings::reschedule_cron( 'weekly', 'daily' ); - - // Verify cron still exists (just with new frequency). - $scheduled = wp_next_scheduled( 'pr_core_verification_scan' ); - $this->assertNotFalse( $scheduled ); - } - - /** - * Test that same cadence does not reschedule. - */ - public function test_no_reschedule_if_unchanged(): void { - update_option( 'pr_core_scan_cadence', 'weekly' ); - - if ( ! wp_next_scheduled( 'pr_core_verification_scan' ) ) { - wp_schedule_event( time(), 'weekly', 'pr_core_verification_scan' ); - } - - $first_scheduled = wp_next_scheduled( 'pr_core_verification_scan' ); - - // Call reschedule with same value. - PR_Core_Settings::reschedule_cron( 'weekly', 'weekly' ); - - $second_scheduled = wp_next_scheduled( 'pr_core_verification_scan' ); - - // Should be unchanged (same cron hook). - $this->assertEquals( $first_scheduled, $second_scheduled ); - } - - /** - * Test cadence sanitization. - */ - public function test_sanitize_cadence(): void { - $this->assertEquals( 'daily', PR_Core_Settings::sanitize_cadence( 'daily' ) ); - $this->assertEquals( 'weekly', PR_Core_Settings::sanitize_cadence( 'weekly' ) ); - $this->assertEquals( 'monthly', PR_Core_Settings::sanitize_cadence( 'monthly' ) ); - $this->assertEquals( 'weekly', PR_Core_Settings::sanitize_cadence( 'invalid' ) ); - } - - /** - * Test email sanitization. - */ - public function test_sanitize_emails(): void { - $input = 'admin@example.com, editor@example.com'; - $output = PR_Core_Settings::sanitize_emails( $input ); - $this->assertStringContainsString( 'admin@example.com', $output ); - $this->assertStringContainsString( 'editor@example.com', $output ); - - // Invalid emails filtered out. - $input_bad = 'admin@example.com, invalid-email, editor@example.com'; - $output = PR_Core_Settings::sanitize_emails( $input_bad ); - $this->assertStringContainsString( 'admin@example.com', $output ); - $this->assertStringContainsString( 'editor@example.com', $output ); - $this->assertStringNotContainsString( 'invalid-email', $output ); +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $key, $default = false ) { + return $GLOBALS['pr_core_options'][ $key ] ?? $default; } } + +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $key, $value ): bool { + $GLOBALS['pr_core_options'][ $key ] = $value; + return true; + } +} + +if ( ! function_exists( 'register_setting' ) ) { + function register_setting( string $group, string $option, array $args = [] ): void {} +} + +if ( ! function_exists( 'add_settings_section' ) ) { + function add_settings_section( string $id, string $title, $cb, string $page ): void {} +} + +if ( ! function_exists( 'add_settings_field' ) ) { + function add_settings_field( string $id, string $title, $cb, string $page, string $section = '' ): void {} +} + +if ( ! function_exists( 'is_email' ) ) { + function is_email( string $email ): bool { + return (bool) filter_var( $email, FILTER_VALIDATE_EMAIL ); + } +} + +if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { + function wp_clear_scheduled_hook( string $hook ): void { + $GLOBALS['pr_core_cron_calls'][] = [ 'action' => 'clear', 'hook' => $hook ]; + } +} + +if ( ! function_exists( 'wp_schedule_event' ) ) { + function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): void { + $GLOBALS['pr_core_cron_calls'][] = [ 'action' => 'schedule', 'hook' => $hook, 'recurrence' => $recurrence ]; + } +} + +// ── Load the settings class ───────────────────────────────────────────── + +require_once __DIR__ . '/../../includes/cpt/class-pr-core-peptide-cpt.php'; +require_once __DIR__ . '/../../includes/admin/class-pr-core-settings.php'; + +echo "== PR_Core_Settings verification methods unit tests ==\n\n"; + +// ── sanitize_cadence() ────────────────────────────────────────────────── + +echo "sanitize_cadence():\n"; +pr_assert_equals( 'daily', PR_Core_Settings::sanitize_cadence( 'daily' ), 'daily → daily' ); +pr_assert_equals( 'weekly', PR_Core_Settings::sanitize_cadence( 'weekly' ), 'weekly → weekly' ); +pr_assert_equals( 'monthly', PR_Core_Settings::sanitize_cadence( 'monthly' ), 'monthly → monthly' ); +pr_assert_equals( 'weekly', PR_Core_Settings::sanitize_cadence( 'hourly' ), 'hourly (invalid) → weekly default' ); +pr_assert_equals( 'weekly', PR_Core_Settings::sanitize_cadence( '' ), 'empty → weekly default' ); +pr_assert_equals( 'weekly', PR_Core_Settings::sanitize_cadence( 'DAILY' ), 'uppercase → weekly (case-sensitive)' ); + +// ── sanitize_emails() ─────────────────────────────────────────────────── + +echo "\nsanitize_emails():\n"; +pr_assert_equals( '', PR_Core_Settings::sanitize_emails( '' ), 'empty → empty' ); +pr_assert_equals( 'admin@example.com', PR_Core_Settings::sanitize_emails( 'admin@example.com' ), 'valid single → kept' ); +pr_assert_equals( '', PR_Core_Settings::sanitize_emails( 'not-an-email' ), 'invalid single → empty' ); +pr_assert_equals( 'a@test.com, b@test.com', PR_Core_Settings::sanitize_emails( 'a@test.com, b@test.com' ), 'valid pair → both kept' ); +pr_assert_equals( 'good@test.com', PR_Core_Settings::sanitize_emails( 'good@test.com, bad-email' ), 'mixed → only valid kept' ); +pr_assert_equals( 'trimmed@test.com', PR_Core_Settings::sanitize_emails( ' trimmed@test.com ' ), 'whitespace trimmed around valid email' ); + +// ── reschedule_cron(): no-op when same value ──────────────────────────── + +echo "\nreschedule_cron() — no-op when value unchanged:\n"; +$GLOBALS['pr_core_cron_calls'] = []; +PR_Core_Settings::reschedule_cron( 'weekly', 'weekly' ); +pr_assert( empty( $GLOBALS['pr_core_cron_calls'] ), 'no cron calls when old === new' ); + +// ── reschedule_cron(): clears + reschedules on change ─────────────────── + +echo "\nreschedule_cron() — reschedules when value changes:\n"; +$GLOBALS['pr_core_cron_calls'] = []; +PR_Core_Settings::reschedule_cron( 'weekly', 'daily' ); + +$actions = array_column( $GLOBALS['pr_core_cron_calls'], 'action' ); +pr_assert( in_array( 'clear', $actions, true ), 'wp_clear_scheduled_hook was called' ); +pr_assert( in_array( 'schedule', $actions, true ), 'wp_schedule_event was called' ); + +$schedule_call = null; +foreach ( $GLOBALS['pr_core_cron_calls'] as $call ) { + if ( 'schedule' === $call['action'] ) { + $schedule_call = $call; + break; + } +} +pr_assert( null !== $schedule_call, 'schedule call found' ); +pr_assert_equals( 'pr_core_verification_scan', $schedule_call['hook'] ?? '', 'rescheduled hook = pr_core_verification_scan' ); +pr_assert_equals( 'daily', $schedule_call['recurrence'] ?? '', 'rescheduled with new cadence "daily"' ); + +// Clear fires before schedule (correct ordering). +$clear_idx = array_search( 'clear', $actions, true ); +$schedule_idx = array_search( 'schedule', $actions, true ); +pr_assert( $clear_idx < $schedule_idx, 'clear fires before schedule' ); + +// ── Summary ───────────────────────────────────────────────────────────── + +exit( pr_test_summary() );