ci: adopt estate CI standard + convert 7 hand-rolled tests to PHPUnit (v0.7.0)

- Replace hand-rolled ci.yml jobs with thin caller to reusable estate workflow
  (peptiderepo/peptide-e2e/.github/workflows/ci.yml@main); tests: stubs; has_js: false.
  workflow_call + permissions: contents: write added per estate standard so deploy.yml
  can gate on it and phpcbf auto-commit step has required token scope.

- Convert 7 hand-rolled test-*.php scripts to PHPUnit TestCase classes:
    PeptideCptTest        <- test-peptide-cpt.php
    VerificationScannerTest <- test-verification-scanner.php
    VerificationSettingsTest <- test-verification-settings.php
    Migration0004Test     <- test-migration-0004.php
    JsonldTest            <- test-jsonld.php
    JsonldWebpageTest     <- test-jsonld-webpage.php
    JsonldPeptidePieceTest <- test-jsonld-peptide-piece.php

  All assertions preserved; old scripts remain in tests/unit/ but CI no longer
  runs them (PHPUnit discovery matches *Test.php only).

- Rewrite tests/bootstrap.php: full WP stub library for PHPUnit (WP_Post, WP_Error,
  all option/cron/CPT/taxonomy/permalink stubs); registers PR_Core_Autoloader.
- Add phpunit.xml.dist (bootstrap, unit suite, includes/ coverage).
- Add phpunit/phpunit ^9.6 + yoast/phpunit-polyfills ^2.0 to require-dev.
- Add composer test script (vendor/bin/phpunit --configuration phpunit.xml.dist).
- deploy.yml: add permissions: contents: write.
- Version bump 0.6.2 -> 0.7.0.

Agent-Session: claude-adopt-ci-20260616
This commit is contained in:
peptiderepo
2026-06-16 15:23:48 +00:00
parent d0b5875fd8
commit 08d7696444
14 changed files with 1884 additions and 194 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
]
}
}

View File

@@ -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__ ) );

20
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
>
<testsuites>
<testsuite name="unit">
<directory suffix="Test.php">./tests/unit</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./includes</directory>
</include>
</coverage>
</phpunit>

View File

