Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 54 additions & 20 deletions wordpress/themes/cdcf-headless/includes/admin/term-propagation.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,21 @@ function cdcf_propagate_project_tags_on_publish($new_status, $old_status, $post)
* ID.
*
* @return int|null The target-language term ID, or null on failure
* (OpenAI error, wp_insert_term error other than
* term_exists, Polylang missing).
* (OpenAI error, wp_insert_term error other than a
* term_exists already-my-sibling case, Polylang missing,
* or a slug collision against a term that belongs to a
* different EN sibling — see below).
*/
function cdcf_get_or_create_translated_term($en_term, string $target_lang): ?int {
$existing = pll_get_term((int) $en_term->term_id, $target_lang);
if ($existing) {
// pll_get_term can return the input term_id itself as a fallback in
// some Polylang versions when no target-language sibling exists.
// Without this guard the EN term gets assigned to a non-EN post
// and pll_set_term_language below silently flips its language,
// corrupting the source — observed on production 2026-06-08 with
// ConfessIt: EN term 171 ("examen") ended up language=fr after
// propagation runs against ES/FR posts.
if ($existing && (int) $existing !== (int) $en_term->term_id) {
return (int) $existing;
}

Expand All @@ -121,33 +130,58 @@ function cdcf_get_or_create_translated_term($en_term, string $target_lang): ?int
'slug' => sanitize_title($translated_name . '-' . $target_lang),
]);

$new_term_id = null;
if (is_wp_error($result)) {
// Slug collision: WP returns term_exists with the existing
// term's ID in error_data. Adopt and Polylang-link it anyway.
if ($result->get_error_code() === 'term_exists') {
// A target-language term with this slug already exists.
// Typical cause: two distinct EN terms whose OpenAI
// translations land on the same target word (e.g. "examen"
// + "examination" both translate to "examen" in Romance
// languages). Resolution policy:
// - If the colliding term IS already a Polylang sibling
// of our EN term, return its id (idempotent — safe to
// reuse on a re-run).
// - Otherwise, DO NOT adopt + rewrite the colliding term's
// Polylang group. That would reassign it from its
// original EN sibling to ours, orphaning the original
// link AND (because pll_set_term_language re-asserts
// language) sometimes flipping a term's language to
// match the adopter — the bug that corrupted ConfessIt.
// Skip with a logged warning and let the caller render
// fewer tags than the source had.
$existing_id = (int) $result->get_error_data();
if ($existing_id) {
$new_term_id = $existing_id;
$en_translations = pll_get_term_translations((int) $en_term->term_id);
$is_already_my_sibling = is_array($en_translations)
&& isset($en_translations[$target_lang])
&& (int) $en_translations[$target_lang] === $existing_id;
if ($is_already_my_sibling) {
return $existing_id;
}
error_log(sprintf(
'cdcf_get_or_create_translated_term: slug collision on "%s" (%s); existing term %d is linked to a different EN sibling — skipping to avoid corrupting Polylang group. EN term %d will not be propagated to %s.',
$translated_name,
$target_lang,
$existing_id,
(int) $en_term->term_id,
$target_lang
));
return null;
}
}
if (!$new_term_id) {
error_log(sprintf(
'cdcf_get_or_create_translated_term: wp_insert_term failed for "%s" (%s): %s',
$translated_name,
$target_lang,
$result->get_error_message()
));
return null;
}
} else {
$new_term_id = (int) $result['term_id'];
error_log(sprintf(
'cdcf_get_or_create_translated_term: wp_insert_term failed for "%s" (%s): %s',
$translated_name,
$target_lang,
$result->get_error_message()
));
return null;
}

$new_term_id = (int) $result['term_id'];
pll_set_term_language($new_term_id, $target_lang);

// Merge into the EN term's existing Polylang group so all 6
// language siblings know about each other.
// Merge into the EN term's existing Polylang group so all language
// siblings know about each other.
$translations = pll_get_term_translations((int) $en_term->term_id);
if (!is_array($translations)) {
$translations = [];
Expand Down
98 changes: 78 additions & 20 deletions wordpress/themes/cdcf-headless/tests/TermPropagationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -405,40 +405,98 @@ public function test_get_or_create_returns_null_when_openai_translation_fails():
$this->assertNull(cdcf_get_or_create_translated_term($this->fakeTerm(169, 'confession'), 'it'));
}

