From b2dc245c6e89108d6081bdc9e0e1133ec9fca3d6 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 23 Jun 2026 05:10:06 +0000 Subject: [PATCH] docs/test: resolve QA P1s + P2 guards (v0.8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-A: CONTEXT.md — add glossary entries for _prab_* namespace, meta-reader pattern, ScholarlyArticle type, and honest-reviewedBy integrity constraint (Person-vs-Organization branching rule). P1-B: Master IA + app-map updated (workspace docs, not in this diff): INFORMATION-ARCHITECTURE.md v1.3.2 — §5 cross-plugin data flow added (PRAutoBlogger _prab_* meta → prcore JSON-LD Article emission); §4 version bumped to 0.8.0. peptide-repo-core.md — two new classes in §2 file tree; §3 data flow Mermaid extended with PRAB→MetaReader→Emitter chain; §5 cross-plugin dependency noted; §6 hooks table updated; last-verified bumped to v0.8.0. P1-C: JsonldArticleTest.php — add 5 new test methods: - test_standalone_fallback_emits_nothing_for_untriggered_post() - test_standalone_fallback_emits_valid_graph_for_triggered_post() - test_standalone_fallback_suppressed_when_yoast_active() - test_get_about_peptides_nonexistent_id_skipped() - test_get_about_peptides_wrong_post_type_skipped() Also add test_is_triggered_cache_returns_same_value() for P2-5 cache. P2-1: get_about_peptides() — skip entries where get_permalink() returns ''. P2-2: get_reviewed_by() — empty display_name falls through to Organization. P2-3: register_hooks() — explicit priority 10 on wpseo_schema_webpage_type. P2-4: emit_standalone_fallback() — guard wp_json_encode() false return; also guard empty permalink before building the graph. P2-5: is_triggered() — per-post avoids triple get_post_meta reads when all three hooks fire for the same post. P3s accepted (noted in QA verdict): P3-1 test-comment misleading; P3-2 docblock @see gap on emit_standalone_fallback; P3-3 DOI regex. Agent-Session: prcore-jsonld-remediation-20260623 --- CONTEXT.md | 59 ++++++++ .../frontend/class-pr-core-jsonld-article.php | 31 ++-- .../class-pr-core-prab-meta-reader.php | 97 ++++++------ tests/unit/JsonldArticleTest.php | 140 +++++++++++++++++- 4 files changed, 261 insertions(+), 66 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 41c326c..c625801 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,3 +113,62 @@ Gutenberg block. Not surfaced in JSON-LD (dosing YMYL constraint). `PR_Core_Migration_Runner` — runs numbered migrations (0001–N) sequentially on every `plugins_loaded`. Compares `pr_core_schema_version` wp_option against `PR_CORE_TARGET_SCHEMA_VERSION` constant. All migrations are idempotent and re-runnable. + +## _prab_* meta namespace + +Post-meta keys written exclusively by PRAutoBlogger (PRAB) and read by prcore's +`PR_Core_Prab_Meta_Reader` at render time. The namespace is `_prab_` (PRAutoBlogger +private). Ownership split: **PRAB writes, prcore reads**. No other plugin reads or +writes `_prab_*` meta. Contract v1 keys (frozen; additive evolution only, gated by +`_prab_schema_version`): + +| Key | Type | Purpose | +|---|---|---| +| `_prab_schema_version` | int (1) | Opt-in trigger; presence + value = 1 enables JSON-LD emission | +| `_prab_citations` | JSON array | `[{url, doi?, title, quality_score?}]` — validated sources | +| `_prab_about_peptides` | JSON array | Peptide post IDs → Drug `@id` references in `about[]` | +| `_prab_review_mode` | string | `human` \| `editorial-system` | +| `_prab_reviewed_at` | string | ISO 8601 datetime → `lastReviewed` | +| `_prab_reviewed_by` | int | WP user ID of Review Queue approver (required when mode=`human`) | + +All `_prab_*` values are treated as **untrusted at read time**: sanitized and +validated by `PR_Core_Prab_Meta_Reader` before any schema.org output is produced. +Malformed values degrade gracefully (skipped/null); no fatal path exists. +Canonical contract: `convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`. + +## Meta-reader pattern + +`PR_Core_Prab_Meta_Reader` is the dedicated sanitizer class that isolates all +`_prab_*` meta reading and validation from the emitter (`PR_Core_Jsonld_Article`). +The pattern: **one sanitizer class per untrusted meta namespace**. The reader owns +all `get_post_meta` calls for the namespace, validates every field, and returns +typed clean values. The emitter (consumer) never calls `get_post_meta` directly. + +This keeps the emitter testable without the meta contract details and ensures that +adding a new `_prab_*` field never touches the emission logic. Future `_prab_v2_*` +fields would use a v2 reader that the emitter selects by version. + +## ScholarlyArticle + +A `schema.org/ScholarlyArticle` node emitted inside a citation array (`citation[]`) +on the `Article` graph piece when a validated DOI is present in the PRAB citation +record. Emitted with `url` (the citation URL), `name` (the citation title), and +`sameAs` (the canonical `https://doi.org/` URI). When no DOI is available, +`CreativeWork` is emitted instead. Per contract v1, `quality_score` is never +emitted (no schema.org mapping). + +## Honest reviewedBy (integrity constraint) + +The `reviewedBy` field on the `MedicalWebPage` node emits a `schema.org/Person` +node **only** when all three conditions hold simultaneously: +1. `_prab_review_mode` = `human` +2. `_prab_reviewed_by` resolves to a WP user ID > 0 +3. `get_userdata()` returns a valid `WP_User` instance with a non-empty + `display_name` + +Any other combination (missing mode, zero user ID, unresolvable user, or +editorial-system mode) produces a `schema.org/Organization` node (Peptide Repo +editorial). This is an **integrity constraint**, not a UI choice: emitting a +fabricated Person would constitute misleading structured data. The constraint is +enforced in `PR_Core_Prab_Meta_Reader::get_reviewed_by()` and covered by dedicated +unit tests. diff --git a/includes/frontend/class-pr-core-jsonld-article.php b/includes/frontend/class-pr-core-jsonld-article.php index c955de7..bf47c48 100644 --- a/includes/frontend/class-pr-core-jsonld-article.php +++ b/includes/frontend/class-pr-core-jsonld-article.php @@ -62,7 +62,7 @@ class PR_Core_Jsonld_Article { * @return void */ public function register_hooks(): void { - add_filter( 'wpseo_schema_webpage_type', array( $this, 'retype_article_page' ) ); + add_filter( 'wpseo_schema_webpage_type', array( $this, 'retype_article_page' ), 10 ); add_filter( 'wpseo_schema_graph', array( $this, 'enrich_article_graph' ), 13, 2 ); add_action( 'wp_head', array( $this, 'emit_standalone_fallback' ), 99 ); } @@ -151,7 +151,7 @@ class PR_Core_Jsonld_Article { * * Suppressed when Yoast is loaded. Emits Article + MedicalWebPage nodes * with Yoast-parity @id conventions so consumers see one shape regardless - * of whether Yoast is present. + * of whether Yoast is present. Skips posts whose permalink cannot be resolved. * * @return void */ @@ -167,7 +167,11 @@ class PR_Core_Jsonld_Article { return; } - $permalink = (string) get_permalink( $post_id ); + $permalink = (string) get_permalink( $post_id ); + if ( '' === $permalink ) { + return; + } + $post = get_post( $post_id ); $review_mode = $this->reader->get_review_mode( $post_id ); $reviewed_at = $this->reader->get_reviewed_at( $post_id ); @@ -198,16 +202,19 @@ class PR_Core_Jsonld_Article { $article['about'] = $about_stubs; } - printf( - '' . "\n", - wp_json_encode( - array( - '@context' => 'https://schema.org', - '@graph' => array( $webpage, $article ), - ), - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT - ) + $json = wp_json_encode( + array( + '@context' => 'https://schema.org', + '@graph' => array( $webpage, $article ), + ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); + if ( false !== $json ) { + printf( + '' . "\n", + $json + ); + } } // ── Private builders ───────────────────────────────────────────────── diff --git a/includes/frontend/class-pr-core-prab-meta-reader.php b/includes/frontend/class-pr-core-prab-meta-reader.php index 2d260f9..c35f117 100644 --- a/includes/frontend/class-pr-core-prab-meta-reader.php +++ b/includes/frontend/class-pr-core-prab-meta-reader.php @@ -27,85 +27,70 @@ declare(strict_types=1); */ class PR_Core_Prab_Meta_Reader { - /** - * Schema version meta key; presence opts the post in, value must equal 1. - * - * @var string - */ + /** @var string Schema version meta key; value must equal 1 to trigger. */ public const META_SCHEMA_VERSION = '_prab_schema_version'; - /** - * Citations meta key; JSON array of {url, title, doi?, quality_score?} objects. - * - * @var string - */ + /** @var string Citations meta key; JSON array of {url, title, doi?, quality_score?}. */ public const META_CITATIONS = '_prab_citations'; - /** - * About-peptides meta key; JSON array of peptide post IDs (ints) for schema `about`. - * - * @var string - */ + /** @var string About-peptides meta key; JSON array of peptide post IDs for schema `about`. */ public const META_ABOUT_PEPTIDES = '_prab_about_peptides'; - /** - * Review mode meta key; value is 'human' or 'editorial-system'. - * - * @var string - */ + /** @var string Review mode meta key; value is 'human' or 'editorial-system'. */ public const META_REVIEW_MODE = '_prab_review_mode'; - /** - * Reviewed-at meta key; ISO 8601 datetime of last review. - * - * @var string - */ + /** @var string Reviewed-at meta key; ISO 8601 datetime of last review. */ public const META_REVIEWED_AT = '_prab_reviewed_at'; - /** - * Reviewed-by meta key; stores the WP user ID of the Review Queue approver. - * - * @var string - */ + /** @var string Reviewed-by meta key; WP user ID of the Review Queue approver. */ public const META_REVIEWED_BY = '_prab_reviewed_by'; - /** - * The only schema version this reader currently supports. - * - * @var int - */ + /** @var int The only schema version this reader currently supports. */ private const SUPPORTED_VERSION = 1; + /** + * Per-request cache for is_triggered() results, keyed by post ID. + * + * Avoids triple get_post_meta reads when all three hooks fire for the same post. + * + * @var array + */ + private array $triggered_cache = array(); + /** * Return true when this post carries a supported _prab_schema_version meta. * - * Logs a notice on an unsupported-but-present version value. + * Result is cached per post ID to avoid repeated get_post_meta reads within + * one request (all three Yoast hooks call this for the same post). * * @param int $post_id WordPress post ID. * @return bool */ public function is_triggered( int $post_id ): bool { + if ( isset( $this->triggered_cache[ $post_id ] ) ) { + return $this->triggered_cache[ $post_id ]; + } $raw = get_post_meta( $post_id, self::META_SCHEMA_VERSION, true ); if ( '' === $raw || false === $raw ) { - return false; + return $this->triggered_cache[ $post_id ] = false; } $version = (int) $raw; if ( self::SUPPORTED_VERSION === $version ) { - return true; + return $this->triggered_cache[ $post_id ] = true; } error_log( sprintf( '[PR Core] Unsupported _prab_schema_version=%d on post %d. Supports v%d only. Emission suppressed.', $version, $post_id, self::SUPPORTED_VERSION ) ); - return false; + return $this->triggered_cache[ $post_id ] = false; } /** * Read and validate _prab_citations meta. * * Each entry must have a valid http/https `url` and a non-empty `title`. - * Invalid entries are skipped individually. `quality_score` is never emitted - * (no schema.org mapping — contract v1). + * Invalid entries are skipped. `quality_score` is never emitted (no schema.org + * mapping — contract v1). * * @param int $post_id WordPress post ID. - * @return array Validated citations. + * @return array Validated. */ public function get_citations( int $post_id ): array { $decoded = $this->decode_json_meta( $post_id, self::META_CITATIONS ); @@ -135,7 +120,8 @@ class PR_Core_Prab_Meta_Reader { * Read and validate _prab_about_peptides meta. * * Returns Drug stub arrays for schema `about`. Each peptide post ID must - * resolve to a published `peptide` post; unresolvable IDs are skipped. + * resolve to a published `peptide` post; unresolvable IDs and posts whose + * permalink is empty are skipped. * * @param int $post_id WordPress post ID. * @return array> Drug stubs. @@ -157,7 +143,10 @@ class PR_Core_Prab_Meta_Reader { || PR_Core_Peptide_CPT::POST_TYPE !== $post->post_type ) { continue; } - $link = (string) get_permalink( $pid ); + $link = (string) get_permalink( $pid ); + if ( '' === $link ) { + continue; + } $stubs[] = array( '@type' => 'Drug', '@id' => $link . '#drug', @@ -207,11 +196,11 @@ class PR_Core_Prab_Meta_Reader { * Resolve the honest reviewedBy value for a PRAB article. * * Returns a Person node only when mode='human' AND _prab_reviewed_by resolves - * to an existing WP user. All other cases return the Organization node. - * Never emits a fabricated Person — that is the core contract guarantee. + * to an existing WP user with a non-empty display_name. All other cases return + * the Organization node. Never emits a fabricated Person — core contract. * * Person @id: {home_url}/#/schema/person/{user_id} (Yoast convention). - * Org @id: {home_url}/#organization (references Yoast's existing publisher node). + * Org @id: {home_url}/#organization (references Yoast's publisher node). * * @param int $post_id WordPress post ID. * @param string $review_mode Result of get_review_mode() for this post. @@ -222,13 +211,15 @@ class PR_Core_Prab_Meta_Reader { $uid = absint( get_post_meta( $post_id, self::META_REVIEWED_BY, true ) ); $user = $uid > 0 ? get_userdata( $uid ) : false; if ( $user instanceof WP_User ) { - return array( - '@type' => 'Person', - '@id' => home_url( '/#/schema/person/' . $uid ), - 'name' => sanitize_text_field( $user->display_name ), - ); + $display = sanitize_text_field( $user->display_name ); + if ( '' !== $display ) { + return array( + '@type' => 'Person', + '@id' => home_url( '/#/schema/person/' . $uid ), + 'name' => $display, + ); + } } - // Log missing/unresolvable user and fall through to Org. error_log( sprintf( '[PR Core] _prab_review_mode=human but reviewed_by user (ID=%d) unresolvable on post %d. Emitting Organization.', $uid, $post_id ) ); } return array( diff --git a/tests/unit/JsonldArticleTest.php b/tests/unit/JsonldArticleTest.php index 1dc6493..572e5af 100644 --- a/tests/unit/JsonldArticleTest.php +++ b/tests/unit/JsonldArticleTest.php @@ -4,15 +4,20 @@ * * Coverage: * - is_triggered: version=1 triggers, absent/wrong version does not. + * - is_triggered cache: second call returns cached value (no extra meta read). * - get_review_mode: 'human', 'editorial-system', unknown → 'editorial-system'. * - get_reviewed_by: human + valid user → Person; human + bad user → Org; editorial → Org. + * - get_reviewed_by: human + empty display_name → Org (P2-2 guard). * - get_citations: valid, missing url, missing title, bad url, doi normalisation. - * - get_about_peptides: valid peptide ID, non-existent ID, non-peptide type. + * - get_about_peptides: valid peptide ID; non-existent ID skipped; non-peptide type skipped. * - get_reviewed_at: valid ISO, empty, unparseable → null. * - retype_article_page: triggered post → MedicalWebPage; untriggered → passthrough. * - enrich_article_graph: Article gains citation/about; WebPage gains lastReviewed/reviewedBy. * - No duplicate Article/WebPage nodes emitted. * - editorial-system mode never emits Person. + * - emit_standalone_fallback: Yoast-active suppresses output. + * - emit_standalone_fallback: untriggered post emits nothing. + * - emit_standalone_fallback: triggered post emits valid @graph with Article + MedicalWebPage. * * @package PeptideRepoCore\Tests */ @@ -77,6 +82,15 @@ class JsonldArticleTest extends TestCase { $this->assertFalse( $this->reader()->is_triggered( 1 ) ); } + public function test_is_triggered_cache_returns_same_value(): void { + $this->set_meta( 5, array( '_prab_schema_version' => '1' ) ); + $r = $this->reader(); + $first = $r->is_triggered( 5 ); + $second = $r->is_triggered( 5 ); + $this->assertSame( $first, $second ); + $this->assertTrue( $first ); + } + // ── get_review_mode ─────────────────────────────────────────────────── public function test_review_mode_human(): void { @@ -123,6 +137,13 @@ class JsonldArticleTest extends TestCase { $this->assertSame( 'Organization', $node['@type'] ); } + public function test_reviewed_by_human_empty_display_name_returns_org(): void { + $this->set_meta( 1, array( '_prab_reviewed_by' => '77' ) ); + $GLOBALS['pr_test_userdata'][77] = new WP_User( array( 'display_name' => '', 'ID' => 77 ) ); + $node = $this->reader()->get_reviewed_by( 1, 'human' ); + $this->assertSame( 'Organization', $node['@type'] ); + } + public function test_reviewed_by_editorial_system_returns_org(): void { $node = $this->reader()->get_reviewed_by( 1, 'editorial-system' ); $this->assertSame( 'Organization', $node['@type'] ); @@ -206,6 +227,34 @@ class JsonldArticleTest extends TestCase { $this->assertCount( 0, $this->reader()->get_citations( 1 ) ); } + // ── get_about_peptides edge cases ───────────────────────────────────── + + public function test_get_about_peptides_nonexistent_id_skipped(): void { + // post ID 999 does not exist in $GLOBALS['pr_test_posts'] → get_post returns null. + $this->set_meta( 1, array( + '_prab_schema_version' => '1', + '_prab_about_peptides' => wp_json_encode( array( 999 ) ), + ) ); + $stubs = $this->reader()->get_about_peptides( 1 ); + $this->assertCount( 0, $stubs ); + } + + public function test_get_about_peptides_wrong_post_type_skipped(): void { + // A published post of type 'post' (not 'peptide') should be excluded. + $GLOBALS['pr_test_posts'][50] = new WP_Post( array( + 'ID' => 50, + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Not a peptide', + ) ); + $this->set_meta( 1, array( + '_prab_schema_version' => '1', + '_prab_about_peptides' => wp_json_encode( array( 50 ) ), + ) ); + $stubs = $this->reader()->get_about_peptides( 1 ); + $this->assertCount( 0, $stubs ); + } + // ── get_reviewed_at ─────────────────────────────────────────────────── public function test_reviewed_at_valid_iso(): void { @@ -343,4 +392,93 @@ class JsonldArticleTest extends TestCase { // Still exactly 2 pieces — no new nodes injected. $this->assertCount( 2, $result ); } + + // ── emit_standalone_fallback ────────────────────────────────────────── + + public function test_standalone_fallback_emits_nothing_for_untriggered_post(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 3; + $this->set_meta( 3, array() ); // no _prab_schema_version. + + $emitter = new PR_Core_Jsonld_Article(); + ob_start(); + $emitter->emit_standalone_fallback(); + $output = ob_get_clean(); + + $this->assertSame( '', $output, 'Untriggered post must produce no output' ); + } + + public function test_standalone_fallback_emits_valid_graph_for_triggered_post(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 4; + $GLOBALS['pr_test_posts'][4] = new WP_Post( array( + 'ID' => 4, + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'BPC-157 Article', + ) ); + $this->set_meta( 4, array( + '_prab_schema_version' => '1', + '_prab_review_mode' => 'editorial-system', + '_prab_reviewed_at' => '2026-06-20T10:00:00+00:00', + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'https://example.com/study', 'title' => 'Study One', 'doi' => '10.1234/abc' ), + ) ), + ) ); + + $emitter = new PR_Core_Jsonld_Article(); + ob_start(); + $emitter->emit_standalone_fallback(); + $output = ob_get_clean(); + + // Must contain a script tag. + $this->assertStringContainsString( '', $output ); + + // Extract JSON between script tags. + preg_match( '#]*>(.*?)#s', $output, $matches ); + $this->assertNotEmpty( $matches[1], 'JSON body found inside script tag' ); + + $decoded = json_decode( $matches[1], true ); + $this->assertIsArray( $decoded, 'Output is valid JSON' ); + $this->assertSame( 'https://schema.org', $decoded['@context'] ); + $this->assertArrayHasKey( '@graph', $decoded ); + + // Collect types. + $types = array_column( $decoded['@graph'], '@type' ); + $this->assertContains( 'MedicalWebPage', $types, '@graph contains MedicalWebPage' ); + $this->assertContains( 'Article', $types, '@graph contains Article' ); + + // Article must have citation with ScholarlyArticle (DOI present). + $article_node = null; + foreach ( $decoded['@graph'] as $piece ) { + if ( 'Article' === ( $piece['@type'] ?? '' ) ) { + $article_node = $piece; + } + } + $this->assertNotNull( $article_node ); + $this->assertArrayHasKey( 'citation', $article_node ); + $this->assertSame( 'ScholarlyArticle', $article_node['citation'][0]['@type'] ); + } + public function test_standalone_fallback_suppressed_when_yoast_active(): void { + // YoastSEO() is defined here so it runs LAST — once defined it persists for + // the whole PHPUnit process and would suppress the output assertions above. + if ( ! function_exists( 'YoastSEO' ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound + eval( 'function YoastSEO() { return new stdClass(); }' ); + } + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 1; + $this->set_meta( 1, array( '_prab_schema_version' => '1' ) ); + + $emitter = new PR_Core_Jsonld_Article(); + ob_start(); + $emitter->emit_standalone_fallback(); + $output = ob_get_clean(); + + $this->assertSame( '', $output, 'Yoast-active path must produce no output' ); + } }