@@ -1,165 +1,417 @@
<?php
/**
* Minimal test bootstrap — stubs the WordPress functions used by the classes
* under test so they can be exercised in plain PHP without a WP test install.
* PHPUnit bootstrap for Peptide Repo Core tests.
*
* This is intentionally NOT a full WP_UnitTestCase harness. It exists so the
* targeted unit assertions in tests/unit/ can verify guard behavior, constant
* shape, and args payload shape on CI (PHP lint job) without introducing a
* PHPUnit + wp-env dependency. A proper integration test harness is a
* follow-up (see CHANGELOG 0.2.0 notes).
* Loads WordPress stubs so unit tests can reference WP functions/classes
* without requiring a full WordPress installation.
*
* @package PeptideRepoCore
* @package PeptideRepoCore\Tests
*/
declare(strict_types=1);
// Composer autoloader.
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
// ── WordPress constants ───────────────────────────────────────────────
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
define( 'ABSPATH', '/tmp/wordpress/' );
}
if ( ! defined( 'WPINC' ) ) {
define( 'WPINC', 'wp-includes' );
}
if ( ! defined( 'PR_CORE_VERSION' ) ) {
define( 'PR_CORE_VERSION', '0.2.0' );
define( 'PR_CORE_VERSION', '0.7.0-test' );
}
if ( ! defined( 'PR_CORE_PLUGIN_DIR' ) ) {
define( 'PR_CORE_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
define( 'PR_CORE_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
}
if ( ! defined( 'PR_CORE_PLUGIN_URL' ) ) {
define( 'PR_CORE_PLUGIN_URL', 'http://example.test/wp-content/plugins/peptide-repo-core/' );
define( 'PR_CORE_PLUGIN_URL', 'https://example.com/wp-content/plugins/peptide-repo-core/' );
}
if ( ! defined( 'PR_CORE_PLUGIN_FILE' ) ) {
define( 'PR_CORE_PLUGIN_FILE', PR_CORE_PLUGIN_DIR . 'peptide-repo-core.php' );
}
if ( ! defined( 'PR_CORE_TARGET_SCHEMA_VERSION' ) ) {
define( 'PR_CORE_TARGET_SCHEMA_VERSION', 4 );
}
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
define( 'HOUR_IN_SECONDS', 3600 );
}
if ( ! defined( 'MINUTE_IN_SECONDS' ) ) {
define( 'MINUTE_IN_SECONDS', 60 );
}
// ── Global harness state (reset between test cases via setUp) ─────────
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'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<string, mixed>
*/
$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<string, string[]> */
public array $errors = [];
/** @var array<string, mixed> */
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<string, mixed>|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';

View File

@@ -0,0 +1,285 @@
<?php
/**
* Regression tests for v0.6.2: inject_graph_pieces (plain array) fatal on Yoast.
*
* Ported from tests/unit/test-jsonld-peptide-piece.php.
*
* Root cause: v0.6.0/v0.6.1 hooked wpseo_schema_graph_pieces and appended plain
* PHP arrays. Yoast's filter_graph_pieces_to_generate() called get_class() on
* every piece, causing: "PHP Fatal: get_class(): Argument #1 ($object) must be
* of type object, array given".
*
* Fix: inject via wpseo_schema_graph (priority 12) using inject_graph_nodes().
*
* @package PeptideRepoCore\Tests
*/
use PHPUnit\Framework\TestCase;
/**
* Tests for PR_Core_Jsonld::inject_graph_nodes() and related regression.
*/
class JsonldPeptidePieceTest extends TestCase {
protected function setUp(): void {
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_is_singular'] = false;
$GLOBALS['pr_test_singular_type'] = '';
$GLOBALS['pr_test_the_id'] = 0;
$GLOBALS['pr_test_peptide_dto'] = null;
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'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<string, mixed> $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<int, array<string, mixed>>
*/
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 );
}
}

297
tests/unit/JsonldTest.php Normal file
View File

@@ -0,0 +1,297 @@
<?php
/**
* Unit tests for JSON-LD v0.6.0 emission.
*
* Ported from tests/unit/test-jsonld.php.
*
* Coverage:
* - Drug node: @id, @type, fields present/absent based on meta.
* - FAQPage: present/absent, @id, mainEntity count.
* - MedicalWebPage retype on/off peptide singular.
* - No duplicate WebPage/BreadcrumbList nodes from our builders.
* - PR_Core_Schema_Sanitizers::sanitize_faq_items() validation.
*
* @package PeptideRepoCore\Tests
*/
use PHPUnit\Framework\TestCase;
/**
* Tests for JSON-LD Drug/FAQ/Webpage emission.
*/
class JsonldTest extends TestCase {
protected function setUp(): void {
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_is_singular'] = false;
$GLOBALS['pr_test_singular_type'] = '';
$GLOBALS['pr_test_the_id'] = 0;
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'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<string, mixed> $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 );
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* Regression tests for v0.6.1: Yoast wpseo_schema_webpage_type array contract.
*
* Ported from tests/unit/test-jsonld-webpage.php.
*
* Root cause: v0.6.0 retype_to_medical_webpage() was strict-typed string, but
* Yoast passes ['WebPage'] (array) on every singular page.
* strict_types=1 → TypeError → HTTP 500 on all monographs + plain Pages.
*
* These tests fail against v0.6.0 and pass after the v0.6.1 fix.
*
* @package PeptideRepoCore\Tests
*/
use PHPUnit\Framework\TestCase;
/**
* Regression tests for PR_Core_Jsonld_Webpage v0.6.1 array-type contract.
*/
class JsonldWebpageTest extends TestCase {
protected function setUp(): void {
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_is_singular'] = false;
$GLOBALS['pr_test_singular_type'] = '';
$GLOBALS['pr_test_the_id'] = 0;
require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-webpage.php';
}
// ── retype_to_medical_webpage: array input ────────────────────────
public function test_retype_non_peptide_singular_array_unchanged(): void {
$GLOBALS['pr_test_is_singular'] = true;
$GLOBALS['pr_test_singular_type'] = 'page';
$result = ( new PR_Core_Jsonld_Webpage() )->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] ?? [] );
}
}

