feat(cpt): consolidate peptide CPT with PSA; drop pr_peptide (v0.2.0)

Resolves the rewrite slug collision that was 404'ing all 89 production
peptide detail pages. PR Core v0.1.1 registered an empty pr_peptide CPT
claiming /peptides/%postname%/; WP's rewrite resolver picked it over PSA's
populated peptide CPT. This release consolidates both registrations onto a
single peptide CPT owned by PR Core. PSA v4.5.0 drops its registration in
favor of this one.

## Contract (per CTO msg 02)
- POST_TYPE: pr_peptide -> peptide
- TAX_CATEGORY: pr_peptide_category -> peptide_category
- TAX_FAMILY const removed (pr_peptide_family was never populated)
- CPT + taxonomy registration guarded with post_type_exists() / taxonomy_exists()
  so deploy order between PR Core and PSA is forgiving
- Args harmonized as superset of PSA + prior PR Core (thumbnail added to
  supports; capability_type + map_meta_cap preserved)
- Activator: flush_rewrite_rules(false) on activation; new
  maybe_flush_on_version_change() handler hooked at init:999 for in-place
  updates that bypass the activation hook (e.g. SCP deploys)
- uninstall.php: blanket DELETE replaced with JOIN on _pr_core_authored so
  PSA-authored peptide posts (the 89 canonical monographs) are never destroyed;
  peptide_category terms preserved (shared ownership)
- Scrub scope: narrow — CPT-slug-derived identifiers only; class names,
  file names, PR_CORE_* constants, pr_core_* options, and pr-core/v1 REST
  namespace remain

## Docs
- ARCHITECTURE.md: new Key Decision #7 (v0.2.0 consolidation) and §2.9
  Uninstall specification
- CONVENTIONS.md: CPT Ownership rule + _pr_core_authored flag contract

## Tests
- Lightweight harness (tests/bootstrap.php) that stubs the handful of WP
  functions exercised by the CPT class
- tests/unit/test-peptide-cpt.php asserts: constants match contract,
  TAX_FAMILY is removed, guard no-ops when post_type_exists/taxonomy_exists
  returns true, args payload shape (thumbnail in supports, rewrite slug,
  REST namespace), hooks wired
- CI: new 'Unit tests (lightweight, no PHPUnit)' step in lint-php job,
  runs across PHP 8.1/8.2/8.3. Full PHPUnit + wp-env harness is a
  follow-up

Refs: convo/prcore/threads/2026-04-peptides-cpt-collision/02-cto-correction.md
This commit is contained in:
peptiderepo
2026-04-22 08:23:00 +00:00
parent c70352eb40
commit 850279e756
15 changed files with 506 additions and 78 deletions

