diff --git a/scripts/cdcf_api.py b/scripts/cdcf_api.py index 1698d64..58edcae 100644 --- a/scripts/cdcf_api.py +++ b/scripts/cdcf_api.py @@ -415,6 +415,20 @@ def link_translations(self, translations: dict[str, int]) -> dict: """ return self._wp_post("cdcf/v1/link-translations", {"translations": translations}) + def link_term_translations(self, taxonomy: str, translations: dict[str, int]) -> dict: + """POST /cdcf/v1/link-term-translations + + Term equivalent of link_translations. Atomically sets each term's + Polylang language and links the group together. + + taxonomy: taxonomy slug, e.g. "project_tag" + translations: dict mapping language code to term ID, e.g. {"en": 169, "it": 231} + """ + return self._wp_post( + "cdcf/v1/link-term-translations", + {"taxonomy": taxonomy, "translations": translations}, + ) + # -- Project Status -- def update_project_status(self, post_id: int, status: str) -> dict: @@ -580,6 +594,15 @@ def _build_parser() -> argparse.ArgumentParser: p.add_argument("--translations", required=True, help='JSON object mapping lang to post ID, e.g. \'{"en":10,"it":12}\'') + # link-term-translations + p = sub.add_parser( + "link-term-translations", + help="Atomically link translation term IDs across languages within a taxonomy", + ) + p.add_argument("--taxonomy", required=True, help='e.g. "project_tag"') + p.add_argument("--translations", required=True, + help='JSON object mapping lang to term ID, e.g. \'{"en":169,"it":231}\'') + # update-project-status p = sub.add_parser("update-project-status", help="Update project approval status") p.add_argument("--post-id", type=int, required=True) @@ -752,6 +775,10 @@ def _run_cli(args: argparse.Namespace, client: CdcfClient) -> dict: translations = json.loads(args.translations) return client.link_translations(translations) + if cmd == "link-term-translations": + translations = json.loads(args.translations) + return client.link_term_translations(args.taxonomy, translations) + if cmd == "update-project-status": return client.update_project_status(args.post_id, args.status) diff --git a/wordpress/themes/cdcf-headless/functions.php b/wordpress/themes/cdcf-headless/functions.php index e713c3c..da3bc4a 100644 --- a/wordpress/themes/cdcf-headless/functions.php +++ b/wordpress/themes/cdcf-headless/functions.php @@ -424,13 +424,49 @@ return current_user_can('edit_posts'); }, 'args' => [ - 'translations' => ['required' => true, 'type' => 'object'], + 'translations' => [ + 'required' => true, + 'type' => 'object', + 'sanitize_callback' => 'cdcf_sanitize_translations_map', + ], ], ]); }); require_once __DIR__ . '/includes/handlers/link-translations.php'; +// ─── REST endpoint for atomically linking term translations ────────── +// +// Term equivalent of /link-translations. Polylang's term-side +// language + group helpers are PHP-only; this is the thin wrapper. +// +// POST /wp-json/cdcf/v1/link-term-translations (Application Password auth) +// Body: { "taxonomy": "project_tag", "translations": { "en": 169, "it": 231, ... } } + +add_action('rest_api_init', function () { + register_rest_route('cdcf/v1', '/link-term-translations', [ + 'methods' => 'POST', + 'callback' => 'cdcf_rest_link_term_translations', + 'permission_callback' => function () { + return current_user_can('edit_posts'); + }, + 'args' => [ + 'taxonomy' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ], + 'translations' => [ + 'required' => true, + 'type' => 'object', + 'sanitize_callback' => 'cdcf_sanitize_translations_map', + ], + ], + ]); +}); + +require_once __DIR__ . '/includes/handlers/link-term-translations.php'; + // ─── REST endpoint for updating project status across translations ─── // // Sets the project_status ACF field on a project and all its Polylang @@ -673,6 +709,7 @@ // cdcf_is_spam_content) used by every public-submission endpoint below. // Required here — after CDCF_DISPOSABLE_DOMAINS_FILE is defined — so // the disposable-domain lookup can read the blocklist file path. +require_once __DIR__ . '/includes/sanitizers.php'; require_once __DIR__ . '/includes/security.php'; // Footnote/fragment-anchor protection helper, applied at every wp_kses_post diff --git a/wordpress/themes/cdcf-headless/includes/handlers/link-term-translations.php b/wordpress/themes/cdcf-headless/includes/handlers/link-term-translations.php new file mode 100644 index 0000000..0b40dc6 --- /dev/null +++ b/wordpress/themes/cdcf-headless/includes/handlers/link-term-translations.php @@ -0,0 +1,81 @@ + term_id} map + * of two or more already-existing terms in a single taxonomy, sets each + * term's Polylang language and links them into a single translation + * group in one atomic pll_save_term_translations() call. + * + * Use case: repairing corrupted Polylang term groups (e.g. after a + * propagation bug scrambled sibling links) or seeding language metadata + * on terms created outside the normal flow. Polylang's term-side + * language and translation-group helpers are PHP-only — there's no + * native REST surface for them, hence this thin wrapper. + * + * Extracted from functions.php so the body can be unit-tested with + * Brain Monkey + Mockery. + */ + +if (defined('ABSPATH') === false) { + return; +} + +function cdcf_rest_link_term_translations(WP_REST_Request $request) { + if ( + !function_exists('pll_set_term_language') + || !function_exists('pll_save_term_translations') + ) { + return new WP_Error('polylang_missing', 'Polylang is not active.', ['status' => 500]); + } + + $taxonomy = $request['taxonomy']; + if (!is_string($taxonomy) || $taxonomy === '' || !taxonomy_exists($taxonomy)) { + return new WP_Error( + 'invalid_taxonomy', + "Taxonomy '{$taxonomy}' does not exist.", + ['status' => 400] + ); + } + + $translations = $request['translations']; + if (!is_array($translations) || count($translations) < 2) { + return new WP_Error( + 'invalid_translations', + 'Provide at least 2 language => term_id pairs.', + ['status' => 400] + ); + } + + // Validate all terms exist in the named taxonomy. + foreach ($translations as $lang => $term_id) { + $term_id = (int) $term_id; + $term = get_term($term_id, $taxonomy); + if (!$term || is_wp_error($term)) { + return new WP_Error( + 'invalid_term', + "Term {$term_id} does not exist in taxonomy '{$taxonomy}'.", + ['status' => 400] + ); + } + $translations[$lang] = $term_id; + } + + // Set language on each term, then atomically link the group. + foreach ($translations as $lang => $term_id) { + pll_set_term_language($term_id, $lang); + } + if (pll_save_term_translations($translations) === false) { + return new WP_Error( + 'link_failed', + 'Polylang refused to save the term translation group.', + ['status' => 500] + ); + } + + return rest_ensure_response([ + 'success' => true, + 'taxonomy' => $taxonomy, + 'translations' => $translations, + ]); +} diff --git a/wordpress/themes/cdcf-headless/includes/sanitizers.php b/wordpress/themes/cdcf-headless/includes/sanitizers.php new file mode 100644 index 0000000..3c7dc19 --- /dev/null +++ b/wordpress/themes/cdcf-headless/includes/sanitizers.php @@ -0,0 +1,48 @@ + id} translations map used by both + * /cdcf/v1/link-translations (posts) and /cdcf/v1/link-term-translations + * (terms). Coerces to an associative array of language-code keys to + * positive integer IDs; silently drops entries whose keys are not a + * recognized language-code shape (`/^[a-z]{2}(-[A-Z]{2})?$/`, covering + * ISO 639-1 alone plus optional ISO 3166-1 region — matches the + * Polylang slug shape the rest of the site uses), and entries whose + * IDs are zero or non-numeric after absint(). The handler is then + * responsible for the count >= 2 structural check and the per-id + * existence check, both of which return contextual WP_Error. + * + * @param mixed $value Whatever the client sent (usually a + * JSON-decoded associative array; WP REST + * framework leaves objects as arrays). + * @return array Empty array on no valid entries. + */ +function cdcf_sanitize_translations_map($value): array { + if (!is_array($value)) { + return []; + } + $sanitized = []; + foreach ($value as $lang => $id) { + if (!is_string($lang) || !preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $lang)) { + continue; + } + $id = absint($id); + if ($id > 0) { + $sanitized[$lang] = $id; + } + } + return $sanitized; +} diff --git a/wordpress/themes/cdcf-headless/tests/LinkTermTranslationsHandlerTest.php b/wordpress/themes/cdcf-headless/tests/LinkTermTranslationsHandlerTest.php new file mode 100644 index 0000000..e082715 --- /dev/null +++ b/wordpress/themes/cdcf-headless/tests/LinkTermTranslationsHandlerTest.php @@ -0,0 +1,217 @@ +returnArg(1); + Functions\when('pll_set_term_language')->justReturn(true); + Functions\when('pll_save_term_translations')->justReturn(true); + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('get_term')->alias( + static fn(int $id, string $tax) => (object) ['term_id' => $id, 'taxonomy' => $tax] + ); + Functions\when('is_wp_error')->alias(static fn($v): bool => $v instanceof WP_Error); + } + + private function allowAllFunctionsToExist(): void + { + Functions\when('function_exists')->alias(static fn(string $name): bool => true); + } + + private function makeRequest(array $params): WP_REST_Request + { + $req = new WP_REST_Request(); + foreach ($params as $k => $v) { + $req->set_param($k, $v); + } + return $req; + } + + public function test_returns_500_when_polylang_inactive(): void + { + Functions\when('function_exists')->alias( + static fn(string $name): bool => $name !== 'pll_set_term_language' + ); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'project_tag', + 'translations' => ['en' => 169, 'it' => 231], + ])); + + $this->assertInstanceOf(WP_Error::class, $response); + $this->assertSame('polylang_missing', $response->get_error_code()); + $this->assertSame(500, $response->get_error_data()['status']); + } + + public function test_returns_400_when_taxonomy_is_missing(): void + { + $this->stubPolylangAndCoreTerm(); + Functions\when('taxonomy_exists')->justReturn(false); + $this->allowAllFunctionsToExist(); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'nonexistent_tax', + 'translations' => ['en' => 169, 'it' => 231], + ])); + + $this->assertInstanceOf(WP_Error::class, $response); + $this->assertSame('invalid_taxonomy', $response->get_error_code()); + $this->assertSame(400, $response->get_error_data()['status']); + } + + public function test_returns_400_when_translations_is_not_array(): void + { + $this->stubPolylangAndCoreTerm(); + $this->allowAllFunctionsToExist(); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'project_tag', + 'translations' => 'not-an-array', + ])); + + $this->assertInstanceOf(WP_Error::class, $response); + $this->assertSame('invalid_translations', $response->get_error_code()); + $this->assertSame(400, $response->get_error_data()['status']); + } + + public function test_returns_400_when_fewer_than_two_translations_provided(): void + { + $this->stubPolylangAndCoreTerm(); + $this->allowAllFunctionsToExist(); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'project_tag', + 'translations' => ['en' => 169], + ])); + + $this->assertInstanceOf(WP_Error::class, $response); + $this->assertSame('invalid_translations', $response->get_error_code()); + } + + public function test_returns_400_when_any_referenced_term_does_not_exist(): void + { + Functions\when('rest_ensure_response')->returnArg(1); + Functions\when('pll_set_term_language')->justReturn(true); + Functions\when('pll_save_term_translations')->justReturn(true); + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('is_wp_error')->alias(static fn($v): bool => $v instanceof WP_Error); + // Term 169 exists; term 999 does not. + Functions\when('get_term')->alias( + static fn(int $id, string $tax) => + $id === 169 ? (object) ['term_id' => 169, 'taxonomy' => $tax] : null + ); + Functions\expect('pll_set_term_language')->never(); + Functions\expect('pll_save_term_translations')->never(); + $this->allowAllFunctionsToExist(); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'project_tag', + 'translations' => ['en' => 169, 'it' => 999], + ])); + + $this->assertInstanceOf(WP_Error::class, $response); + $this->assertSame('invalid_term', $response->get_error_code()); + $this->assertStringContainsString('999', $response->get_error_message()); + $this->assertSame(400, $response->get_error_data()['status']); + } + + public function test_returns_400_when_get_term_returns_wp_error(): void + { + Functions\when('rest_ensure_response')->returnArg(1); + Functions\when('pll_set_term_language')->justReturn(true); + Functions\when('pll_save_term_translations')->justReturn(true); + Functions\when('taxonomy_exists')->justReturn(true); + Functions\when('is_wp_error')->alias(static fn($v): bool => $v instanceof WP_Error); + Functions\when('get_term')->justReturn(new WP_Error('invalid_term', 'oops')); + Functions\expect('pll_set_term_language')->never(); + $this->allowAllFunctionsToExist(); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'project_tag', + 'translations' => ['en' => 169, 'it' => 231], + ])); + + $this->assertInstanceOf(WP_Error::class, $response); + $this->assertSame('invalid_term', $response->get_error_code()); + } + + public function test_returns_500_when_pll_save_term_translations_fails(): void + { + $this->stubPolylangAndCoreTerm(); + Functions\when('pll_save_term_translations')->justReturn(false); + $this->allowAllFunctionsToExist(); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'project_tag', + 'translations' => ['en' => 169, 'it' => 231], + ])); + + $this->assertInstanceOf(WP_Error::class, $response); + $this->assertSame('link_failed', $response->get_error_code()); + $this->assertSame(500, $response->get_error_data()['status']); + } + + public function test_happy_path_sets_language_on_each_term_and_links_group(): void + { + $this->stubPolylangAndCoreTerm(); + + $languageWrites = []; + Functions\when('pll_set_term_language')->alias( + function (int $term_id, string $lang) use (&$languageWrites): bool { + $languageWrites[] = [$term_id, $lang]; + return true; + } + ); + + $linkedGroup = null; + Functions\when('pll_save_term_translations')->alias( + function (array $map) use (&$linkedGroup): bool { + $linkedGroup = $map; + return true; + } + ); + + $this->allowAllFunctionsToExist(); + + $response = cdcf_rest_link_term_translations($this->makeRequest([ + 'taxonomy' => 'project_tag', + // Mix string IDs to verify the (int) coercion. + 'translations' => ['en' => 169, 'it' => '231', 'es' => '241'], + ])); + + $this->assertSame( + [[169, 'en'], [231, 'it'], [241, 'es']], + $languageWrites + ); + $this->assertSame( + ['en' => 169, 'it' => 231, 'es' => 241], + $linkedGroup + ); + $this->assertTrue($response['success']); + $this->assertSame('project_tag', $response['taxonomy']); + $this->assertSame(['en' => 169, 'it' => 231, 'es' => 241], $response['translations']); + } +} diff --git a/wordpress/themes/cdcf-headless/tests/SanitizersTest.php b/wordpress/themes/cdcf-headless/tests/SanitizersTest.php new file mode 100644 index 0000000..8aeae1b --- /dev/null +++ b/wordpress/themes/cdcf-headless/tests/SanitizersTest.php @@ -0,0 +1,121 @@ +alias(static fn($v): int => (int) max(0, (int) $v)); + } + + protected function tearDown(): void + { + Monkey\tearDown(); + Mockery::close(); + parent::tearDown(); + } + + public function test_non_array_input_returns_empty_array(): void + { + $this->assertSame([], cdcf_sanitize_translations_map('not-an-array')); + $this->assertSame([], cdcf_sanitize_translations_map(null)); + $this->assertSame([], cdcf_sanitize_translations_map(42)); + $this->assertSame([], cdcf_sanitize_translations_map(new stdClass())); + } + + public function test_empty_array_returns_empty_array(): void + { + $this->assertSame([], cdcf_sanitize_translations_map([])); + } + + public function test_well_formed_map_passes_through_with_int_coercion(): void + { + $sanitized = cdcf_sanitize_translations_map([ + 'en' => 10, + 'it' => '11', // string-int coerced via absint + 'es' => 12.0, // float coerced + ]); + $this->assertSame(['en' => 10, 'it' => 11, 'es' => 12], $sanitized); + } + + public function test_accepts_iso_639_1_with_optional_iso_3166_1_region(): void + { + // 2-letter alone, and the 2-2 hyphenated region form (en-US, pt-BR). + $sanitized = cdcf_sanitize_translations_map([ + 'en' => 1, + 'en-US' => 2, + 'pt-BR' => 3, + 'zh-CN' => 4, + ]); + $this->assertSame( + ['en' => 1, 'en-US' => 2, 'pt-BR' => 3, 'zh-CN' => 4], + $sanitized + ); + } + + public function test_drops_malformed_language_keys(): void + { + // Mix of valid and invalid keys; only valid ones survive. + $sanitized = cdcf_sanitize_translations_map([ + 'en' => 1, // valid + 'eng' => 2, // 3-letter — drop + 'EN' => 3, // uppercase — drop + 'e1' => 4, // digit — drop + 'en-us' => 5, // region must be uppercase — drop + 'en_US' => 6, // underscore not allowed — drop + 'en-USA' => 7, // 3-letter region — drop + '' => 8, // empty — drop + 'it' => 9, // valid + ]); + $this->assertSame(['en' => 1, 'it' => 9], $sanitized); + } + + public function test_drops_non_string_keys(): void + { + // PHP numeric-string-key coercion: array literal keys like 0 + // and '0' become int(0). is_string() returns false → dropped. + $sanitized = cdcf_sanitize_translations_map([ + 'en' => 1, + 0 => 2, // int key + '1' => 3, // string-numeric coerced to int by PHP + 'it' => 4, + ]); + $this->assertSame(['en' => 1, 'it' => 4], $sanitized); + } + + public function test_drops_zero_and_negative_values(): void + { + $sanitized = cdcf_sanitize_translations_map([ + 'en' => 10, + 'it' => 0, // absint → 0 → drop + 'es' => -5, // absint → 0 (max guard) → drop + 'fr' => '0', // absint('0') → 0 → drop + 'pt' => 'abc', // absint('abc') → 0 → drop + 'de' => 20, + ]); + $this->assertSame(['en' => 10, 'de' => 20], $sanitized); + } + + public function test_polylang_six_language_map_round_trips_intact(): void + { + // The standard cdcf production map — must round-trip with no churn. + $input = [ + 'en' => 169, 'it' => 231, 'es' => 241, + 'fr' => 252, 'pt' => 264, 'de' => 275, + ]; + $this->assertSame($input, cdcf_sanitize_translations_map($input)); + } +} diff --git a/wordpress/themes/cdcf-headless/tests/bootstrap.php b/wordpress/themes/cdcf-headless/tests/bootstrap.php index 7bd42dc..8488ab3 100644 --- a/wordpress/themes/cdcf-headless/tests/bootstrap.php +++ b/wordpress/themes/cdcf-headless/tests/bootstrap.php @@ -175,6 +175,7 @@ public function set(string $key, $value): void { ]); } +require_once __DIR__ . '/../includes/sanitizers.php'; require_once __DIR__ . '/../includes/security.php'; require_once __DIR__ . '/../includes/fragment-anchors.php'; require_once __DIR__ . '/../includes/translation-status.php'; @@ -191,6 +192,7 @@ public function set(string $key, $value): void { require_once __DIR__ . '/../includes/handlers/deploy-translation.php'; require_once __DIR__ . '/../includes/handlers/translation-status.php'; require_once __DIR__ . '/../includes/handlers/link-translations.php'; +require_once __DIR__ . '/../includes/handlers/link-term-translations.php'; require_once __DIR__ . '/../includes/handlers/project-status.php'; require_once __DIR__ . '/../includes/handlers/flush-opcache.php'; require_once __DIR__ . '/../includes/handlers/send-verification-code.php';