View File

@@ -0,0 +1,239 @@
<?php
/**
* Unit tests for PR_Core_Migration_0004_Backfill_Peptide_Meta.
*
* Ported from tests/unit/test-migration-0004.php.
*
* Coverage:
* - parse_weight(): strips "Da"/"DA"/"da" suffix, handles plain float,
* empty string → 0.0, suffix-only → 0.0.
* - parse_aliases_string(): splits CSV, trims, drops empty, returns '[]' for empty.
* - PR_Core_Schema_Sanitizers::sanitize_molecular_formula(): valid, HTML, groups,
* injection, fully-invalid, empty.
* - Idempotency: backfill_post() returns 'skipped' when all three keys populated.
* - PSA source path: copies and transforms psa_* → _pr_* keys.
* - PubChem skip path: 'pubchem_skip' when PubChem network fails.
*
* @package PeptideRepoCore\Tests
*/
use PHPUnit\Framework\TestCase;
/**
* Tests for PR_Core_Migration_0004_Backfill_Peptide_Meta.
*/
class Migration0004Test extends TestCase {
/** @var PR_Core_Migration_0004_Backfill_Peptide_Meta */
private $migration;
protected function setUp(): void {
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_updated_meta'] = [];
$GLOBALS['pr_test_posts'] = [];
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'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( '<b>C62H98N16O22</b>' ) );
}
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( '<script>alert(1)</script>' ) );
}
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'] );
}
}

View File

@@ -0,0 +1,247 @@
<?php
/**
* Unit tests for PR_Core_Peptide_CPT (v0.2.0 CPT consolidation).
*
* Ported from tests/unit/test-peptide-cpt.php.
*
* Coverage:
* - Constants match the v0.2.0 consolidated contract.
* - TAX_FAMILY constant is removed.
* - register_peptide_post_type() guard: no-op when post_type_exists.
* - register_peptide_post_type() payload: args match contract.
* - register_taxonomies() guard: no-op when taxonomy_exists.
* - register_taxonomies() only registers peptide_category.
* - register_hooks() adds action on 'init'.
*
* @package PeptideRepoCore\Tests
*/
use PHPUnit\Framework\TestCase;
/**
* Tests for PR_Core_Peptide_CPT.
*/
class PeptideCptTest extends TestCase {
/**
* Reset global WP state before each test.
*/
protected function setUp(): void {
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'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<array<string>>
*/
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 );
}
}

View File

@@ -0,0 +1,126 @@
<?php
/**
* Unit tests for PR_Core_Verification_Scanner::compute_status().
*
* Ported from tests/unit/test-verification-scanner.php.
*
* Coverage:
* - Empty last_verified → overdue.
* - Days < 90% of threshold → current.
* - Days >= 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' )
);
}
}

View File

@@ -0,0 +1,144 @@
<?php
/**
* Unit tests for PR_Core_Settings verification methods.
*
* Ported from tests/unit/test-verification-settings.php.
*
* Coverage:
* - sanitize_cadence() accepts valid values, rejects invalid → 'weekly'.
* - sanitize_emails() filters invalid addresses, preserves valid ones.
* - reschedule_cron() no-ops when old === new value.
* - reschedule_cron() clears + reschedules when value changes.
*
* @package PeptideRepoCore\Tests
*/
use PHPUnit\Framework\TestCase;
/**
* Tests for PR_Core_Settings verification methods.
*/
class VerificationSettingsTest extends TestCase {
protected function setUp(): void {
$GLOBALS['pr_core_options'] = [];
$GLOBALS['pr_core_cron_calls'] = [];
$GLOBALS['pr_core_test_state'] = [
'existing_post_types' => [],
'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 );
}
}