148
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,148 @@
<?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.
*
* 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).
*
* @package PeptideRepoCore
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
if ( ! defined( 'PR_CORE_VERSION' ) ) {
define( 'PR_CORE_VERSION', '0.2.0' );
}
/**
* 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' => [],
];
}
function post_type_exists( string $post_type ): bool {
return in_array( $post_type, $GLOBALS['pr_core_test_state']['existing_post_types'], true );
}
function taxonomy_exists( string $taxonomy ): bool {
return in_array( $taxonomy, $GLOBALS['pr_core_test_state']['existing_taxonomies'], true );
}
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 ];
}
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;
}
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;
}
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;
}
function __( string $text, string $domain = 'default' ): string {
return $text;
}
function current_user_can( string $capability ): bool {
return true;
}
function sanitize_text_field( $value ): string {
return is_scalar( $value ) ? trim( (string) $value ) : '';
}
function wp_json_encode( $data ): string {
return json_encode( $data ) ?: '[]';
}
function absint( $value ): int {
return abs( (int) $value );
}
function floatval( $value ): float {
return (float) $value;
}
/* ── 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";
}
return 1;
}
return 0;
}
/* ── Load the plugin's autoloader + required classes ─────────────────── */
require_once __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,129 @@
<?php
/**
* Unit tests for PR_Core_Peptide_CPT (v0.2.0 CPT consolidation).
*
* Run: php tests/unit/test-peptide-cpt.php
* Exit code 0 = all pass, 1 = any failure.
*
* What's covered:
* - Constants match the v0.2.0 consolidated contract (peptide / peptide_category).
* - TAX_FAMILY constant is removed.
* - register_peptide_post_type() guard: no-op when post_type_exists returns true.
* - register_peptide_post_type() payload: args match the harmonized contract
* (thumbnail in supports, rewrite slug peptides, pr-core/v1 REST namespace).
* - register_taxonomies() guard: no-op when taxonomy_exists returns true.
* - register_taxonomies() only registers peptide_category; pr_peptide_family is gone.
*
* Not covered here (follow-up with proper PHPUnit + wp-env harness):
* - Actual rewrite rule map on a live WP install.
* - Activation flow end-to-end (migrations + capabilities + flush).
* - REST route registration smoke.
*
* @package PeptideRepoCore
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "== PR_Core_Peptide_CPT v0.2.0 unit tests ==\n\n";
/* ── Constants ──────────────────────────────────────────────────────── */
echo "Constants:\n";
pr_assert_equals( 'peptide', PR_Core_Peptide_CPT::POST_TYPE, 'POST_TYPE is "peptide"' );
pr_assert_equals( 'peptide_category', PR_Core_Peptide_CPT::TAX_CATEGORY, 'TAX_CATEGORY is "peptide_category"' );
pr_assert_equals( 'manage_peptide_content', PR_Core_Peptide_CPT::CAPABILITY, 'CAPABILITY unchanged' );
$reflection = new ReflectionClass( PR_Core_Peptide_CPT::class );
pr_assert( ! $reflection->hasConstant( 'TAX_FAMILY' ), 'TAX_FAMILY constant removed' );
/* ── CPT guard: post_type_exists === true => no-op ──────────────────── */
echo "\nCPT registration guard:\n";
pr_core_test_reset();
$GLOBALS['pr_core_test_state']['existing_post_types'] = [ 'peptide' ];
PR_Core_Peptide_CPT::register_peptide_post_type();
pr_assert(
empty( $GLOBALS['pr_core_test_state']['registered_post_types'] ),
'register_peptide_post_type() no-ops when post_type_exists("peptide") is true'
);
/* ── CPT registration payload ───────────────────────────────────────── */
echo "\nCPT registration payload:\n";
pr_core_test_reset();
PR_Core_Peptide_CPT::register_peptide_post_type();
pr_assert(
isset( $GLOBALS['pr_core_test_state']['registered_post_types']['peptide'] ),
'register_post_type was called with slug "peptide"'
);
$args = $GLOBALS['pr_core_test_state']['registered_post_types']['peptide'] ?? [];
pr_assert_equals( true, $args['public'] ?? null, 'public = true' );
pr_assert_equals( true, $args['publicly_queryable'] ?? null, 'publicly_queryable = true' );
pr_assert_equals( true, $args['show_in_rest'] ?? null, 'show_in_rest = true' );
pr_assert_equals( 'peptides', $args['rest_base'] ?? null, 'rest_base = "peptides"' );
pr_assert_equals( 'pr-core/v1', $args['rest_namespace'] ?? null, 'rest_namespace = "pr-core/v1"' );
pr_assert_equals( 'post', $args['capability_type'] ?? null, 'capability_type = "post"' );
pr_assert_equals( true, $args['map_meta_cap'] ?? null, 'map_meta_cap = true' );
pr_assert_equals( false, $args['hierarchical'] ?? null, 'hierarchical = false' );
pr_assert_equals( true, $args['has_archive'] ?? null, 'has_archive = true' );
pr_assert_equals( 'peptides', $args['rewrite']['slug'] ?? null, 'rewrite slug = "peptides"' );
pr_assert_equals( false, $args['rewrite']['with_front'] ?? null, 'rewrite with_front = false' );
$supports = $args['supports'] ?? [];
foreach ( [ 'title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'custom-fields' ] as $feature ) {
pr_assert( in_array( $feature, $supports, true ), "supports includes \"{$feature}\"" );
}
/* ── Taxonomy guard: taxonomy_exists === true => no-op ──────────────── */
echo "\nTaxonomy registration guard:\n";
pr_core_test_reset();
$GLOBALS['pr_core_test_state']['existing_taxonomies'] = [ 'peptide_category' ];
PR_Core_Peptide_CPT::register_taxonomies();
pr_assert(
empty( $GLOBALS['pr_core_test_state']['registered_taxonomies'] ),
'register_taxonomies() no-ops when taxonomy_exists("peptide_category") is true'
);
/* ── Taxonomy registration payload ──────────────────────────────────── */
echo "\nTaxonomy registration payload:\n";
pr_core_test_reset();
PR_Core_Peptide_CPT::register_taxonomies();
pr_assert(
isset( $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category'] ),
'peptide_category taxonomy registered'
);
pr_assert(
! isset( $GLOBALS['pr_core_test_state']['registered_taxonomies']['pr_peptide_family'] ),
'pr_peptide_family taxonomy NOT registered (removed in v0.2.0)'
);
pr_assert(
! isset( $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_family'] ),
'peptide_family taxonomy NOT registered either (the rename-only path would have left this lingering)'
);
$tax = $GLOBALS['pr_core_test_state']['registered_taxonomies']['peptide_category'] ?? [];
pr_assert_equals( 'peptide', $tax['object_type'] ?? null, 'peptide_category attached to peptide CPT' );
pr_assert_equals( true, $tax['args']['hierarchical'] ?? null, 'peptide_category hierarchical = true' );
pr_assert_equals( true, $tax['args']['show_in_rest'] ?? null, 'peptide_category show_in_rest = true' );
pr_assert_equals( 'peptide-category', $tax['args']['rewrite']['slug'] ?? null, 'peptide_category rewrite slug = "peptide-category"' );
/* ── Hook registration ──────────────────────────────────────────────── */
echo "\nHook registration:\n";
pr_core_test_reset();
$cpt = new PR_Core_Peptide_CPT();
$cpt->register_hooks();
$hooks = array_column( $GLOBALS['pr_core_test_state']['added_actions'], 'hook' );
pr_assert( in_array( 'init', $hooks, true ), 'register_hooks() adds action on "init"' );
/* ── Summary ────────────────────────────────────────────────────────── */
exit( pr_test_summary() );