public function test_get_or_create_adopts_existing_term_on_slug_collision(): void
public function test_get_or_create_treats_pll_get_term_self_fallback_as_no_sibling(): void
{
// wp_insert_term returns a term_exists WP_Error with the colliding
// term's ID in data. The handler should adopt that ID and still
// Polylang-link it.
// Regression guard for the production-2026-06-08 corruption of
// ConfessIt EN term 171 ("examen"): some Polylang versions return
// the input term_id itself from pll_get_term when no
// target-language sibling exists. Without a self-equality guard,
// the handler treats this fallback as "found a sibling", assigns
// the EN term to a non-EN post, and pll_set_term_language flips
// the EN term's language to match the non-EN target. Here we
// stub pll_get_term to return the input id and assert the
// handler falls through to OpenAI + wp_insert_term instead of
// returning the EN id.
$this->stubCommonFunctions();
Functions\when('pll_get_term')->justReturn(0);
Functions\when('cdcf_openai_translate')->justReturn(['term' => 'confessione']);
Functions\when('wp_insert_term')->justReturn(
new WP_Error('term_exists', 'A term with this slug exists', 880)
Functions\when('pll_get_term')->alias(
static fn(int $term_id, string $lang): int => $term_id // self-fallback
);
Functions\when('cdcf_openai_translate')->justReturn(['term' => 'confessione']);
Functions\when('wp_insert_term')->justReturn(['term_id' => 802]);

$polyCall = new stdClass();
$polyCall->set_lang = null;
$polyCall->save_translations = null;
$captured = new stdClass();
$captured->set_lang = null;
Functions\when('pll_set_term_language')->alias(
function (int $term_id, string $lang) use ($polyCall): bool {
$polyCall->set_lang = [$term_id, $lang];
function (int $term_id, string $lang) use ($captured): bool {
$captured->set_lang = [$term_id, $lang];
return true;
}
);
Functions\when('pll_save_term_translations')->alias(
function (array $translations) use ($polyCall): bool {
$polyCall->save_translations = $translations;
return true;
}
$this->allowAllFunctionsToExist();

$result = cdcf_get_or_create_translated_term($this->fakeTerm(169, 'confession'), 'it');

$this->assertSame(802, $result, 'must create a new IT term, not reuse the EN id 169');
// pll_set_term_language must operate on the NEW term (802), not on
// the EN term (169) — that was the bug that flipped EN 171 to fr.
$this->assertSame([802, 'it'], $captured->set_lang);
}

public function test_get_or_create_reuses_collision_term_when_already_my_polylang_sibling(): void
{
// Idempotent path on slug collision: the colliding term IS
// already a sibling of our EN term in this language (e.g. the
// hook fired twice for the same post). Return its id; do NOT
// re-run pll_set_term_language or pll_save_term_translations.
$this->stubCommonFunctions();
Functions\when('pll_get_term')->justReturn(0); // no sibling via direct lookup
Functions\when('cdcf_openai_translate')->justReturn(['term' => 'confessione']);
Functions\when('wp_insert_term')->justReturn(
new WP_Error('term_exists', 'A term with this slug exists', 880)
);
// The collision-resolution path queries the EN term's polylang
// group; here term 880 IS already its IT sibling.
Functions\when('pll_get_term_translations')->justReturn([
'en' => 169, 'it' => 880,
]);
Functions\expect('pll_set_term_language')->never();
Functions\expect('pll_save_term_translations')->never();
$this->allowAllFunctionsToExist();

$result = cdcf_get_or_create_translated_term($this->fakeTerm(169, 'confession'), 'it');

$this->assertSame(880, $result);
$this->assertSame([880, 'it'], $polyCall->set_lang);
$this->assertSame(['en' => 169, 'it' => 880], $polyCall->save_translations);
}

public function test_get_or_create_returns_null_when_collision_term_belongs_to_different_sibling(): void
{
// The corruption case: two distinct EN terms whose OpenAI
// translations land on the same target word (e.g. EN "examen"
// + EN "examination" both translate to "examen" in Romance
// languages). The colliding term is already linked to a
// DIFFERENT EN sibling; adopting + rewriting its polylang group
// would orphan that other link AND can flip term languages.
// Skip with a logged warning, return null.
$this->stubCommonFunctions();
Functions\when('pll_get_term')->justReturn(0);
Functions\when('cdcf_openai_translate')->justReturn(['term' => 'examen']);
Functions\when('wp_insert_term')->justReturn(
new WP_Error('term_exists', 'A term with this slug exists', 880)
);
// EN term 173 ("examination")'s polylang group does NOT have
// term 880 as its IT sibling — 880 belongs to a different EN
// sibling (e.g. EN 171 "examen", which translated to the same
// word in Italian).
Functions\when('pll_get_term_translations')->justReturn([
'en' => 173, // (we're asking about term 173, but 880 is not in this map)
]);
Functions\expect('pll_set_term_language')->never();
Functions\expect('pll_save_term_translations')->never();
$this->allowAllFunctionsToExist();

$result = cdcf_get_or_create_translated_term($this->fakeTerm(173, 'examination'), 'it');

$this->assertNull($result);
}

public function test_get_or_create_links_new_term_into_polylang_group(): void
Expand Down