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
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,23 @@ function cdcf_is_public_submission(int $post_id): bool {
}

/**
* For each target language (it/es/fr/pt/de):
* - Skip if a Polylang translation already exists.
* - Otherwise create a draft sibling post, link it via Polylang,
* and enqueue a background AI translation job.
* Create + Polylang-link the missing it/es/fr/pt/de sibling drafts of an
* EN source post, then enqueue an AI translation job per newly-created
* sibling. The existing worker (cdcf_process_translation) will auto-
* publish each translation once its source post is `publish`.
*
* The existing worker (cdcf_process_translation) will auto-publish
* each translation once its source post is `publish`.
* Group save is **atomic** — exactly one pll_save_post_translations()
* call after every sibling has been created. The earlier shape (one
* save per iteration, accumulating the map) lost-updated the Polylang
* translation group on production: Interior Castle App publish on
* 2026-06-08 created all 6 siblings with correct per-post languages but
* the group came out as {en} only, orphaning IT/ES/FR/PT/DE — the same
* lost-update race the /translate-all endpoint was created to fix for
* the meta-box Translate-All fan-out. Mirroring its atomic shape here
* makes the publish-flow race-resistant by construction.
*
* If the atomic save fails, every just-created draft is force-deleted
* so a failed call leaves no orphans behind (same shape as /translate-all).
*
* @param int $en_post_id The English (source) post ID. MUST be the source,
* not a translation — caller should resolve via
Expand All @@ -80,19 +90,20 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_

$target_langs = ['it', 'es', 'fr', 'pt', 'de'];

// Build the Polylang translation map once; accumulate as we create new siblings.
// Pre-seeding from the existing map handles partial re-runs where some langs
// are already linked.
// Pre-seed the map from any existing group (handles partial re-runs
// where some langs are already linked from a previous attempt).
$translations = pll_get_post_translations($en_post_id);
$translations['en'] = $en_post_id;

// Phase 1: create draft siblings + per-post language assignment.
// Group save is intentionally NOT called here — see file-level
// docblock for the lost-update race rationale.
$newly_created = [];
foreach ($target_langs as $lang) {
// Skip if a translation is already linked for this language.
if (!empty($translations[$lang])) {
continue;
}

// Create a draft sibling post; the worker will fill content and auto-publish.
$trans_id = wp_insert_post([
'post_type' => $post_type,
'post_status' => 'draft',
Expand All @@ -105,11 +116,32 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_
}

pll_set_post_language($trans_id, $lang);

$translations[$lang] = $trans_id;
pll_save_post_translations($translations);
$newly_created[$lang] = $trans_id;
}

if (empty($newly_created)) {
// Nothing new to link or enqueue.
return;
}

// Phase 2: one atomic group save with the full {lang => post_id} map.
$save_result = pll_save_post_translations($translations);
if ($save_result === false) {
error_log(sprintf(
'cdcf_enqueue_translations_for_submission: pll_save_post_translations returned false for post %d; rolling back %d draft(s).',
$en_post_id,
count($newly_created)
));
foreach ($newly_created as $trans_id) {
wp_delete_post($trans_id, true);
}
return;
}

// Enqueue background translation: Redis Queue if available, WP-Cron fallback.
// Phase 3: enqueue translation jobs for the newly-created siblings only.
// (Existing siblings from the pre-seed already had their content done.)
foreach ($newly_created as $lang => $trans_id) {
if (function_exists('cdcf_enqueue_translation')) {
cdcf_enqueue_translation($trans_id, $en_post_id, $lang);
} else {
Expand Down
102 changes: 102 additions & 0 deletions wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,108 @@ function (int $trans_id, int $en_id, string $lang) use (&$enqueued): string {
$this->assertSame(['it', 'es', 'pt', 'de'], $enqueued);
}

public function test_enqueue_translations_calls_pll_save_exactly_once_with_full_map(): void
{
// Regression guard for the lost-update race observed on
// production 2026-06-08 (Interior Castle App publish): the old
// shape called pll_save_post_translations 5x with progressively
// larger maps, racing against Polylang's post-update hooks that
// fire when the worker auto-publishes a sibling mid-loop. The
// atomic shape saves exactly once with the complete map.
$this->stubCommonFunctions();
Functions\when('get_post')->justReturn($this->fakePost([
'ID' => 42,
'post_title' => 'New Project',
]));

$counter = 199;
Functions\when('wp_insert_post')->alias(
function () use (&$counter): int {
$counter++;
return $counter;
}
);

$saves = [];
Functions\when('pll_save_post_translations')->alias(
function (array $map) use (&$saves): bool {
$saves[] = $map;
return true;
}
);
$this->allowAllFunctionsToExist();

cdcf_enqueue_translations_for_submission(42, 'project');

$this->assertCount(1, $saves, 'pll_save_post_translations must be called exactly once');
$this->assertSame(
['en' => 42, 'it' => 200, 'es' => 201, 'fr' => 202, 'pt' => 203, 'de' => 204],
$saves[0],
'the single save must carry the complete 6-language map'
);
}

public function test_enqueue_translations_skips_save_and_enqueue_when_all_langs_already_linked(): void
{
// Pre-seed already covers every target lang — no new drafts to
// create, so nothing new to atomically save and nothing to enqueue.
$this->stubCommonFunctions();
Functions\when('get_post')->justReturn($this->fakePost(['ID' => 42]));
Functions\when('pll_get_post_translations')->justReturn([
'it' => 50, 'es' => 51, 'fr' => 52, 'pt' => 53, 'de' => 54,
]);
Functions\expect('wp_insert_post')->never();
Functions\expect('pll_save_post_translations')->never();
Functions\expect('cdcf_enqueue_translation')->never();
$this->allowAllFunctionsToExist();

cdcf_enqueue_translations_for_submission(42, 'project');

$this->assertTrue(true);
}

public function test_enqueue_translations_rolls_back_drafts_when_atomic_save_fails(): void
{
// pll_save_post_translations can return false (Polylang inactive
// post-creation, term-save error, etc.) — drafts that were just
// created must be force-deleted to avoid orphan rows in the DB,
// and no translation jobs should be enqueued for posts that no
// longer have a Polylang group.
$this->stubCommonFunctions();
Functions\when('get_post')->justReturn($this->fakePost([
'ID' => 42,
'post_title' => 'New Project',
]));

$counter = 199;
Functions\when('wp_insert_post')->alias(
function () use (&$counter): int {
$counter++;
return $counter;
}
);
Functions\when('pll_save_post_translations')->justReturn(false);

$deleted = [];
Functions\when('wp_delete_post')->alias(
function (int $id, bool $force) use (&$deleted): array {
$deleted[] = [$id, $force];
return [];
}
);
Functions\expect('cdcf_enqueue_translation')->never();
$this->allowAllFunctionsToExist();

cdcf_enqueue_translations_for_submission(42, 'project');

// All 5 just-created drafts (it, es, fr, pt, de = ids 200-204)
// force-deleted (second arg = true).
$this->assertSame(
[[200, true], [201, true], [202, true], [203, true], [204, true]],
$deleted
);
}

// ─── cdcf_repend_submission_on_untrash ────────────────────────────

public function test_repend_ignores_status_transitions_that_arent_trash_to_draft(): void
Expand Down