Summary
In handle_rest_associate() (includes/taxonomy.php), the published-post branch associates the connected post using API\get_term_id( $post_id ) as the term:
wp_set_object_terms( $associated_post_id, API\get_term_id( $post_id ), API\get_taxonomy_slug( $post_id ), true );
// ...
return rest_ensure_response( [ 'success' => true, 'message' => '', 'posts' => $associated_posts->posts ] );
API\get_term_id() returns 0 when it cannot resolve a shadow term for the post. WordPress core's wp_set_object_terms() explicitly skips a non-existent integer term ID (wp-includes/taxonomy.php: "Skip if a non-existent term ID is passed."). So when get_term_id() returns 0, the call is a no-op — but the handler still returns success: true (with an empty posts array). The caller is told the association succeeded when nothing happened.
Note: this is distinct from the $append data-loss bug fixed in #67. Before that fix, this same condition was worse — wp_set_object_terms() with $append = false and an empty resolved term set would delete all of the connected post's existing shadow terms. #67 stops the data loss; this issue is the remaining silent false-success.
Conditions that trigger it
get_term_id() returns 0 for a published shadow post in these cases:
- Missing term. The shadow term was deleted directly (e.g. an editor with the
override_shadow_terms capability removed it from the term admin UI) and the post has not been re-saved, so sync.php's recovery branch hasn't recreated it. The post is still publish, but no term exists.
- Term name no longer matches the post title.
get_term_id() looks the term up with get_term_by( 'name', $post->post_title, $taxonomy ). The slug stays stable, but if a privileged user renames the term (its name) so it diverges from the current post title, the name lookup fails and returns 0 even though a term still exists.
- Title/name normalization mismatch. Edge cases where the stored term
name and the post title differ after sanitization/encoding so the exact-name lookup misses.
In all three, the REST associate request returns HTTP 200 / success: true while creating no term relationship.
Suggested fix (for discussion)
Guard the published branch on a resolved term before writing, e.g.:
- If
get_term_id( $post_id ) === 0, either return success: false with an explanatory message, or recreate the missing term (mirroring sync.php's recovery branch) before associating.
Decision needed: should the endpoint fail loudly, or self-heal by recreating the term?
How to test
PHPUnit regression test (mirrors the harness already added in tests/test-multi-association.php):
public function test_rest_associate_reports_failure_when_published_term_missing(): void {
$editor_id = $this->factory()->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $editor_id );
$acme_id = $this->create_post( 'example', 'Acme' ); // publish -> term created
$article_id = $this->create_post( 'post', 'Article' );
// Simulate the missing-term condition: delete the term but leave the post published
// and un-saved (so sync's recovery branch never runs).
$acme_term = get_term_by( 'slug', 'acme', 'example_connect' );
wp_delete_term( $acme_term->term_id, 'example_connect' );
$request = new WP_REST_Request( 'POST', '/shadow-terms/v1/associate' );
$request->set_param( 'postId', $acme_id );
$request->set_param( 'associatedPostId', $article_id );
$response = $this->rest_server->dispatch( $request );
// Current (buggy) behavior: success === true, but the article has no terms.
$data = $response->get_data();
$terms = wp_get_object_terms( $article_id, 'example_connect' );
// Desired behavior: the response should NOT claim success while doing nothing.
$this->assertFalse(
$data['success'] && empty( $terms ),
'associate must not report success when no term was actually attached.'
);
}
Manual repro:
- Create and publish an
example post "Acme" (a shadow term acme is created).
- In the shadow taxonomy admin UI (as a user with
override_shadow_terms), delete the acme term. Do not re-save the Acme post.
POST to /wp-json/shadow-terms/v1/associate with postId = Acme's ID and associatedPostId = any post.
- Observe: response is
200 { "success": true, "posts": [] }, but the target post gains no example_connect term — the association silently did not happen.
Summary
In
handle_rest_associate()(includes/taxonomy.php), the published-post branch associates the connected post usingAPI\get_term_id( $post_id )as the term:API\get_term_id()returns 0 when it cannot resolve a shadow term for the post. WordPress core'swp_set_object_terms()explicitly skips a non-existent integer term ID (wp-includes/taxonomy.php: "Skip if a non-existent term ID is passed."). So whenget_term_id()returns 0, the call is a no-op — but the handler still returnssuccess: true(with an emptypostsarray). The caller is told the association succeeded when nothing happened.Conditions that trigger it
get_term_id()returns 0 for a published shadow post in these cases:override_shadow_termscapability removed it from the term admin UI) and the post has not been re-saved, sosync.php's recovery branch hasn't recreated it. The post is stillpublish, but no term exists.get_term_id()looks the term up withget_term_by( 'name', $post->post_title, $taxonomy ). The slug stays stable, but if a privileged user renames the term (itsname) so it diverges from the current post title, the name lookup fails and returns 0 even though a term still exists.nameand the post title differ after sanitization/encoding so the exact-name lookup misses.In all three, the REST
associaterequest returns HTTP 200 /success: truewhile creating no term relationship.Suggested fix (for discussion)
Guard the published branch on a resolved term before writing, e.g.:
get_term_id( $post_id ) === 0, either returnsuccess: falsewith an explanatory message, or recreate the missing term (mirroringsync.php's recovery branch) before associating.Decision needed: should the endpoint fail loudly, or self-heal by recreating the term?
How to test
PHPUnit regression test (mirrors the harness already added in
tests/test-multi-association.php):Manual repro:
examplepost "Acme" (a shadow termacmeis created).override_shadow_terms), delete theacmeterm. Do not re-save the Acme post.POSTto/wp-json/shadow-terms/v1/associatewithpostId= Acme's ID andassociatedPostId= any post.200 { "success": true, "posts": [] }, but the target post gains noexample_connectterm — the association silently did not happen.