diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c85f64..fbe4959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,78 +1,27 @@ +## +# Thin CI caller — peptide-repo-core +# +# Delegates all lint/test/file-size jobs to the estate reusable workflow. +# Per estate standard: workflow_call is required so deploy.yml can gate on it. +# See: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main for job definitions. +## name: CI on: + pull_request: {} push: - branches: [ main ] - pull_request: - branches: [ main ] - workflow_call: + branches: [main] + workflow_call: {} +# Required: calling workflow must declare contents: write so the reusable +# phpcs job can commit phpcbf auto-fixes back to the PR branch. permissions: - contents: read + contents: write jobs: - # ── PHP Syntax Check ───────────────────────────────────────────── - lint-php: - name: PHP Lint (${{ matrix.php }}) - runs-on: ubuntu-latest - strategy: - matrix: - php: [ '8.1', '8.2', '8.3' ] - steps: - - uses: actions/checkout@v4 - - - name: Set up PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - - - name: Syntax check all PHP files - run: find . -name "*.php" -not -path "./vendor/*" | xargs -n 1 php -l - - - name: Unit tests (lightweight, no PHPUnit) - run: | - for test in tests/unit/test-*.php; do - echo "=== Running $test ===" - php "$test" - done - - # ── WordPress Coding Standards (PHP CodeSniffer) ────────────────── - lint-phpcs: - name: WordPress Coding Standards - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - - - name: Install Composer dependencies - run: composer install --no-interaction --prefer-dist --no-progress - - - name: Run PHPCS - run: composer run lint - continue-on-error: true - - # ── 300-line file check ────────────────────────────────────────── - file-size: - name: 300-line file check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Check no PHP file exceeds 300 lines - run: | - violations=0 - while IFS= read -r file; do - lines=$(wc -l < "$file") - if [ "$lines" -gt 300 ]; then - echo "VIOLATION: $file ($lines lines)" - violations=$((violations + 1)) - fi - done < <(find . -name "*.php" -not -path "./vendor/*" -not -path "./tests/*") - if [ "$violations" -gt 0 ]; then - echo "$violations file(s) exceed 300 lines" - exit 1 - fi - echo "All PHP files are 300 lines or fewer." + ci: + uses: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main + with: + tests: stubs + has_js: false + secrets: inherit diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 96709c1..53ee892 100755 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,8 +4,10 @@ on: push: branches: [ main ] +# contents: write required so the ci.yml caller can pass write permission +# through to the phpcbf auto-commit step in the reusable workflow. permissions: - contents: read + contents: write concurrency: group: deploy-peptide-repo-core diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c283f8..e82bb74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to Peptide Repo Core are documented here. Format: [Semantic Versioning](https://semver.org/). + +## [0.7.0] - 2026-06-16 + +### Changed +- CI: adopt estate reusable workflow (thin caller, tests: stubs, has_js: false). +- PHPUnit test suite: 7 hand-rolled test scripts converted to 7 TestCase classes. +- Added phpunit/phpunit ^9.6, yoast/phpunit-polyfills ^2.0 to require-dev. +- Added phpunit.xml.dist and composer test script. +- deploy.yml: added permissions: contents: write. + ## [0.6.2] — 2026-06-13 ### Fixed diff --git a/composer.json b/composer.json index ce73219..2107f6f 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "squizlabs/php_codesniffer": "^3.13", "wp-coding-standards/wpcs": "^3.0", "phpcsstandards/phpcsutils": "^1.0", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpunit/phpunit": "^9.6", + "yoast/phpunit-polyfills": "^2.0" }, "config": { "allow-plugins": { @@ -19,6 +21,11 @@ }, "scripts": { "lint": "phpcs", - "lint:fix": "phpcbf" + "lint:fix": "phpcbf", + "test": "vendor/bin/phpunit --configuration phpunit.xml.dist", + "check": [ + "@lint", + "@test" + ] } } diff --git a/peptide-repo-core.php b/peptide-repo-core.php index 15e72b3..286bfff 100644 --- a/peptide-repo-core.php +++ b/peptide-repo-core.php @@ -3,7 +3,7 @@ * Plugin Name: Peptide Repo Core * Plugin URI: https://peptiderepo.com * Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output. - * Version: 0.6.2 + * Version: 0.7.0 * Author: peptiderepo * Author URI: https://peptiderepo.com * License: GPL-2.0-or-later @@ -22,7 +22,7 @@ if ( ! defined( 'ABSPATH' ) ) { /* ── Constants ────────────────────────────────────────────────────────── */ -define( 'PR_CORE_VERSION', '0.6.2' ); +define( 'PR_CORE_VERSION', '0.7.0' ); define( 'PR_CORE_PLUGIN_FILE', __FILE__ ); define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6cf6d35 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + ./tests/unit + + + + + + ./includes + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c3f1913..5df23c0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,165 +1,417 @@ [], + 'existing_taxonomies' => [], + 'registered_post_types' => [], + 'registered_taxonomies' => [], + 'registered_meta' => [], + 'added_actions' => [], + 'added_filters' => [], +]; +$GLOBALS['pr_core_options'] = []; +$GLOBALS['pr_core_cron_calls'] = []; +$GLOBALS['pr_test_postmeta'] = []; +$GLOBALS['pr_test_posts'] = []; +$GLOBALS['pr_test_updated_meta'] = []; +$GLOBALS['pr_test_is_singular'] = false; +$GLOBALS['pr_test_singular_type'] = ''; +$GLOBALS['pr_test_the_id'] = 0; +$GLOBALS['pr_test_peptide_dto'] = null; + +// ── WordPress function stubs ────────────────────────────────────────── if ( ! function_exists( 'trailingslashit' ) ) { function trailingslashit( string $path ): string { return rtrim( $path, '/\\' ) . '/'; } } - -/** - * Global harness state. Tests reset this between cases. - * - * @var array - */ -$GLOBALS['pr_core_test_state'] = [ - 'existing_post_types' => [], - 'existing_taxonomies' => [], - 'registered_post_types' => [], - 'registered_taxonomies' => [], - 'registered_meta' => [], - 'added_actions' => [], -]; - -function pr_core_test_reset(): void { - $GLOBALS['pr_core_test_state'] = [ - 'existing_post_types' => [], - 'existing_taxonomies' => [], - 'registered_post_types' => [], - 'registered_taxonomies' => [], - 'registered_meta' => [], - 'added_actions' => [], - ]; +if ( ! function_exists( 'plugin_dir_path' ) ) { + function plugin_dir_path( string $file ): string { + return trailingslashit( dirname( $file ) ); + } } - -function post_type_exists( string $post_type ): bool { - return in_array( $post_type, $GLOBALS['pr_core_test_state']['existing_post_types'], true ); +if ( ! function_exists( 'plugin_dir_url' ) ) { + function plugin_dir_url( string $file ): string { + return 'https://example.com/wp-content/plugins/' . basename( dirname( $file ) ) . '/'; + } } - -function taxonomy_exists( string $taxonomy ): bool { - return in_array( $taxonomy, $GLOBALS['pr_core_test_state']['existing_taxonomies'], true ); +if ( ! function_exists( 'plugin_basename' ) ) { + function plugin_basename( string $file ): string { + return basename( dirname( $file ) ) . '/' . basename( $file ); + } } - -function register_post_type( string $post_type, array $args = [] ) { - $GLOBALS['pr_core_test_state']['registered_post_types'][ $post_type ] = $args; - $GLOBALS['pr_core_test_state']['existing_post_types'][] = $post_type; - return (object) [ 'name' => $post_type, 'args' => $args ]; +if ( ! function_exists( '__' ) ) { + function __( string $text, string $domain = 'default' ): string { + return $text; + } } - -function register_taxonomy( string $taxonomy, $object_type, array $args = [] ): void { - $GLOBALS['pr_core_test_state']['registered_taxonomies'][ $taxonomy ] = [ - 'object_type' => $object_type, - 'args' => $args, - ]; - $GLOBALS['pr_core_test_state']['existing_taxonomies'][] = $taxonomy; +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( string $text ): string { + return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); + } } - -function register_post_meta( string $post_type, string $key, array $args = [] ): bool { - $GLOBALS['pr_core_test_state']['registered_meta'][ $post_type ][ $key ] = $args; - return true; +if ( ! function_exists( 'esc_html__' ) ) { + function esc_html__( string $text, string $domain = 'default' ): string { + return esc_html( $text ); + } } - -function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool { - $GLOBALS['pr_core_test_state']['added_actions'][] = [ - 'hook' => $hook, - 'callback' => $callback, - 'priority' => $priority, - ]; - return true; +if ( ! function_exists( 'esc_attr' ) ) { + function esc_attr( string $text ): string { + return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); + } } - -function __( string $text, string $domain = 'default' ): string { - return $text; +if ( ! function_exists( 'esc_url_raw' ) ) { + function esc_url_raw( string $url ): string { + return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; + } } - -function current_user_can( string $capability ): bool { - return true; +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( $value ): string { + return is_scalar( $value ) ? trim( strip_tags( (string) $value ) ) : ''; + } } - -function sanitize_text_field( $value ): string { - return is_scalar( $value ) ? trim( (string) $value ) : ''; +if ( ! function_exists( 'sanitize_textarea_field' ) ) { + function sanitize_textarea_field( $value ): string { + return is_scalar( $value ) ? trim( strip_tags( (string) $value ) ) : ''; + } +} +if ( ! function_exists( 'wp_strip_all_tags' ) ) { + function wp_strip_all_tags( $value ): string { + return strip_tags( (string) $value ); + } +} +if ( ! function_exists( 'absint' ) ) { + function absint( $value ): int { + return abs( (int) $value ); + } } - if ( ! function_exists( 'wp_json_encode' ) ) { - function wp_json_encode( $data, int $flags = 0 ): string { - return json_encode( $data, $flags ) ?: '[]'; + function wp_json_encode( $data, int $flags = 0, int $depth = 512 ): string { + return json_encode( $data, $flags, $depth ) ?: '[]'; + } +} +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { + return $thing instanceof WP_Error; + } +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $tag, $value ) { + return $value; + } +} +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $capability ): bool { + return true; + } +} +if ( ! function_exists( 'home_url' ) ) { + function home_url( string $path = '' ): string { + return 'https://peptiderepo.com' . $path; + } +} +if ( ! function_exists( 'error_log' ) ) { + function error_log( string $msg ): bool { + return true; + } +} +if ( ! function_exists( 'is_email' ) ) { + function is_email( string $email ): bool { + return (bool) filter_var( $email, FILTER_VALIDATE_EMAIL ); } } -function absint( $value ): int { - return abs( (int) $value ); -} +// ── Option / cron stubs ─────────────────────────────────────────────── - -/* ── Test assertion helpers ─────────────────────────────────────────── */ - -$GLOBALS['pr_core_test_report'] = [ 'pass' => 0, 'fail' => 0, 'failures' => [] ]; - -function pr_assert( bool $condition, string $label ): void { - if ( $condition ) { - $GLOBALS['pr_core_test_report']['pass']++; - echo " PASS: {$label}\n"; - } else { - $GLOBALS['pr_core_test_report']['fail']++; - $GLOBALS['pr_core_test_report']['failures'][] = $label; - echo " FAIL: {$label}\n"; - } -} - -function pr_assert_equals( $expected, $actual, string $label ): void { - $pass = ( $expected === $actual ); - pr_assert( $pass, $label . ( $pass ? '' : ' — expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) ); -} - -function pr_test_summary(): int { - $r = $GLOBALS['pr_core_test_report']; - echo "\n---\n"; - echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n"; - if ( $r['fail'] > 0 ) { - echo "Failures:\n"; - foreach ( $r['failures'] as $f ) { - echo " - {$f}\n"; +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $key, $default = false ) { + if ( isset( $GLOBALS['pr_core_options'][ $key ] ) ) { + return $GLOBALS['pr_core_options'][ $key ]; } - return 1; + return $default; } - return 0; +} +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $key, $value ): bool { + $GLOBALS['pr_core_options'][ $key ] = $value; + return true; + } +} +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, + ]; + } +} +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 {} } -/* ── Load the plugin's autoloader + required classes ─────────────────── */ +// ── Post meta stubs ─────────────────────────────────────────────────── -require_once __DIR__ . '/../includes/class-pr-core-autoloader.php'; +if ( ! function_exists( 'get_post_meta' ) ) { + function get_post_meta( $post_id, string $key = '', bool $single = false ) { + if ( $single ) { + return $GLOBALS['pr_test_postmeta'][ (int) $post_id ][ $key ] ?? ''; + } + return array_values( $GLOBALS['pr_test_postmeta'][ (int) $post_id ] ?? [] ); + } +} +if ( ! function_exists( 'update_post_meta' ) ) { + function update_post_meta( int $post_id, string $key, $value ): bool { + $GLOBALS['pr_test_postmeta'][ $post_id ][ $key ] = $value; + $GLOBALS['pr_test_updated_meta'][] = [ + 'post_id' => $post_id, + 'key' => $key, + 'value' => $value, + ]; + return true; + } +} +if ( ! function_exists( 'get_post' ) ) { + function get_post( int $id ) { + return $GLOBALS['pr_test_posts'][ $id ] ?? null; + } +} +if ( ! function_exists( 'get_posts' ) ) { + function get_posts( array $args = [] ): array { + return $GLOBALS['pr_test_all_post_ids'] ?? []; + } +} + +// ── CPT / taxonomy stubs ────────────────────────────────────────────── + +if ( ! function_exists( 'post_type_exists' ) ) { + function post_type_exists( string $post_type ): bool { + return in_array( $post_type, $GLOBALS['pr_core_test_state']['existing_post_types'], true ); + } +} +if ( ! function_exists( 'taxonomy_exists' ) ) { + function taxonomy_exists( string $taxonomy ): bool { + return in_array( $taxonomy, $GLOBALS['pr_core_test_state']['existing_taxonomies'], true ); + } +} +if ( ! function_exists( 'register_post_type' ) ) { + function register_post_type( string $post_type, array $args = [] ) { + $GLOBALS['pr_core_test_state']['registered_post_types'][ $post_type ] = $args; + $GLOBALS['pr_core_test_state']['existing_post_types'][] = $post_type; + return (object) [ 'name' => $post_type, 'args' => $args ]; + } +} +if ( ! function_exists( 'register_taxonomy' ) ) { + function register_taxonomy( string $taxonomy, $object_type, array $args = [] ): void { + $GLOBALS['pr_core_test_state']['registered_taxonomies'][ $taxonomy ] = [ + 'object_type' => $object_type, + 'args' => $args, + ]; + $GLOBALS['pr_core_test_state']['existing_taxonomies'][] = $taxonomy; + } +} +if ( ! function_exists( 'register_post_meta' ) ) { + function register_post_meta( string $post_type, string $key, array $args = [] ): bool { + $GLOBALS['pr_core_test_state']['registered_meta'][ $post_type ][ $key ] = $args; + return true; + } +} +if ( ! function_exists( 'add_action' ) ) { + function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool { + $GLOBALS['pr_core_test_state']['added_actions'][] = [ + 'hook' => $hook, + 'callback' => $callback, + 'priority' => $priority, + ]; + return true; + } +} +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool { + $GLOBALS['pr_core_test_state']['added_filters'][] = [ + 'hook' => $hook, + 'callback' => $callback, + 'priority' => $priority, + ]; + return true; + } +} + +// ── Singular / permalink stubs ──────────────────────────────────────── + +if ( ! function_exists( 'is_singular' ) ) { + function is_singular( $type = '' ): bool { + if ( '' === $type ) { + return $GLOBALS['pr_test_is_singular']; + } + return $GLOBALS['pr_test_is_singular'] + && ( $GLOBALS['pr_test_singular_type'] === $type ); + } +} +if ( ! function_exists( 'get_the_ID' ) ) { + function get_the_ID(): int { + return $GLOBALS['pr_test_the_id']; + } +} +if ( ! function_exists( 'get_permalink' ) ) { + function get_permalink( $post_id = null ): string { + return 'https://peptiderepo.com/peptides/bpc-157/'; + } +} +if ( ! function_exists( 'get_post_modified_time' ) ) { + function get_post_modified_time( $format, $gmt = false, $post = null ) { + return '2026-04-25'; + } +} +if ( ! function_exists( 'get_userdata' ) ) { + function get_userdata( int $id ) { + return false; + } +} +if ( ! function_exists( 'get_author_posts_url' ) ) { + function get_author_posts_url( int $id ): string { + return 'https://peptiderepo.com/author/' . $id; + } +} + +// ── WP_Error stub ───────────────────────────────────────────────────── + +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + /** @var array */ + public array $errors = []; + /** @var array */ + public array $error_data = []; + + public function __construct( string $code = '', string $message = '', $data = '' ) { + if ( $code ) { + $this->errors[ $code ][] = $message; + if ( $data ) { + $this->error_data[ $code ] = $data; + } + } + } + + public function get_error_code(): string { + $codes = array_keys( $this->errors ); + return $codes ? $codes[0] : ''; + } + + public function get_error_message( string $code = '' ): string { + if ( ! $code ) { + $code = $this->get_error_code(); + } + return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : ''; + } + } +} + + +// ── WP_Post stub ────────────────────────────────────────────────────── + +if ( ! class_exists( 'WP_Post' ) ) { + class WP_Post { + public int $ID = 0; + public string $post_author = '0'; + public string $post_date = ''; + public string $post_title = ''; + public string $post_content = ''; + public string $post_excerpt = ''; + public string $post_status = 'publish'; + public string $post_name = ''; + public string $post_type = 'post'; + public string $post_modified = ''; + + /** + * @param array|object $data + */ + public function __construct( $data = [] ) { + foreach ( (array) $data as $key => $value ) { + $this->$key = $value; + } + } + } +} + +if ( ! function_exists( 'wp_get_post_terms' ) ) { + function wp_get_post_terms( int $post_id, string $taxonomy, array $args = [] ): array { + return []; + } +} + +if ( ! function_exists( 'sanitize_title' ) ) { + function sanitize_title( string $title ): string { + return strtolower( trim( preg_replace( '/[^a-z0-9-]/', '-', strtolower( $title ) ) ?: '', '-' ) ); + } +} + +if ( ! function_exists( 'wp_count_posts' ) ) { + function wp_count_posts( string $type = 'post' ) { + return (object) [ 'publish' => 0 ]; + } +} + +// ── Load the plugin's autoloader ────────────────────────────────────── + +require_once PR_CORE_PLUGIN_DIR . 'includes/class-pr-core-autoloader.php'; PR_Core_Autoloader::register(); -require_once __DIR__ . '/../includes/cpt/class-pr-core-peptide-cpt.php'; diff --git a/tests/unit/JsonldPeptidePieceTest.php b/tests/unit/JsonldPeptidePieceTest.php new file mode 100644 index 0000000..ea07c7c --- /dev/null +++ b/tests/unit/JsonldPeptidePieceTest.php @@ -0,0 +1,285 @@ + [], + 'existing_taxonomies' => [], + 'registered_post_types' => [], + 'registered_taxonomies' => [], + 'registered_meta' => [], + 'added_actions' => [], + 'added_filters' => [], + ]; + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-schema-sanitizers.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/dto/class-pr-core-peptide-dto.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-drug.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-faq.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-webpage.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld.php'; + } + + // ── Helpers ─────────────────────────────────────────────────────── + + /** + * @param array $overrides + */ + private function make_dto( array $overrides = [] ): PR_Core_Peptide_DTO { + return new PR_Core_Peptide_DTO( array_merge( [ + 'id' => 36, + 'title' => 'BPC-157', + 'slug' => 'bpc-157', + 'content' => '', + 'excerpt' => 'A synthetic pentadecapeptide.', + 'status' => 'publish', + 'display_name' => 'BPC-157', + 'aliases' => [], + 'molecular_formula' => '', + 'molecular_weight' => 0.0, + 'cas_number' => '', + 'drugbank_id' => '', + 'chembl_id' => '', + 'evidence_strength' => 'preclinical', + 'editorial_review_status' => 'published', + 'last_editorial_review_at' => '', + 'medical_editor_id' => 0, + 'categories' => [], + 'families' => [], + ], $overrides ) ); + } + + /** + * @return array> + */ + private function make_existing_graph(): array { + return [ + [ + '@type' => 'MedicalWebPage', + '@id' => 'https://peptiderepo.com/peptides/bpc-157/#webpage', + 'name' => 'BPC-157', + ], + [ + '@type' => 'BreadcrumbList', + '@id' => 'https://peptiderepo.com/peptides/bpc-157/#breadcrumb', + ], + ]; + } + + private function set_peptide_context( int $post_id, array $meta = [] ): void { + $GLOBALS['pr_test_the_id'] = $post_id; + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = PR_Core_Peptide_CPT::POST_TYPE; + $GLOBALS['pr_test_postmeta'][ $post_id ] = $meta; + $GLOBALS['pr_test_peptide_dto'] = $this->make_dto( [ 'id' => $post_id ] ); + // Supply a WP_Post object so PR_Core_Peptide_Repository::find_by_id() passes + // the post_type check and post_to_dto() type hint (our get_post() stub reads + // from pr_test_posts). + $post = new WP_Post(); + $post->ID = $post_id; + $post->post_type = PR_Core_Peptide_CPT::POST_TYPE; + $post->post_title = 'BPC-157'; + $post->post_name = 'bpc-157'; + $post->post_content = ''; + $post->post_excerpt = ''; + $post->post_status = 'publish'; + $GLOBALS['pr_test_posts'][ $post_id ] = $post; + } + + // ── Regression: get_class(array) throws ─────────────────────────── + + public function test_get_class_on_plain_array_throws(): void { + $GLOBALS['pr_test_postmeta'][36] = []; + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $this->make_dto() ); + $caught = false; + try { + get_class( $drug_node ); + } catch ( TypeError $e ) { + $caught = true; + } catch ( Error $e ) { + $caught = true; + } + $this->assertTrue( $caught, 'get_class(array) must throw — confirms the v0.6.1 fatal' ); + } + + // ── inject_graph_nodes does not throw ───────────────────────────── + + public function test_inject_graph_nodes_does_not_throw(): void { + $this->set_peptide_context( 36, [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Body Protection Compound"]', + ] ); + $jsonld = new PR_Core_Jsonld(); + $threw = false; + try { + $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + } catch ( Throwable $e ) { + $threw = true; + } + $this->assertFalse( $threw ); + } + + public function test_inject_graph_nodes_appends_drug_node(): void { + $this->set_peptide_context( 36, [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Body Protection Compound"]', + ] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $this->assertGreaterThan( count( $this->make_existing_graph() ), count( $result ) ); + } + + public function test_inject_graph_nodes_exactly_one_drug_node(): void { + $this->set_peptide_context( 36, [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Body Protection Compound"]', + ] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $drug_nodes = array_filter( $result, static function ( $n ) { + $types = is_array( $n['@type'] ?? null ) ? $n['@type'] : [ $n['@type'] ?? '' ]; + return in_array( 'Drug', $types, true ); + } ); + $this->assertCount( 1, $drug_nodes ); + } + + public function test_inject_graph_nodes_drug_id_format(): void { + $this->set_peptide_context( 36, [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '[]', + ] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $drug_nodes = array_values( array_filter( $result, static function ( $n ) { + $types = is_array( $n['@type'] ?? null ) ? $n['@type'] : [ $n['@type'] ?? '' ]; + return in_array( 'Drug', $types, true ); + } ) ); + $this->assertSame( 'https://peptiderepo.com/peptides/bpc-157/#drug', $drug_nodes[0]['@id'] ); + } + + public function test_inject_graph_nodes_drug_has_molecular_formula(): void { + $this->set_peptide_context( 36, [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '[]', + ] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $drug_nodes = array_values( array_filter( $result, static function ( $n ) { + $types = is_array( $n['@type'] ?? null ) ? $n['@type'] : [ $n['@type'] ?? '' ]; + return in_array( 'Drug', $types, true ); + } ) ); + $this->assertArrayHasKey( 'molecularFormula', $drug_nodes[0] ); + } + + public function test_inject_graph_nodes_drug_molecular_weight_quantitative_value(): void { + $this->set_peptide_context( 36, [ '_pr_molecular_weight' => '1419.5' ] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $drug_nodes = array_values( array_filter( $result, static function ( $n ) { + $types = is_array( $n['@type'] ?? null ) ? $n['@type'] : [ $n['@type'] ?? '' ]; + return in_array( 'Drug', $types, true ); + } ) ); + $this->assertSame( 'QuantitativeValue', $drug_nodes[0]['molecularWeight']['@type'] ); + $this->assertSame( 1419.5, $drug_nodes[0]['molecularWeight']['value'] ); + $this->assertSame( 'g/mol', $drug_nodes[0]['molecularWeight']['unitText'] ); + } + + public function test_inject_graph_nodes_preserves_webpage_and_breadcrumb(): void { + $this->set_peptide_context( 36, [] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $webpage = array_filter( $result, static fn( $n ) => ( $n['@type'] ?? '' ) === 'MedicalWebPage' ); + $breadcrumb = array_filter( $result, static fn( $n ) => ( $n['@type'] ?? '' ) === 'BreadcrumbList' ); + $this->assertCount( 1, $webpage ); + $this->assertCount( 1, $breadcrumb ); + } + + // ── FAQ injection via inject_graph_nodes ────────────────────────── + + public function test_inject_graph_nodes_faq_present_when_items_exist(): void { + $this->set_peptide_context( 36, [ + '_pr_faq_items' => wp_json_encode( [ + [ 'question' => 'What is BPC-157?', 'answer' => 'A synthetic pentadecapeptide.' ], + [ 'question' => 'Is it safe?', 'answer' => 'Research use only.' ], + ] ), + ] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $faq_nodes = array_filter( $result, static fn( $n ) => ( $n['@type'] ?? '' ) === 'FAQPage' ); + $this->assertCount( 1, $faq_nodes ); + $faq_node = array_values( $faq_nodes )[0]; + $this->assertSame( 'https://peptiderepo.com/peptides/bpc-157/#faq', $faq_node['@id'] ); + $this->assertCount( 2, $faq_node['mainEntity'] ); + } + + public function test_inject_graph_nodes_faq_absent_when_items_empty(): void { + $this->set_peptide_context( 36, [ '_pr_faq_items' => '[]' ] ); + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $this->make_existing_graph(), null ); + $faq_nodes = array_filter( $result, static fn( $n ) => ( $n['@type'] ?? '' ) === 'FAQPage' ); + $this->assertCount( 0, $faq_nodes ); + } + + // ── Non-peptide page: no injection ──────────────────────────────── + + public function test_inject_graph_nodes_noop_on_non_peptide_page(): void { + $GLOBALS['pr_test_is_singular'] = false; + $GLOBALS['pr_test_singular_type'] = ''; + $GLOBALS['pr_test_the_id'] = 0; + $non_peptide_graph = [ [ '@type' => 'WebPage', '@id' => 'https://peptiderepo.com/about/#webpage' ] ]; + $jsonld = new PR_Core_Jsonld(); + $result = $jsonld->inject_graph_nodes( $non_peptide_graph, null ); + $this->assertCount( 1, $result ); + } + + // ── register_hooks: wpseo_schema_graph_pieces must NOT be hooked ── + + public function test_jsonld_does_not_hook_wpseo_schema_graph_pieces(): void { + $src = file_get_contents( PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld.php' ); + $this->assertStringNotContainsString( + "'wpseo_schema_graph_pieces'", + $src, + 'class-pr-core-jsonld.php must NOT hook wpseo_schema_graph_pieces (v0.6.2 fix)' + ); + } + + public function test_jsonld_references_inject_graph_nodes(): void { + $src = file_get_contents( PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld.php' ); + $this->assertStringContainsString( 'inject_graph_nodes', $src ); + } + + public function test_jsonld_hooks_wpseo_schema_graph(): void { + $src = file_get_contents( PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld.php' ); + $this->assertStringContainsString( "'wpseo_schema_graph'", $src ); + } +} diff --git a/tests/unit/JsonldTest.php b/tests/unit/JsonldTest.php new file mode 100644 index 0000000..3f5836d --- /dev/null +++ b/tests/unit/JsonldTest.php @@ -0,0 +1,297 @@ + [], + 'existing_taxonomies' => [], + 'registered_post_types' => [], + 'registered_taxonomies' => [], + 'registered_meta' => [], + 'added_actions' => [], + 'added_filters' => [], + ]; + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-schema-sanitizers.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/dto/class-pr-core-peptide-dto.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-drug.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-faq.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-webpage.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld.php'; + } + + // ── Helper ──────────────────────────────────────────────────────── + + /** + * @param array $overrides + */ + private function make_peptide_dto( array $overrides = [] ): PR_Core_Peptide_DTO { + return new PR_Core_Peptide_DTO( array_merge( [ + 'id' => 1, + 'title' => 'BPC-157', + 'slug' => 'bpc-157', + 'content' => '', + 'excerpt' => 'A synthetic peptide.', + 'status' => 'publish', + 'display_name' => 'BPC-157', + 'aliases' => [], + 'molecular_formula' => '', + 'molecular_weight' => 0.0, + 'cas_number' => '', + 'drugbank_id' => '', + 'chembl_id' => '', + 'evidence_strength' => 'preclinical', + 'editorial_review_status' => 'published', + 'last_editorial_review_at' => '', + 'medical_editor_id' => 0, + 'categories' => [], + 'families' => [], + ], $overrides ) ); + } + + // ── Drug @id ───────────────────────────────────────────────────── + + public function test_drug_node_has_id(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertArrayHasKey( '@id', $drug_node ); + } + + public function test_drug_node_id_format(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertSame( 'https://peptiderepo.com/peptides/bpc-157/#drug', $drug_node['@id'] ); + } + + // ── Drug @type ──────────────────────────────────────────────────── + + public function test_drug_type_is_array(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertIsArray( $drug_node['@type'] ); + } + + public function test_drug_type_includes_drug(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertContains( 'Drug', $drug_node['@type'] ); + } + + public function test_drug_type_includes_molecular_entity(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertContains( 'MolecularEntity', $drug_node['@type'] ); + } + + // ── Drug fields present when meta set ──────────────────────────── + + public function test_molecular_formula_present_when_meta_set(): void { + $GLOBALS['pr_test_postmeta'][1] = [ '_pr_molecular_formula' => 'C62H98N16O22', '_pr_molecular_weight' => '1419.5', '_pr_aliases' => '[]' ]; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertArrayHasKey( 'molecularFormula', $drug_node ); + $this->assertSame( 'C62H98N16O22', $drug_node['molecularFormula'] ); + } + + public function test_molecular_weight_present_when_meta_set(): void { + $GLOBALS['pr_test_postmeta'][1] = [ '_pr_molecular_formula' => '', '_pr_molecular_weight' => '1419.5', '_pr_aliases' => '[]' ]; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertArrayHasKey( 'molecularWeight', $drug_node ); + $this->assertSame( 'QuantitativeValue', $drug_node['molecularWeight']['@type'] ); + $this->assertSame( 1419.5, $drug_node['molecularWeight']['value'] ); + $this->assertSame( 'g/mol', $drug_node['molecularWeight']['unitText'] ); + } + + public function test_alternate_name_present_when_aliases_set(): void { + $GLOBALS['pr_test_postmeta'][1] = [ '_pr_molecular_formula' => '', '_pr_molecular_weight' => '0', '_pr_aliases' => '["Body Protection Compound-157","PL 14736"]' ]; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertArrayHasKey( 'alternateName', $drug_node ); + $this->assertCount( 2, $drug_node['alternateName'] ); + } + + // ── Drug fields omitted when meta empty ────────────────────────── + + public function test_molecular_formula_omitted_when_meta_empty(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertArrayNotHasKey( 'molecularFormula', $drug_node ); + } + + public function test_molecular_weight_omitted_when_meta_empty(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertArrayNotHasKey( 'molecularWeight', $drug_node ); + } + + public function test_alternate_name_omitted_when_aliases_empty(): void { + $GLOBALS['pr_test_postmeta'][1] = [ '_pr_aliases' => '[]' ]; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $this->assertArrayNotHasKey( 'alternateName', $drug_node ); + } + + // ── FAQPage emission ────────────────────────────────────────────── + + public function test_faq_piece_not_null_when_items_present(): void { + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_faq_items' => wp_json_encode( [ + [ 'question' => 'What is BPC-157?', 'answer' => 'A synthetic peptide.' ], + [ 'question' => 'Is it legal?', 'answer' => 'Research use only.' ], + ] ), + ]; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, 'https://peptiderepo.com/peptides/bpc-157/' ); + $this->assertNotNull( $faq_piece ); + } + + public function test_faq_piece_type_is_faq_page(): void { + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_faq_items' => wp_json_encode( [ [ 'question' => 'Q?', 'answer' => 'A.' ] ] ), + ]; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, 'https://peptiderepo.com/peptides/bpc-157/' ); + $this->assertSame( 'FAQPage', $faq_piece['@type'] ); + } + + public function test_faq_piece_id_format(): void { + $permalink = 'https://peptiderepo.com/peptides/bpc-157/'; + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_faq_items' => wp_json_encode( [ [ 'question' => 'Q?', 'answer' => 'A.' ] ] ), + ]; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, $permalink ); + $this->assertSame( $permalink . '#faq', $faq_piece['@id'] ); + } + + public function test_faq_piece_has_main_entity(): void { + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_faq_items' => wp_json_encode( [ + [ 'question' => 'What is BPC-157?', 'answer' => 'A synthetic peptide.' ], + [ 'question' => 'Is it legal?', 'answer' => 'Research use only.' ], + ] ), + ]; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, 'https://peptiderepo.com/peptides/bpc-157/' ); + $this->assertArrayHasKey( 'mainEntity', $faq_piece ); + $this->assertCount( 2, $faq_piece['mainEntity'] ); + } + + public function test_faq_main_entity_question_type(): void { + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_faq_items' => wp_json_encode( [ [ 'question' => 'Q?', 'answer' => 'A.' ] ] ), + ]; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, 'https://peptiderepo.com/peptides/bpc-157/' ); + $this->assertSame( 'Question', $faq_piece['mainEntity'][0]['@type'] ); + } + + public function test_faq_main_entity_has_accepted_answer(): void { + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_faq_items' => wp_json_encode( [ [ 'question' => 'Q?', 'answer' => 'A.' ] ] ), + ]; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, 'https://peptiderepo.com/peptides/bpc-157/' ); + $this->assertArrayHasKey( 'acceptedAnswer', $faq_piece['mainEntity'][0] ); + } + + public function test_faq_piece_null_when_items_json_empty(): void { + $GLOBALS['pr_test_postmeta'][1] = [ '_pr_faq_items' => '[]' ]; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, 'https://peptiderepo.com/peptides/bpc-157/' ); + $this->assertNull( $faq_piece ); + } + + public function test_faq_piece_null_when_items_absent(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $faq_piece = ( new PR_Core_Jsonld_Faq() )->build( 1, 'https://peptiderepo.com/peptides/bpc-157/' ); + $this->assertNull( $faq_piece ); + } + + // ── MedicalWebPage retype ───────────────────────────────────────── + + public function test_retype_returns_medical_webpage_on_peptide_singular(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'peptide'; + $result = ( new PR_Core_Jsonld_Webpage() )->retype_to_medical_webpage( 'WebPage' ); + $this->assertSame( 'MedicalWebPage', $result ); + } + + public function test_retype_passes_through_on_non_peptide_page(): void { + $GLOBALS['pr_test_is_singular'] = false; + $GLOBALS['pr_test_singular_type'] = ''; + $result = ( new PR_Core_Jsonld_Webpage() )->retype_to_medical_webpage( 'WebPage' ); + $this->assertSame( 'WebPage', $result ); + } + + // ── No duplicate nodes from our builders ────────────────────────── + + public function test_builders_do_not_emit_webpage_type(): void { + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Body Protection Compound-157","PL 14736"]', + ]; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $types = is_array( $drug_node['@type'] ) ? $drug_node['@type'] : [ $drug_node['@type'] ]; + $this->assertNotContains( 'WebPage', $types ); + $this->assertNotContains( 'MedicalWebPage', $types ); + } + + public function test_builders_do_not_emit_breadcrumb_type(): void { + $GLOBALS['pr_test_postmeta'][1] = []; + $peptide = $this->make_peptide_dto(); + $drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + $types = is_array( $drug_node['@type'] ) ? $drug_node['@type'] : [ $drug_node['@type'] ]; + $this->assertNotContains( 'BreadcrumbList', $types ); + } + + // ── sanitize_faq_items() ────────────────────────────────────────── + + public function test_sanitize_faq_items_valid_input(): void { + $result = PR_Core_Schema_Sanitizers::sanitize_faq_items( wp_json_encode( [ [ 'question' => 'Q?', 'answer' => 'A.' ] ] ) ); + $decoded = json_decode( $result, true ); + $this->assertIsArray( $decoded ); + $this->assertCount( 1, $decoded ); + } + + public function test_sanitize_faq_items_empty_json_array(): void { + $this->assertSame( '[]', PR_Core_Schema_Sanitizers::sanitize_faq_items( '[]' ) ); + } + + public function test_sanitize_faq_items_malformed_json(): void { + $this->assertSame( '[]', PR_Core_Schema_Sanitizers::sanitize_faq_items( 'not-json' ) ); + } + + public function test_sanitize_faq_items_drops_item_missing_answer(): void { + $result = PR_Core_Schema_Sanitizers::sanitize_faq_items( wp_json_encode( [ [ 'question' => 'No answer here?' ] ] ) ); + $decoded = json_decode( $result, true ); + $this->assertCount( 0, $decoded ); + } +} diff --git a/tests/unit/JsonldWebpageTest.php b/tests/unit/JsonldWebpageTest.php new file mode 100644 index 0000000..085b347 --- /dev/null +++ b/tests/unit/JsonldWebpageTest.php @@ -0,0 +1,112 @@ +retype_to_medical_webpage( [ 'WebPage' ] ); + $this->assertSame( [ 'WebPage' ], $result ); + } + + public function test_retype_peptide_singular_array_returns_medical_webpage_string(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'peptide'; + $result = ( new PR_Core_Jsonld_Webpage() )->retype_to_medical_webpage( [ 'WebPage' ] ); + $this->assertSame( 'MedicalWebPage', $result ); + } + + public function test_retype_string_input_non_singular_passthrough(): void { + $GLOBALS['pr_test_is_singular'] = false; + $GLOBALS['pr_test_singular_type'] = ''; + $result = ( new PR_Core_Jsonld_Webpage() )->retype_to_medical_webpage( 'CollectionPage' ); + $this->assertSame( 'CollectionPage', $result ); + } + + // ── enrich_webpage_piece: @type as array injects enrichments ────── + + public function test_enrich_webpage_piece_array_type_injects_last_reviewed(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'peptide'; + $GLOBALS['pr_test_the_id'] = 42; + $GLOBALS['pr_test_postmeta'][42] = [ '_pr_last_reviewed' => '2026-06-01' ]; + + $graph = [ [ '@type' => [ 'WebPage' ], '@id' => 'https://peptiderepo.com/peptides/bpc-157/#webpage', 'name' => 'BPC-157' ] ]; + $result_graph = ( new PR_Core_Jsonld_Webpage() )->enrich_webpage_piece( $graph, new stdClass() ); + $piece = $result_graph[0] ?? []; + $this->assertArrayHasKey( 'lastReviewed', $piece ); + $this->assertSame( '2026-06-01', $piece['lastReviewed'] ); + } + + public function test_enrich_webpage_piece_array_type_injects_reviewed_by(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'peptide'; + $GLOBALS['pr_test_the_id'] = 42; + $GLOBALS['pr_test_postmeta'][42] = [ '_pr_last_reviewed' => '2026-06-01' ]; + + $graph = [ [ '@type' => [ 'WebPage' ], '@id' => 'https://peptiderepo.com/peptides/bpc-157/#webpage' ] ]; + $result_graph = ( new PR_Core_Jsonld_Webpage() )->enrich_webpage_piece( $graph, new stdClass() ); + $this->assertArrayHasKey( 'reviewedBy', $result_graph[0] ?? [] ); + } + + public function test_enrich_webpage_piece_array_type_injects_audience(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'peptide'; + $GLOBALS['pr_test_the_id'] = 42; + $GLOBALS['pr_test_postmeta'][42] = [ '_pr_last_reviewed' => '2026-06-01' ]; + + $graph = [ [ '@type' => [ 'WebPage' ], '@id' => 'https://peptiderepo.com/peptides/bpc-157/#webpage' ] ]; + $result_graph = ( new PR_Core_Jsonld_Webpage() )->enrich_webpage_piece( $graph, new stdClass() ); + $this->assertArrayHasKey( 'audience', $result_graph[0] ?? [] ); + } + + public function test_enrich_webpage_piece_string_medical_webpage_also_enriches(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'peptide'; + $GLOBALS['pr_test_the_id'] = 42; + $GLOBALS['pr_test_postmeta'][42] = [ '_pr_last_reviewed' => '2026-06-01' ]; + + $graph = [ [ '@type' => 'MedicalWebPage', '@id' => 'https://peptiderepo.com/peptides/bpc-157/#webpage' ] ]; + $result_graph = ( new PR_Core_Jsonld_Webpage() )->enrich_webpage_piece( $graph, new stdClass() ); + $this->assertArrayHasKey( 'lastReviewed', $result_graph[0] ?? [] ); + } + + public function test_enrich_webpage_piece_non_peptide_does_not_inject(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'page'; + $GLOBALS['pr_test_the_id'] = 50; + $GLOBALS['pr_test_postmeta'][50] = []; + + $graph = [ [ '@type' => [ 'WebPage' ], '@id' => 'https://peptiderepo.com/about/#webpage' ] ]; + $result_graph = ( new PR_Core_Jsonld_Webpage() )->enrich_webpage_piece( $graph, new stdClass() ); + $this->assertArrayNotHasKey( 'lastReviewed', $result_graph[0] ?? [] ); + } +} diff --git a/tests/unit/Migration0004Test.php b/tests/unit/Migration0004Test.php new file mode 100644 index 0000000..d6b9b7b --- /dev/null +++ b/tests/unit/Migration0004Test.php @@ -0,0 +1,239 @@ + [], + 'existing_taxonomies' => [], + 'registered_post_types' => [], + 'registered_taxonomies' => [], + 'registered_meta' => [], + 'added_actions' => [], + 'added_filters' => [], + ]; + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-schema-sanitizers.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/migrations/class-pr-core-pubchem-client.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php'; + $this->migration = new PR_Core_Migration_0004_Backfill_Peptide_Meta(); + } + + // ── Helper: call private method via reflection ──────────────────── + + /** + * @param mixed[] $args + * @return mixed + */ + private function call_private( object $obj, string $method, array $args = [] ) { + $ref = new ReflectionMethod( $obj, $method ); + $ref->setAccessible( true ); + return $ref->invokeArgs( $obj, $args ); + } + + // ── parse_weight() ──────────────────────────────────────────────── + + public function test_parse_weight_strips_da_suffix(): void { + $this->assertSame( 1419.5, $this->call_private( $this->migration, 'parse_weight', [ '1419.5 Da' ] ) ); + } + + public function test_parse_weight_strips_uppercase_da(): void { + $this->assertSame( 1419.5, $this->call_private( $this->migration, 'parse_weight', [ '1419.5 DA' ] ) ); + } + + public function test_parse_weight_plain_float(): void { + $this->assertSame( 1419.5, $this->call_private( $this->migration, 'parse_weight', [ '1419.5' ] ) ); + } + + public function test_parse_weight_empty_returns_zero(): void { + $this->assertSame( 0.0, $this->call_private( $this->migration, 'parse_weight', [ '' ] ) ); + } + + public function test_parse_weight_suffix_only_returns_zero(): void { + $this->assertSame( 0.0, $this->call_private( $this->migration, 'parse_weight', [ ' Da ' ] ) ); + } + + public function test_parse_weight_lowercase_da_with_whitespace(): void { + $this->assertSame( 800.123, $this->call_private( $this->migration, 'parse_weight', [ ' 800.123 da ' ] ) ); + } + + // ── parse_aliases_string() ──────────────────────────────────────── + + public function test_parse_aliases_string_splits_csv(): void { + $result = json_decode( + $this->call_private( $this->migration, 'parse_aliases_string', [ 'Body Protection Compound-157, PL 14736, PL-10, Bepecin' ] ), + true + ); + $this->assertIsArray( $result ); + $this->assertCount( 4, $result ); + $this->assertSame( 'Body Protection Compound-157', $result[0] ); + $this->assertSame( 'PL 14736', $result[1] ); + } + + public function test_parse_aliases_string_empty_returns_json_empty_array(): void { + $this->assertSame( '[]', $this->call_private( $this->migration, 'parse_aliases_string', [ '' ] ) ); + } + + public function test_parse_aliases_string_whitespace_only_returns_json_empty_array(): void { + $this->assertSame( '[]', $this->call_private( $this->migration, 'parse_aliases_string', [ ' , , ' ] ) ); + } + + public function test_parse_aliases_string_single_alias(): void { + $result = json_decode( $this->call_private( $this->migration, 'parse_aliases_string', [ 'Only One' ] ), true ); + $this->assertCount( 1, $result ); + } + + // ── sanitize_molecular_formula() ────────────────────────────────── + + public function test_sanitize_formula_valid_passes_unchanged(): void { + $this->assertSame( 'C62H98N16O22', PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C62H98N16O22' ) ); + } + + public function test_sanitize_formula_strips_html_tags(): void { + $this->assertSame( 'C62H98N16O22', PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C62H98N16O22' ) ); + } + + public function test_sanitize_formula_preserves_parentheses(): void { + $this->assertSame( 'C2H5(OH)', PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C2H5(OH)' ) ); + } + + public function test_sanitize_formula_strips_non_formula_chars(): void { + $this->assertSame( 'C10H12', PR_Core_Schema_Sanitizers::sanitize_molecular_formula( "C10H12\ninjection" ) ); + } + + public function test_sanitize_formula_fully_invalid_returns_empty(): void { + $this->assertSame( '', PR_Core_Schema_Sanitizers::sanitize_molecular_formula( '' ) ); + } + + public function test_sanitize_formula_empty_returns_empty(): void { + $this->assertSame( '', PR_Core_Schema_Sanitizers::sanitize_molecular_formula( '' ) ); + } + + public function test_sanitize_formula_whitespace_only_returns_empty(): void { + $this->assertSame( '', PR_Core_Schema_Sanitizers::sanitize_molecular_formula( ' ' ) ); + } + + // ── Idempotency ─────────────────────────────────────────────────── + + public function test_backfill_post_skipped_when_all_keys_populated(): void { + $GLOBALS['pr_test_postmeta'][1] = [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Alias One"]', + ]; + $result = $this->call_private( $this->migration, 'backfill_post', [ 1 ] ); + $this->assertSame( 'skipped', $result ); + $this->assertEmpty( $GLOBALS['pr_test_updated_meta'] ); + } + + // ── PSA source path ─────────────────────────────────────────────── + + public function test_backfill_post_copies_psa_data(): void { + $GLOBALS['pr_test_postmeta'][2] = [ + 'psa_molecular_formula' => 'C62H98N16O22', + 'psa_molecular_weight' => '1419.5 Da', + 'psa_aliases' => 'Body Protection Compound-157, PL 14736', + ]; + $GLOBALS['pr_test_posts'][2] = (object) [ 'post_title' => 'BPC-157', 'ID' => 2 ]; + $result = $this->call_private( $this->migration, 'backfill_post', [ 2 ] ); + $this->assertSame( 'copied_psa', $result ); + } + + public function test_backfill_post_psa_writes_formula_correctly(): void { + $GLOBALS['pr_test_postmeta'][2] = [ + 'psa_molecular_formula' => 'C62H98N16O22', + 'psa_molecular_weight' => '1419.5 Da', + 'psa_aliases' => 'Body Protection Compound-157', + ]; + $GLOBALS['pr_test_posts'][2] = (object) [ 'post_title' => 'BPC-157', 'ID' => 2 ]; + $this->call_private( $this->migration, 'backfill_post', [ 2 ] ); + $written = null; + foreach ( $GLOBALS['pr_test_updated_meta'] as $entry ) { + if ( '_pr_molecular_formula' === $entry['key'] ) { + $written = $entry['value']; + } + } + $this->assertSame( 'C62H98N16O22', $written ); + } + + public function test_backfill_post_psa_strips_da_from_weight(): void { + $GLOBALS['pr_test_postmeta'][2] = [ + 'psa_molecular_formula' => 'C62H98N16O22', + 'psa_molecular_weight' => '1419.5 Da', + 'psa_aliases' => 'Body Protection Compound-157', + ]; + $GLOBALS['pr_test_posts'][2] = (object) [ 'post_title' => 'BPC-157', 'ID' => 2 ]; + $this->call_private( $this->migration, 'backfill_post', [ 2 ] ); + $written = null; + foreach ( $GLOBALS['pr_test_updated_meta'] as $entry ) { + if ( '_pr_molecular_weight' === $entry['key'] ) { + $written = $entry['value']; + } + } + $this->assertSame( '1419.5', $written ); + } + + public function test_backfill_post_psa_writes_aliases_as_json_array(): void { + $GLOBALS['pr_test_postmeta'][2] = [ + 'psa_molecular_formula' => 'C62H98N16O22', + 'psa_molecular_weight' => '1419.5 Da', + 'psa_aliases' => 'Body Protection Compound-157, PL 14736', + ]; + $GLOBALS['pr_test_posts'][2] = (object) [ 'post_title' => 'BPC-157', 'ID' => 2 ]; + $this->call_private( $this->migration, 'backfill_post', [ 2 ] ); + $written = null; + foreach ( $GLOBALS['pr_test_updated_meta'] as $entry ) { + if ( '_pr_aliases' === $entry['key'] ) { + $written = $entry['value']; + } + } + $aliases = json_decode( $written, true ); + $this->assertIsArray( $aliases ); + $this->assertCount( 2, $aliases ); + } + + // ── PubChem skip path ───────────────────────────────────────────── + + public function test_backfill_post_returns_pubchem_skip_when_network_fails(): void { + $mock_pubchem = new class extends PR_Core_Pubchem_Client { + public function fetch_by_cid( string $cid ): ?array { return null; } + public function fetch_by_name( string $name ): ?array { return null; } + }; + $mock_migration = new PR_Core_Migration_0004_Backfill_Peptide_Meta( $mock_pubchem ); + + $GLOBALS['pr_test_postmeta'][10] = []; + $GLOBALS['pr_test_posts'][10] = (object) [ 'post_title' => 'IGF-1 LR3', 'ID' => 10 ]; + + $result = $this->call_private( $mock_migration, 'backfill_post', [ 10 ] ); + $this->assertSame( 'pubchem_skip', $result ); + $this->assertEmpty( $GLOBALS['pr_test_updated_meta'] ); + } +} diff --git a/tests/unit/PeptideCptTest.php b/tests/unit/PeptideCptTest.php new file mode 100644 index 0000000..0e1284d --- /dev/null +++ b/tests/unit/PeptideCptTest.php @@ -0,0 +1,247 @@ + [], + 'existing_taxonomies' => [], + 'registered_post_types' => [], + 'registered_taxonomies' => [], + 'registered_meta' => [], + 'added_actions' => [], + 'added_filters' => [], + ]; + // Ensure CPT class is loaded. + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php'; + } + + // ── Constants ───────────────────────────────────────────────────── + + public function test_post_type_constant(): void { + $this->assertSame( 'peptide', PR_Core_Peptide_CPT::POST_TYPE ); + } + + public function test_tax_category_constant(): void { + $this->assertSame( 'peptide_category', PR_Core_Peptide_CPT::TAX_CATEGORY ); + } + + public function test_capability_constant(): void { + $this->assertSame( 'manage_peptide_content', PR_Core_Peptide_CPT::CAPABILITY ); + } + + public function test_tax_family_constant_removed(): void { + $reflection = new ReflectionClass( PR_Core_Peptide_CPT::class ); + $this->assertFalse( + $reflection->hasConstant( 'TAX_FAMILY' ), + 'TAX_FAMILY constant should have been removed in v0.2.0' + ); + } + + // ── CPT registration guard ──────────────────────────────────────── + + public function test_register_post_type_noop_when_already_exists(): void { + $GLOBALS['pr_core_test_state']['existing_post_types'] = [ 'peptide' ]; + PR_Core_Peptide_CPT::register_peptide_post_type(); + $this->assertEmpty( + $GLOBALS['pr_core_test_state']['registered_post_types'], + 'register_peptide_post_type() must no-op when post_type_exists("peptide") is true' + ); + } + + // ── CPT registration payload ────────────────────────────────────── + + public function test_register_post_type_registers_peptide_slug(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $this->assertArrayHasKey( + 'peptide', + $GLOBALS['pr_core_test_state']['registered_post_types'] + ); + } + + public function test_register_post_type_args_public(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertTrue( $args['public'] ?? null ); + } + + public function test_register_post_type_args_publicly_queryable(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertTrue( $args['publicly_queryable'] ?? null ); + } + + public function test_register_post_type_args_show_in_rest(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertTrue( $args['show_in_rest'] ?? null ); + } + + public function test_register_post_type_args_rest_base(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertSame( 'peptides', $args['rest_base'] ?? null ); + } + + public function test_register_post_type_args_no_rest_namespace(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertArrayNotHasKey( + 'rest_namespace', + $args, + 'rest_namespace must be absent — defaults to wp/v2 for Gutenberg compatibility' + ); + } + + public function test_register_post_type_args_capability_type(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertSame( 'post', $args['capability_type'] ?? null ); + } + + public function test_register_post_type_args_map_meta_cap(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertTrue( $args['map_meta_cap'] ?? null ); + } + + public function test_register_post_type_args_not_hierarchical(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertFalse( $args['hierarchical'] ?? null ); + } + + public function test_register_post_type_args_has_archive(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertTrue( $args['has_archive'] ?? null ); + } + + public function test_register_post_type_args_rewrite_slug(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertSame( 'peptides', $args['rewrite']['slug'] ?? null ); + } + + public function test_register_post_type_args_rewrite_with_front(): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $this->assertFalse( $args['rewrite']['with_front'] ?? null ); + } + + /** + * @dataProvider supports_provider + */ + public function test_register_post_type_supports_feature( string $feature ): void { + PR_Core_Peptide_CPT::register_peptide_post_type(); + $args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide']; + $supports = $args['supports'] ?? []; + $this->assertContains( $feature, $supports ); + } + + /** + * @return array> + */ + public static function supports_provider(): array { + return [ + [ 'title' ], + [ 'editor' ], + [ 'thumbnail' ], + [ 'excerpt' ], + [ 'revisions' ], + [ 'custom-fields' ], + ]; + } + + // ── Taxonomy guard ──────────────────────────────────────────────── + + public function test_register_taxonomies_noop_when_already_exists(): void { + $GLOBALS['pr_core_test_state']['existing_taxonomies'] = [ 'peptide_category' ]; + PR_Core_Peptide_CPT::register_taxonomies(); + $this->assertEmpty( $GLOBALS['pr_core_test_state']['registered_taxonomies'] ); + } + + // ── Taxonomy registration payload ───────────────────────────────── + + public function test_register_taxonomies_registers_peptide_category(): void { + PR_Core_Peptide_CPT::register_taxonomies(); + $this->assertArrayHasKey( + 'peptide_category', + $GLOBALS['pr_core_test_state']['registered_taxonomies'] + ); + } + + public function test_register_taxonomies_does_not_register_pr_peptide_family(): void { + PR_Core_Peptide_CPT::register_taxonomies(); + $this->assertArrayNotHasKey( + 'pr_peptide_family', + $GLOBALS['pr_core_test_state']['registered_taxonomies'], + 'pr_peptide_family taxonomy must not be registered (removed in v0.2.0)' + ); + } + + public function test_register_taxonomies_does_not_register_peptide_family(): void { + PR_Core_Peptide_CPT::register_taxonomies(); + $this->assertArrayNotHasKey( + 'peptide_family', + $GLOBALS['pr_core_test_state']['registered_taxonomies'] + ); + } + + public function test_register_taxonomies_attached_to_peptide_cpt(): void { + PR_Core_Peptide_CPT::register_taxonomies(); + $tax = $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category']; + $this->assertSame( 'peptide', $tax['object_type'] ?? null ); + } + + public function test_register_taxonomies_is_hierarchical(): void { + PR_Core_Peptide_CPT::register_taxonomies(); + $tax = $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category']; + $this->assertTrue( $tax['args']['hierarchical'] ?? null ); + } + + public function test_register_taxonomies_show_in_rest(): void { + PR_Core_Peptide_CPT::register_taxonomies(); + $tax = $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category']; + $this->assertTrue( $tax['args']['show_in_rest'] ?? null ); + } + + public function test_register_taxonomies_rewrite_slug(): void { + PR_Core_Peptide_CPT::register_taxonomies(); + $tax = $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category']; + $this->assertSame( 'peptide-category', $tax['args']['rewrite']['slug'] ?? null ); + } + + // ── Hook registration ───────────────────────────────────────────── + + public function test_register_hooks_adds_init_action(): void { + $cpt = new PR_Core_Peptide_CPT(); + $cpt->register_hooks(); + $hooks = array_column( $GLOBALS['pr_core_test_state']['added_actions'], 'hook' ); + $this->assertContains( 'init', $hooks ); + } +} diff --git a/tests/unit/VerificationScannerTest.php b/tests/unit/VerificationScannerTest.php new file mode 100644 index 0000000..4cfb051 --- /dev/null +++ b/tests/unit/VerificationScannerTest.php @@ -0,0 +1,126 @@ += 90% and < 100% → due. + * - Days >= threshold → overdue. + * - Boundary at exactly 90% → due. + * - high velocity applies 60-day threshold from option. + * - low velocity applies 365-day constant (ignores option). + * - medium/default applies 180-day threshold from option. + * - Unknown velocity falls to medium/default. + * + * @package PeptideRepoCore\Tests + */ + +use PHPUnit\Framework\TestCase; + +/** + * Tests for PR_Core_Verification_Scanner::compute_status(). + */ +class VerificationScannerTest extends TestCase { + + protected function setUp(): void { + $GLOBALS['pr_core_options'] = []; + require_once PR_CORE_PLUGIN_DIR . 'includes/scanner/class-pr-core-verification-scanner.php'; + } + + // ── Helper ──────────────────────────────────────────────────────── + + private function days_ago( int $days ): string { + return gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); + } + + // ── Empty last_verified ─────────────────────────────────────────── + + public function test_empty_last_verified_returns_overdue(): void { + $this->assertSame( + 'overdue', + PR_Core_Verification_Scanner::compute_status( '', 'medium' ) + ); + } + + // ── Medium velocity (default 180-day threshold) ─────────────────── + + public function test_medium_160_days_is_current(): void { + $this->assertSame( 'current', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 160 ), 'medium' ) ); + } + + public function test_medium_163_days_is_due(): void { + $this->assertSame( 'due', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 163 ), 'medium' ) ); + } + + public function test_medium_180_days_is_overdue(): void { + $this->assertSame( 'overdue', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 180 ), 'medium' ) ); + } + + public function test_medium_200_days_is_overdue(): void { + $this->assertSame( 'overdue', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 200 ), 'medium' ) ); + } + + // ── 90% boundary exactness ──────────────────────────────────────── + + public function test_boundary_89_days_threshold_100_is_current(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 100 ]; + $this->assertSame( 'current', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 89 ), 'medium' ) ); + } + + public function test_boundary_90_days_threshold_100_is_due(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 100 ]; + $this->assertSame( 'due', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 90 ), 'medium' ) ); + } + + public function test_boundary_100_days_threshold_100_is_overdue(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 100 ]; + $this->assertSame( 'overdue', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 100 ), 'medium' ) ); + } + + // ── High velocity (60-day threshold from option) ────────────────── + + public function test_high_50_days_is_current(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_high_velocity_threshold' => 60 ]; + $this->assertSame( 'current', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 50 ), 'high' ) ); + } + + public function test_high_55_days_is_due(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_high_velocity_threshold' => 60 ]; + $this->assertSame( 'due', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 55 ), 'high' ) ); + } + + public function test_high_61_days_is_overdue(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_high_velocity_threshold' => 60 ]; + $this->assertSame( 'overdue', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 61 ), 'high' ) ); + } + + // ── Low velocity (365-day constant — ignores option) ────────────── + + public function test_low_300_days_is_current(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 999 ]; + $this->assertSame( 'current', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 300 ), 'low' ) ); + } + + public function test_low_340_days_is_due(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 999 ]; + $this->assertSame( 'due', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 340 ), 'low' ) ); + } + + public function test_low_366_days_is_overdue(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 999 ]; + $this->assertSame( 'overdue', PR_Core_Verification_Scanner::compute_status( $this->days_ago( 366 ), 'low' ) ); + } + + // ── Unknown velocity falls to medium/default ────────────────────── + + public function test_unknown_velocity_uses_default_threshold(): void { + $GLOBALS['pr_core_options'] = [ 'pr_core_default_threshold' => 180 ]; + $this->assertSame( + 'current', + PR_Core_Verification_Scanner::compute_status( $this->days_ago( 100 ), 'unknown' ) + ); + } +} diff --git a/tests/unit/VerificationSettingsTest.php b/tests/unit/VerificationSettingsTest.php new file mode 100644 index 0000000..132afaf --- /dev/null +++ b/tests/unit/VerificationSettingsTest.php @@ -0,0 +1,144 @@ + [], + 'existing_taxonomies' => [], + 'registered_post_types' => [], + 'registered_taxonomies' => [], + 'registered_meta' => [], + 'added_actions' => [], + 'added_filters' => [], + ]; + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/admin/class-pr-core-settings.php'; + } + + // ── sanitize_cadence() ──────────────────────────────────────────── + + public function test_sanitize_cadence_daily(): void { + $this->assertSame( 'daily', PR_Core_Settings::sanitize_cadence( 'daily' ) ); + } + + public function test_sanitize_cadence_weekly(): void { + $this->assertSame( 'weekly', PR_Core_Settings::sanitize_cadence( 'weekly' ) ); + } + + public function test_sanitize_cadence_monthly(): void { + $this->assertSame( 'monthly', PR_Core_Settings::sanitize_cadence( 'monthly' ) ); + } + + public function test_sanitize_cadence_invalid_defaults_to_weekly(): void { + $this->assertSame( 'weekly', PR_Core_Settings::sanitize_cadence( 'hourly' ) ); + } + + public function test_sanitize_cadence_empty_defaults_to_weekly(): void { + $this->assertSame( 'weekly', PR_Core_Settings::sanitize_cadence( '' ) ); + } + + public function test_sanitize_cadence_uppercase_defaults_to_weekly(): void { + $this->assertSame( 'weekly', PR_Core_Settings::sanitize_cadence( 'DAILY' ) ); + } + + // ── sanitize_emails() ───────────────────────────────────────────── + + public function test_sanitize_emails_empty_returns_empty(): void { + $this->assertSame( '', PR_Core_Settings::sanitize_emails( '' ) ); + } + + public function test_sanitize_emails_valid_single_kept(): void { + $this->assertSame( 'admin@example.com', PR_Core_Settings::sanitize_emails( 'admin@example.com' ) ); + } + + public function test_sanitize_emails_invalid_single_returns_empty(): void { + $this->assertSame( '', PR_Core_Settings::sanitize_emails( 'not-an-email' ) ); + } + + public function test_sanitize_emails_valid_pair_both_kept(): void { + $this->assertSame( 'a@test.com, b@test.com', PR_Core_Settings::sanitize_emails( 'a@test.com, b@test.com' ) ); + } + + public function test_sanitize_emails_mixed_only_valid_kept(): void { + $this->assertSame( 'good@test.com', PR_Core_Settings::sanitize_emails( 'good@test.com, bad-email' ) ); + } + + public function test_sanitize_emails_whitespace_trimmed(): void { + $this->assertSame( 'trimmed@test.com', PR_Core_Settings::sanitize_emails( ' trimmed@test.com ' ) ); + } + + // ── reschedule_cron(): no-op when same value ────────────────────── + + public function test_reschedule_cron_noop_when_value_unchanged(): void { + PR_Core_Settings::reschedule_cron( 'weekly', 'weekly' ); + $this->assertEmpty( $GLOBALS['pr_core_cron_calls'] ); + } + + // ── reschedule_cron(): clears + reschedules on change ───────────── + + public function test_reschedule_cron_calls_clear_when_changed(): void { + PR_Core_Settings::reschedule_cron( 'weekly', 'daily' ); + $actions = array_column( $GLOBALS['pr_core_cron_calls'], 'action' ); + $this->assertContains( 'clear', $actions ); + } + + public function test_reschedule_cron_calls_schedule_when_changed(): void { + PR_Core_Settings::reschedule_cron( 'weekly', 'daily' ); + $actions = array_column( $GLOBALS['pr_core_cron_calls'], 'action' ); + $this->assertContains( 'schedule', $actions ); + } + + public function test_reschedule_cron_schedules_correct_hook(): void { + PR_Core_Settings::reschedule_cron( 'weekly', 'daily' ); + $schedule_call = null; + foreach ( $GLOBALS['pr_core_cron_calls'] as $call ) { + if ( 'schedule' === $call['action'] ) { + $schedule_call = $call; + break; + } + } + $this->assertNotNull( $schedule_call ); + $this->assertSame( 'pr_core_verification_scan', $schedule_call['hook'] ?? '' ); + } + + public function test_reschedule_cron_schedules_new_cadence(): void { + PR_Core_Settings::reschedule_cron( 'weekly', 'daily' ); + $schedule_call = null; + foreach ( $GLOBALS['pr_core_cron_calls'] as $call ) { + if ( 'schedule' === $call['action'] ) { + $schedule_call = $call; + break; + } + } + $this->assertSame( 'daily', $schedule_call['recurrence'] ?? '' ); + } + + public function test_reschedule_cron_clear_fires_before_schedule(): void { + PR_Core_Settings::reschedule_cron( 'weekly', 'daily' ); + $actions = array_column( $GLOBALS['pr_core_cron_calls'], 'action' ); + $clear_idx = array_search( 'clear', $actions, true ); + $schedule_idx = array_search( 'schedule', $actions, true ); + $this->assertLessThan( $schedule_idx, $clear_idx ); + } +}