From 084a52abdaa35376124bc412c8d3da3194144953 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Thu, 11 Jun 2026 09:56:45 -0400 Subject: [PATCH] feat(submissions): translate featured-media attachment before queueing post translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the publish hook now synchronously ensures attachment translation siblings exist for the source's featured_image BEFORE Phase 1 creates the post translation drafts. By the time the worker later handles each post-translation job, pll_get_post(thumbnail, lang) already returns the matching-language attachment sibling — no more EN-image fallback on non-EN posts. Motivation: the existing fallback in cdcf_process_translation $lang_image_id = pll_get_post($source_thumbnail_id, $target_lang); set_post_thumbnail($post_id, $lang_image_id ?: $source_thumbnail_id); quietly assigned the EN attachment when no sibling existed. That left non-EN posts with EN-language alt-text (a11y + SEO regression) and — depending on the WPGraphQL+Polylang resolver behavior — could surface as a null featuredImage on the frontend (cross-language attachment filtered out at query time). Implementation: cdcf_ensure_attachment_translations($source_attachment_id, $target_langs) - Phase 1: per-lang create attachment sibling + pll_set_post_language - Phase 2: ONE atomic pll_save_post_translations with the full map (mirrors PR #203's atomic shape for posts — same lost-update race avoidance, same rollback-on-failure semantics) - Phase 3 (n/a — no queueing; attachment translation is inline) cdcf_create_attachment_translation($source, $source_lang, $target_lang) - OpenAI-translates title/caption/description/alt-text via the existing cdcf_openai_translate helper - Creates a new wp_posts row pointing at the source's underlying _wp_attached_file (no new bytes uploaded — Polylang media translation is metadata-only) - Copies _wp_attachment_metadata + sets translated alt-text via _wp_attachment_image_alt meta - Falls back to source strings on OpenAI error rather than failing the create — a sibling with source-language metadata still beats no sibling (the latter regresses to the EN-image fallback we're fixing) - Returns null only on wp_insert_post hard failure Diagnostic logging follows the PR #206 ENTER/PHASE_1_DONE/PHASE_2_OK /PHASE_2_FAIL pattern with source_id keyed lines (single grep 'cdcf_ensure_attachment' yields the full per-publish attachment timeline). Integration point: cdcf_enqueue_translations_for_submission's existing ENTER log now sees the source thumbnail check, calls Phase 0 if present, then proceeds to Phase 1 unchanged. Phase 0 is a no-op when the source has no thumbnail. 9 new tests cover: - bails when Polylang inactive - bails when source isn't an attachment - skips already-linked languages (pre-seed honored) - skips source_lang in target_langs (caller-error tolerance) - exactly-once atomic save with full final group (regression guard for the lost-update race in the attachment context) - rollback: pll_save_post_translations returning false force-deletes every just-created sibling (mirrors PR #203 shape) - create helper: OpenAI strings flow through to wp_insert_post + alt-text meta correctly - create helper: OpenAI error → source-string fallback (not null) - create helper: wp_insert_post failure → return null 578/578 theme suite green (was 569 pre-PR; +9 new tests + 1 stub addition to stubCommonFunctions for the Phase 0 get_post_thumbnail_id call — all existing publish-flow tests preserve original behavior with the default thumbnail=0 stub meaning Phase 0 is a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../includes/admin/submission-lifecycle.php | 213 +++++++++++++ .../tests/SubmissionLifecycleTest.php | 301 ++++++++++++++++++ 2 files changed, 514 insertions(+) diff --git a/wordpress/themes/cdcf-headless/includes/admin/submission-lifecycle.php b/wordpress/themes/cdcf-headless/includes/admin/submission-lifecycle.php index b374da2..766e470 100644 --- a/wordpress/themes/cdcf-headless/includes/admin/submission-lifecycle.php +++ b/wordpress/themes/cdcf-headless/includes/admin/submission-lifecycle.php @@ -102,6 +102,18 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_ cdcf_format_lang_map($translations) )); + // Phase 0: ensure attachment translations exist for the source's + // featured_image. Without this, post-translation workers would + // either render the EN image (with EN alt-text / SEO regression) or + // get a null featuredImage from WPGraphQL on non-EN posts. Running + // before Phase 1 means by the time the worker handles each post + // translation, pll_get_post(thumbnail, lang) already returns the + // matching-language attachment sibling. + $source_thumbnail_id = (int) get_post_thumbnail_id($en_post_id); + if ($source_thumbnail_id > 0) { + cdcf_ensure_attachment_translations($source_thumbnail_id, $target_langs); + } + // 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. @@ -203,6 +215,207 @@ function cdcf_format_lang_map(array $map): string { return '{' . implode(', ', $parts) . '}'; } +/** + * Synchronously ensure attachment-translation siblings exist for each + * target language of a source attachment. Mirrors the atomic shape of + * cdcf_enqueue_translations_for_submission (Phase 1 create + per-post + * language, Phase 2 ONE atomic pll_save_post_translations) but for + * attachments and inline (no Redis queue): each missing sibling is + * created with OpenAI-translated title/caption/description/alt-text + * before this function returns. + * + * Called from cdcf_enqueue_translations_for_submission's Phase 0 so + * the post-translation worker can later resolve the correct-language + * featured-image via pll_get_post() without falling back to the source + * attachment (which would render with source-language alt-text — an + * SEO + a11y regression — and may be filtered out by WPGraphQL on + * non-source-language posts entirely). + * + * No new file is uploaded: each new sibling is a fresh `wp_posts` row + * pointing at the source's underlying `_wp_attached_file` (and its + * `_wp_attachment_metadata`). Only the WP-side language-dependent + * fields (title, post_excerpt as caption, post_content as description, + * `_wp_attachment_image_alt` meta) are translated. + * + * @param int $source_attachment_id Source attachment post ID. + * @param array $target_langs Locale slugs (e.g. ['it','es','fr','pt','de']). + * @return array {lang => attachment_id} including the source. Empty + * array if Polylang missing or source isn't an attachment. + */ +function cdcf_ensure_attachment_translations(int $source_attachment_id, array $target_langs): array { + if ( + !function_exists('pll_set_post_language') + || !function_exists('pll_save_post_translations') + || !function_exists('pll_get_post_translations') + || !function_exists('pll_get_post_language') + ) { + error_log("cdcf_ensure_attachment_translations: Polylang not active; skipping attachment {$source_attachment_id}."); + return []; + } + + $source = get_post($source_attachment_id); + if (!$source || $source->post_type !== 'attachment') { + error_log("cdcf_ensure_attachment_translations: post {$source_attachment_id} is not an attachment; skipping."); + return []; + } + + $source_lang = pll_get_post_language($source_attachment_id, 'slug') ?: 'en'; + + $translations = pll_get_post_translations($source_attachment_id); + $translations[$source_lang] = $source_attachment_id; + + error_log(sprintf( + 'cdcf_ensure_attachment_translations: ENTER source_id=%d source_lang=%s pre_seed_group=%s', + $source_attachment_id, + $source_lang, + cdcf_format_lang_map($translations) + )); + + // Phase 1: per-lang create + per-post language. Group save deferred to Phase 2. + $newly_created = []; + $already_linked = []; + foreach ($target_langs as $lang) { + if ($lang === $source_lang) { + continue; + } + if (!empty($translations[$lang])) { + $already_linked[$lang] = (int) $translations[$lang]; + continue; + } + + $new_id = cdcf_create_attachment_translation($source, $source_lang, $lang); + if (!$new_id) { + // Helper already logged the specific failure. + continue; + } + pll_set_post_language($new_id, $lang); + $translations[$lang] = $new_id; + $newly_created[$lang] = $new_id; + } + + error_log(sprintf( + 'cdcf_ensure_attachment_translations: PHASE_1_DONE source_id=%d newly_created=%s already_linked=%s', + $source_attachment_id, + cdcf_format_lang_map($newly_created), + cdcf_format_lang_map($already_linked) + )); + + if (empty($newly_created)) { + return $translations; + } + + // Phase 2: one atomic group save. Mirrors PR #203's shape for posts. + $save_result = pll_save_post_translations($translations); + if ($save_result === false) { + error_log(sprintf( + 'cdcf_ensure_attachment_translations: PHASE_2_FAIL source_id=%d pll_save_post_translations returned false; rolling back %d attachment(s): %s', + $source_attachment_id, + count($newly_created), + cdcf_format_lang_map($newly_created) + )); + foreach ($newly_created as $id) { + wp_delete_post($id, true); + } + return []; + } + + error_log(sprintf( + 'cdcf_ensure_attachment_translations: PHASE_2_OK source_id=%d atomic group save succeeded; final_group=%s', + $source_attachment_id, + cdcf_format_lang_map($translations) + )); + + return $translations; +} + +/** + * Create a single attachment-translation sibling. + * + * The new sibling shares the source's underlying file (_wp_attached_file + * + _wp_attachment_metadata). Its title/caption/description/alt-text are + * OpenAI-translated when CDCF's OpenAI helper is available; otherwise + * the source values are copied verbatim (so callers always get a usable + * attachment back rather than a half-formed one). + * + * @return int|null New attachment post ID, or null on wp_insert_post failure. + */ +function cdcf_create_attachment_translation(object $source, string $source_lang, string $target_lang): ?int { + // Collect translatable strings. + $strings = array_filter([ + 'title' => $source->post_title, + 'caption' => $source->post_excerpt, + 'description' => $source->post_content, + 'alt_text' => (string) get_post_meta($source->ID, '_wp_attachment_image_alt', true), + ], static fn($v) => $v !== ''); + + // OpenAI-translate the strings if possible. On any failure we fall + // back to the source values rather than skipping the attachment — + // a sibling with source-language metadata is still better than no + // sibling at all (the latter triggers Phase 2 to skip linking, + // which puts us back in the original "EN fallback on non-EN post" + // regression we're trying to fix). + $translated = $strings; + if (!empty($strings) && function_exists('cdcf_openai_translate')) { + $api_key = get_option('cdcf_openai_api_key'); + if ($api_key) { + $source_name = defined('CDCF_LOCALE_NAMES') + ? (CDCF_LOCALE_NAMES[$source_lang] ?? $source_lang) + : $source_lang; + $target_name = defined('CDCF_LOCALE_NAMES') + ? (CDCF_LOCALE_NAMES[$target_lang] ?? $target_lang) + : $target_lang; + $result = cdcf_openai_translate($strings, $source_name, $target_name, $api_key); + if (!is_wp_error($result) && is_array($result)) { + $translated = array_merge($strings, $result); + } else { + $msg = is_wp_error($result) ? $result->get_error_message() : 'non-array response'; + error_log(sprintf( + 'cdcf_create_attachment_translation: OpenAI error for attachment %d -> %s (%s); using source values.', + $source->ID, + $target_lang, + $msg + )); + } + } + } + + $new_id = wp_insert_post([ + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'post_mime_type' => $source->post_mime_type, + 'post_title' => $translated['title'] ?? $source->post_title, + 'post_excerpt' => $translated['caption'] ?? $source->post_excerpt, + 'post_content' => $translated['description'] ?? $source->post_content, + 'guid' => $source->guid, + ]); + + if (is_wp_error($new_id) || !$new_id) { + $msg = is_wp_error($new_id) ? $new_id->get_error_message() : 'returned 0'; + error_log("cdcf_create_attachment_translation: wp_insert_post failed for attachment {$source->ID} -> {$target_lang}: {$msg}"); + return null; + } + + // Point the sibling at the source's underlying file + metadata. No + // new bytes uploaded — Polylang attachment translations are a + // metadata-only concern; the file remains shared via _wp_attached_file. + $attached_file = get_post_meta($source->ID, '_wp_attached_file', true); + if ($attached_file) { + update_post_meta($new_id, '_wp_attached_file', $attached_file); + } + $metadata = wp_get_attachment_metadata($source->ID); + if (is_array($metadata)) { + wp_update_attachment_metadata($new_id, $metadata); + } + + // Language-specific alt text (lives in _wp_attachment_image_alt, not + // a post-table column). + if (!empty($translated['alt_text'])) { + update_post_meta($new_id, '_wp_attachment_image_alt', $translated['alt_text']); + } + + return (int) $new_id; +} + /** * transition_post_status hook: when a publicly-submitted post * (project or local_group) is restored from trash, WordPress sets it diff --git a/wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php b/wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php index d45cee4..5ecd7a2 100644 --- a/wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php +++ b/wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php @@ -51,6 +51,10 @@ private function stubCommonFunctions(): void Functions\when('pll_get_post_translations')->justReturn([]); Functions\when('get_post_meta')->justReturn(''); Functions\when('cdcf_enqueue_translation')->justReturn('redis'); + // Phase 0 (PR #208) calls get_post_thumbnail_id on the source; + // tests that don't care about featured-image translation get a + // default of 0 (no thumbnail = Phase 0 is a no-op). + Functions\when('get_post_thumbnail_id')->justReturn(0); } private function allowAllFunctionsToExist(): void @@ -284,6 +288,9 @@ public function test_enqueue_translations_falls_back_to_wp_cron_when_redis_helpe Functions\when('pll_get_post_translations')->justReturn([]); Functions\when('get_post')->justReturn($this->fakePost(['ID' => 42])); Functions\when('wp_insert_post')->justReturn(200); + // Phase 0 (PR #208): no thumbnail on the source → skip attachment + // translation. Not exercising that path in this wp-cron-fallback test. + Functions\when('get_post_thumbnail_id')->justReturn(0); Functions\expect('wp_schedule_single_event')->times(5)->andReturn(true); Functions\expect('spawn_cron')->times(5)->andReturnNull(); // Deliberately do NOT stub cdcf_enqueue_translation — it must @@ -453,6 +460,300 @@ public function test_format_lang_map_coerces_string_ids_to_int(): void $this->assertSame('{en:10, it:11}', cdcf_format_lang_map(['en' => '10', 'it' => '11'])); } + // ─── cdcf_ensure_attachment_translations + cdcf_create_attachment_translation ─── + + /** + * Build a fake attachment WP_Post-ish object with the fields the + * production code reads (post_type, post_title, post_excerpt, + * post_content, post_mime_type, guid). Mirrors the bootstrap.php + * WP_Post stub by using stdClass. + */ + private function fakeAttachment(array $overrides = []): stdClass + { + $a = new stdClass(); + $a->ID = 1510; + $a->post_type = 'attachment'; + $a->post_title = 'icon'; + $a->post_excerpt = 'icon caption'; + $a->post_content = 'icon description'; + $a->post_mime_type = 'image/webp'; + $a->guid = 'https://cms.example.test/wp-content/uploads/2026/06/icon.webp'; + foreach ($overrides as $k => $v) { + $a->$k = $v; + } + return $a; + } + + public function test_ensure_attachment_translations_bails_when_polylang_inactive(): void + { + $this->stubCommonFunctions(); + Functions\when('function_exists')->alias( + // pll_set_post_language gated out — bail at the entry guard. + static fn(string $name): bool => $name !== 'pll_set_post_language' + ); + Functions\expect('wp_insert_post')->never(); + + $result = cdcf_ensure_attachment_translations(1510, ['it', 'es', 'fr', 'pt', 'de']); + + $this->assertSame([], $result); + } + + public function test_ensure_attachment_translations_bails_when_source_isnt_attachment(): void + { + $this->stubCommonFunctions(); + // get_post returns a regular post, not an attachment. + Functions\when('get_post')->justReturn($this->fakePost(['ID' => 42, 'post_type' => 'page'])); + Functions\expect('wp_insert_post')->never(); + $this->allowAllFunctionsToExist(); + + $result = cdcf_ensure_attachment_translations(42, ['it', 'es', 'fr', 'pt', 'de']); + + $this->assertSame([], $result); + } + + public function test_ensure_attachment_translations_skips_langs_already_linked(): void + { + // Source 1510 already has an IT and ES sibling — only fr/pt/de + // should get newly created. + $this->stubCommonFunctions(); + Functions\when('get_post')->justReturn($this->fakeAttachment()); + Functions\when('pll_get_post_language')->justReturn('en'); + Functions\when('pll_get_post_translations')->justReturn([ + 'en' => 1510, 'it' => 1516, 'es' => 1517, + ]); + + $counter = 1599; + $inserts = 0; + Functions\when('wp_insert_post')->alias(function () use (&$counter, &$inserts): int { + $counter++; + $inserts++; + return $counter; + }); + Functions\when('cdcf_openai_translate')->justReturn(['title' => 'icona']); + Functions\when('get_option')->justReturn('sk-test-key'); + Functions\when('wp_get_attachment_metadata')->justReturn([]); + Functions\when('wp_update_attachment_metadata')->justReturn(true); + Functions\when('update_post_meta')->justReturn(true); + $this->allowAllFunctionsToExist(); + + $result = cdcf_ensure_attachment_translations(1510, ['it', 'es', 'fr', 'pt', 'de']); + + // 3 newly-created (fr/pt/de = 1600/1601/1602), 2 pre-seeded. + $this->assertSame(3, $inserts); + $this->assertSame([ + 'en' => 1510, 'it' => 1516, 'es' => 1517, + 'fr' => 1600, 'pt' => 1601, 'de' => 1602, + ], $result); + } + + public function test_ensure_attachment_translations_skips_source_lang_in_target_list(): void + { + // If the source is EN and 'en' appears in target_langs (caller bug), + // it must be silently skipped, not duplicated. + $this->stubCommonFunctions(); + Functions\when('get_post')->justReturn($this->fakeAttachment()); + Functions\when('pll_get_post_language')->justReturn('en'); + Functions\when('pll_get_post_translations')->justReturn(['en' => 1510]); + + $inserts = 0; + Functions\when('wp_insert_post')->alias(function () use (&$inserts): int { + $inserts++; + return 1600 + $inserts; + }); + Functions\when('cdcf_openai_translate')->justReturn(['title' => 'translated']); + Functions\when('get_option')->justReturn('sk-test-key'); + Functions\when('wp_get_attachment_metadata')->justReturn([]); + Functions\when('wp_update_attachment_metadata')->justReturn(true); + Functions\when('update_post_meta')->justReturn(true); + $this->allowAllFunctionsToExist(); + + cdcf_ensure_attachment_translations(1510, ['en', 'it', 'es', 'fr', 'pt', 'de']); + + // 5 inserts (it/es/fr/pt/de) — en skipped via source-lang check. + $this->assertSame(5, $inserts); + } + + public function test_ensure_attachment_translations_atomic_save_then_returns_full_group(): void + { + // Happy path: 5 missing langs, OpenAI translates each, wp_insert_post + // succeeds 5 times, ONE atomic pll_save_post_translations call, + // returned group has 6 entries. + $this->stubCommonFunctions(); + Functions\when('get_post')->justReturn($this->fakeAttachment()); + Functions\when('pll_get_post_language')->justReturn('en'); + Functions\when('pll_get_post_translations')->justReturn(['en' => 1510]); + + $counter = 1599; + Functions\when('wp_insert_post')->alias(function () use (&$counter): int { + $counter++; + return $counter; + }); + Functions\when('cdcf_openai_translate')->justReturn(['title' => 'icona']); + Functions\when('get_option')->justReturn('sk-test-key'); + Functions\when('wp_get_attachment_metadata')->justReturn(['width' => 96]); + Functions\when('wp_update_attachment_metadata')->justReturn(true); + Functions\when('update_post_meta')->justReturn(true); + + $saves = []; + Functions\when('pll_save_post_translations')->alias( + function (array $map) use (&$saves): bool { + $saves[] = $map; + return true; + } + ); + $this->allowAllFunctionsToExist(); + + $result = cdcf_ensure_attachment_translations(1510, ['it', 'es', 'fr', 'pt', 'de']); + + // Exactly ONE atomic save — regression guard for the lost-update race. + $this->assertCount(1, $saves); + $this->assertSame( + ['en' => 1510, 'it' => 1600, 'es' => 1601, 'fr' => 1602, 'pt' => 1603, 'de' => 1604], + $result + ); + } + + public function test_ensure_attachment_translations_rolls_back_on_atomic_save_failure(): void + { + // pll_save_post_translations returns false → force-delete all + // just-created siblings + return empty. Mirrors PR #203's + // rollback shape so a failed call leaves no orphan attachments. + $this->stubCommonFunctions(); + Functions\when('get_post')->justReturn($this->fakeAttachment()); + Functions\when('pll_get_post_language')->justReturn('en'); + Functions\when('pll_get_post_translations')->justReturn(['en' => 1510]); + + $counter = 1599; + Functions\when('wp_insert_post')->alias(function () use (&$counter): int { + $counter++; + return $counter; + }); + Functions\when('cdcf_openai_translate')->justReturn(['title' => 'x']); + Functions\when('get_option')->justReturn('sk-test-key'); + Functions\when('wp_get_attachment_metadata')->justReturn([]); + Functions\when('wp_update_attachment_metadata')->justReturn(true); + Functions\when('update_post_meta')->justReturn(true); + 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 []; + } + ); + $this->allowAllFunctionsToExist(); + + $result = cdcf_ensure_attachment_translations(1510, ['it', 'es', 'fr', 'pt', 'de']); + + $this->assertSame([], $result); + $this->assertSame( + [[1600, true], [1601, true], [1602, true], [1603, true], [1604, true]], + $deleted, + 'all 5 just-created attachments must be force-deleted on rollback' + ); + } + + public function test_create_attachment_translation_uses_openai_translated_strings(): void + { + $this->stubCommonFunctions(); + $captured_insert = null; + Functions\when('wp_insert_post')->alias( + function (array $args) use (&$captured_insert): int { + $captured_insert = $args; + return 1600; + } + ); + // Verify OpenAI got source strings (sans empty) and returns target translations. + Functions\when('cdcf_openai_translate')->alias( + static fn(array $strings, string $src, string $tgt) => array_combine( + array_keys($strings), + array_map(static fn($v) => $tgt . ':' . $v, $strings) + ) + ); + Functions\when('get_option')->justReturn('sk-test-key'); + Functions\when('get_post_meta')->alias( + static fn(int $id, string $key) => $key === '_wp_attachment_image_alt' ? 'icon alt' : '' + ); + Functions\when('wp_get_attachment_metadata')->justReturn([]); + Functions\when('wp_update_attachment_metadata')->justReturn(true); + + $alt_writes = []; + Functions\when('update_post_meta')->alias( + function (int $id, string $key, $value) use (&$alt_writes): bool { + if ($key === '_wp_attachment_image_alt') { + $alt_writes[] = [$id, $value]; + } + return true; + } + ); + $this->allowAllFunctionsToExist(); + + $result = cdcf_create_attachment_translation($this->fakeAttachment(), 'en', 'it'); + + $this->assertSame(1600, $result); + // OpenAI helper receives the LOCALE NAME (CDCF_LOCALE_NAMES['it'] + // = "Italian"), not the slug, so prefixed strings reflect that. + $this->assertSame('Italian:icon', $captured_insert['post_title']); + $this->assertSame('Italian:icon caption', $captured_insert['post_excerpt']); + $this->assertSame('Italian:icon description', $captured_insert['post_content']); + $this->assertSame('attachment', $captured_insert['post_type']); + $this->assertSame('image/webp', $captured_insert['post_mime_type']); + // Alt-text lives in meta, not the posts table. + $this->assertSame([[1600, 'Italian:icon alt']], $alt_writes); + } + + public function test_create_attachment_translation_falls_back_to_source_on_openai_error(): void + { + // OpenAI error → use source strings verbatim. A sibling with + // source-language metadata is still better than no sibling at + // all (the latter regresses to the EN-image fallback we're + // fixing in the first place). + $this->stubCommonFunctions(); + $captured_insert = null; + Functions\when('wp_insert_post')->alias( + function (array $args) use (&$captured_insert): int { + $captured_insert = $args; + return 1600; + } + ); + Functions\when('cdcf_openai_translate')->justReturn( + new WP_Error('openai_error', 'rate limit', ['status' => 429]) + ); + Functions\when('get_option')->justReturn('sk-test-key'); + Functions\when('get_post_meta')->justReturn(''); + Functions\when('wp_get_attachment_metadata')->justReturn([]); + Functions\when('wp_update_attachment_metadata')->justReturn(true); + Functions\when('update_post_meta')->justReturn(true); + $this->allowAllFunctionsToExist(); + + $result = cdcf_create_attachment_translation($this->fakeAttachment(), 'en', 'it'); + + $this->assertSame(1600, $result); + // Source strings verbatim — not "it:..."-prefixed. + $this->assertSame('icon', $captured_insert['post_title']); + $this->assertSame('icon caption', $captured_insert['post_excerpt']); + $this->assertSame('icon description', $captured_insert['post_content']); + } + + public function test_create_attachment_translation_returns_null_on_wp_insert_post_failure(): void + { + $this->stubCommonFunctions(); + Functions\when('wp_insert_post')->justReturn( + new WP_Error('insert_failed', 'database error') + ); + Functions\when('cdcf_openai_translate')->justReturn(['title' => 'icona']); + Functions\when('get_option')->justReturn('sk-test-key'); + Functions\when('get_post_meta')->justReturn(''); + Functions\expect('wp_update_attachment_metadata')->never(); + Functions\expect('update_post_meta')->never(); + $this->allowAllFunctionsToExist(); + + $result = cdcf_create_attachment_translation($this->fakeAttachment(), 'en', 'it'); + + $this->assertNull($result); + } + // ─── cdcf_repend_submission_on_untrash ──────────────────────────── public function test_repend_ignores_status_transitions_that_arent_trash_to_draft(): void