Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f79ae953 | |||
| 75e2bf2535 | |||
| 3fb5a3b3f9 | |||
| 86257843e8 | |||
| 8c83a3a695 | |||
| 815412f7fa | |||
| babff260a1 | |||
| 0abaacfcc0 |
@@ -2,9 +2,9 @@
|
||||
|
||||
> **Para quién es este documento:** para Inma y Mixbot, a partir de la conversación del
|
||||
> 2026-08-31 en Buzz: además del audio ([#222](https://gitea.feadulta.com/rafa/feadulta/issues/222),
|
||||
> ver `docs/guia-tts-audio-autoservicio-inma-mixbot.md`), Rafa quiere traspasar también la
|
||||
> traducción. A diferencia del audio, hoy **no hay un issue abierto pidiéndolo** — este
|
||||
> documento adelanta el trabajo para cuando se dispare.
|
||||
> ver `docs/guia-tts-audio-autoservicio-inma-mixbot.md`), Rafa confirmó que la traducción se
|
||||
> traspasa también. El endpoint para enlazar traducciones (PR #225) se hizo bajo el paraguas
|
||||
> del propio #222.
|
||||
>
|
||||
> **Motores autorizados por Rafa (2026-08-31):** un agente local con Haiku desde el propio
|
||||
> Claude/Cowork de Inma (no la API de pago de Rafa — es otra cuenta, otro caso), o MiniMax
|
||||
@@ -13,28 +13,43 @@
|
||||
|
||||
---
|
||||
|
||||
## 0. Antes de nada: esto también tiene un tope bloqueante
|
||||
## 0. Crear y enlazar la traducción — ya resuelto
|
||||
|
||||
Igual que pasaba con el audio, **hoy no hay forma de decirle a WordPress "esta traducción
|
||||
va en inglés y es la versión de este post en español"** desde fuera. Ese enlace (idioma +
|
||||
grupo de traducción) lo gestiona Polylang con dos funciones PHP internas
|
||||
(`pll_set_post_language`, `pll_save_post_translations`) que **no están expuestas por REST** —
|
||||
ni siquiera de lectura (el único endpoint que hay, `fea/v1/lang/{id}`, solo lee el idioma, no
|
||||
permite fijarlo ni enlazar grupos).
|
||||
Ya existe un endpoint que crea el post traducido **y** lo enlaza con Polylang (idioma +
|
||||
grupo de traducción) en una sola llamada — no hace falta tocar nada de bajo nivel:
|
||||
|
||||
Podéis crear el post traducido por `POST /wp/v2/posts` sin problema (igual que cualquier
|
||||
artículo), pero **quedaría suelto**: sin idioma asignado y sin enlace Polylang al original en
|
||||
español, así que el selector de idioma del sitio no lo encontraría y no contaría como "la
|
||||
traducción EN de este artículo".
|
||||
```
|
||||
POST https://www.feadulta.com/wp-json/fea/v1/crear-traduccion
|
||||
Authorization: Basic <usuario:contraseña_de_aplicación>
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
**Esto necesita el mismo tipo de solución que el audio**: un endpoint `fea/v1/crear-traduccion`
|
||||
(o registrar la taxonomía `language` de Polylang en REST con permisos de administrador) que
|
||||
haga internamente lo mismo que ya hace `scripts/fea_translate_helper.php::create` en local:
|
||||
crear el post, `pll_set_post_language($id, $lang)`, y `pll_save_post_translations($grupo)`
|
||||
enlazándolo con el ES. No lo he encargado todavía — es una pieza más grande que la del audio
|
||||
(toca la lógica central de multiidioma del sitio) y prefiero que Rafa decida el momento y el
|
||||
alcance antes de que Codix la construya. Documento aquí el resto del proceso para que, en
|
||||
cuanto ese hueco se cierre, solo falte la llamada final.
|
||||
es_id=<ID del post ES>
|
||||
lang=en|fr|it|pt
|
||||
title=<título traducido>
|
||||
content=<HTML traducido>
|
||||
excerpt=<opcional>
|
||||
status=draft|publish (default draft)
|
||||
model=<opcional, solo trazabilidad — p.ej. "minimax" o "haiku-local">
|
||||
```
|
||||
|
||||
Respuesta (`201` si crea, `200` si ya existía — **es idempotente**, repetir la llamada con el
|
||||
mismo `es_id`/`lang` nunca duplica):
|
||||
|
||||
```json
|
||||
{"es_id": 56786, "lang": "en", "translation_id": 56787, "created": true,
|
||||
"url": "https://www.feadulta.com/en/?p=56787"}
|
||||
```
|
||||
|
||||
Por detrás replica exactamente lo que hacía `scripts/fea_translate_helper.php::create` en
|
||||
local: asigna el idioma antes de las categorías, mapea cada categoría del ES a su
|
||||
equivalente traducida (o la deja en español si no existe traducción de esa categoría),
|
||||
preserva el resto del grupo de traducciones si el ES ya tenía otros idiomas, y añade los
|
||||
metas de trazabilidad (`traduccion_automatica`, `traduccion_origen`, `traduccion_modelo`,
|
||||
`traduccion_fecha`).
|
||||
|
||||
Verificado por Claudix el 2026-08-31 con una prueba real de extremo a extremo en producción
|
||||
(post ES desechable → traducción EN creada → confirmado con `pll_get_post_translations` que
|
||||
el grupo quedó `{es: ..., en: ...}` — todo borrado después).
|
||||
|
||||
---
|
||||
|
||||
@@ -153,9 +168,8 @@ ritmo), parad y reintentad más tarde, no machaquéis la API.
|
||||
|
||||
## 7. Qué falta para que esto sea autoservicio de verdad
|
||||
|
||||
- **Bloqueante (§0):** endpoint o mecanismo para fijar idioma + enlazar grupo Polylang de
|
||||
cada traducción — sin esto podéis generar el texto pero no "engancharlo" al sitio como
|
||||
traducción real, igual que pasaba con el audio antes del #222.
|
||||
- Decisión pendiente de Rafa (no la resuelvo yo aquí): si vuestro agente de traducción va a
|
||||
tener acceso de shell/SSH al WordPress (como tenía Hermes) o va a ser puramente REST como
|
||||
el resto de vuestro trabajo — cambia bastante el diseño del endpoint que haría falta.
|
||||
Con el endpoint del §0 ya no queda ningún bloqueante de infraestructura conocido para crear
|
||||
y enlazar traducciones por REST. Lo que sigue siendo trabajo vuestro, no de infraestructura:
|
||||
generar el texto (motores del principio del documento), aplicar el QA del §4 antes de
|
||||
llamar al endpoint, y decidir cuándo pasáis una traducción de `draft` a `publish` (mismo
|
||||
criterio que la carta en español, ver `docs/guia-publicacion-carta-inma.md`).
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Fe Adulta — API crear traducción
|
||||
* Description: Endpoint REST idempotente para crear y enlazar traducciones Polylang.
|
||||
* Version: 1.0
|
||||
*
|
||||
* POST /wp-json/fea/v1/crear-traduccion
|
||||
* es_id=<post ES>, lang=<en|fr|it|pt>, title/content/excerpt/status/model
|
||||
*
|
||||
* Ver issue gitea.feadulta.com/rafa/feadulta#222.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
const FEA_TRANSLATION_LANGUAGES = ['en', 'fr', 'it', 'pt'];
|
||||
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_route('fea/v1', '/crear-traduccion', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => 'fea_crear_traduccion_handle',
|
||||
'permission_callback' => 'fea_crear_traduccion_can_call',
|
||||
// Se valida después de autorizar para que las llamadas anónimas
|
||||
// obtengan 401 aunque omitan parámetros.
|
||||
'args' => [
|
||||
'es_id' => [
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'lang' => [
|
||||
'sanitize_callback' => 'sanitize_key',
|
||||
],
|
||||
'status' => [
|
||||
'sanitize_callback' => 'sanitize_key',
|
||||
],
|
||||
'model' => [
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
/** Editor o superior: mismo nivel que los demás endpoints fea/v1 de escritura. */
|
||||
function fea_crear_traduccion_can_call(WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_not_authenticated',
|
||||
'Debes autenticarte para crear una traducción.',
|
||||
['status' => 401]
|
||||
);
|
||||
}
|
||||
if (!current_user_can('edit_others_posts')) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_forbidden',
|
||||
'No tienes permiso para crear traducciones.',
|
||||
['status' => 403]
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function fea_crear_traduccion_handle(WP_REST_Request $request) {
|
||||
$es_id = absint($request->get_param('es_id'));
|
||||
if (!$es_id) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_invalid_source',
|
||||
'es_id es obligatorio.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
$lang = (string) $request->get_param('lang');
|
||||
if (!in_array($lang, FEA_TRANSLATION_LANGUAGES, true)) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_invalid_language',
|
||||
'lang debe ser en, fr, it o pt.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
$status = (string) ($request->get_param('status') ?: 'draft');
|
||||
if (!in_array($status, ['draft', 'publish'], true)) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_invalid_status',
|
||||
'status debe ser draft o publish.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
$source = get_post($es_id);
|
||||
if (!$source) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_source_not_found',
|
||||
'No existe el post español indicado.',
|
||||
['status' => 404]
|
||||
);
|
||||
}
|
||||
if (!function_exists('pll_get_post') || !function_exists('pll_set_post_language') || !function_exists('pll_save_post_translations')) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_polylang_unavailable',
|
||||
'Polylang no está disponible.',
|
||||
['status' => 500]
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotencia dura: se responde antes de interpretar un payload nuevo.
|
||||
$existing_id = (int) pll_get_post($es_id, $lang);
|
||||
if ($existing_id && !get_post($existing_id)) $existing_id = 0;
|
||||
if ($existing_id) {
|
||||
return new WP_REST_Response([
|
||||
'es_id' => $es_id,
|
||||
'lang' => $lang,
|
||||
'translation_id' => $existing_id,
|
||||
'created' => false,
|
||||
'url' => get_permalink($existing_id),
|
||||
], 200);
|
||||
}
|
||||
|
||||
$title = (string) ($request->get_param('title') ?? '');
|
||||
$new_id = wp_insert_post([
|
||||
'post_title' => wp_slash($title),
|
||||
'post_content' => wp_slash((string) ($request->get_param('content') ?? '')),
|
||||
'post_excerpt' => wp_slash((string) ($request->get_param('excerpt') ?? '')),
|
||||
'post_name' => sanitize_title($title),
|
||||
'post_status' => $status,
|
||||
'post_type' => 'post',
|
||||
'post_author' => (int) $source->post_author,
|
||||
'post_date' => $source->post_date,
|
||||
'to_ping' => '',
|
||||
'pinged' => '',
|
||||
], true);
|
||||
if (is_wp_error($new_id)) {
|
||||
return new WP_Error(
|
||||
'fea_crear_traduccion_insert_failed',
|
||||
'No se pudo crear la traducción: ' . $new_id->get_error_message(),
|
||||
['status' => 500]
|
||||
);
|
||||
}
|
||||
|
||||
// El idioma se asigna antes de mapear categorías para que Polylang admita
|
||||
// los términos traducidos en el nuevo post.
|
||||
pll_set_post_language($new_id, $lang);
|
||||
|
||||
$categories = wp_get_post_categories($es_id);
|
||||
$mapped_categories = [];
|
||||
foreach ($categories as $category_id) {
|
||||
$translated_category = function_exists('pll_get_term') ? (int) pll_get_term($category_id, $lang) : 0;
|
||||
$mapped_categories[] = $translated_category ?: $category_id;
|
||||
}
|
||||
if ($mapped_categories) {
|
||||
wp_set_post_categories($new_id, array_values(array_unique($mapped_categories)));
|
||||
}
|
||||
|
||||
$translations = function_exists('pll_get_post_translations') ? pll_get_post_translations($es_id) : ['es' => $es_id];
|
||||
if (!$translations) $translations = ['es' => $es_id];
|
||||
$translations[$lang] = $new_id;
|
||||
pll_save_post_translations($translations);
|
||||
|
||||
update_post_meta($new_id, 'traduccion_automatica', '1');
|
||||
update_post_meta($new_id, 'traduccion_origen', $es_id);
|
||||
update_post_meta($new_id, 'traduccion_modelo', (string) ($request->get_param('model') ?? ''));
|
||||
update_post_meta($new_id, 'traduccion_fecha', gmdate('c'));
|
||||
|
||||
return new WP_REST_Response([
|
||||
'es_id' => $es_id,
|
||||
'lang' => $lang,
|
||||
'translation_id' => (int) $new_id,
|
||||
'created' => true,
|
||||
'url' => get_permalink($new_id),
|
||||
], 201);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Fe Adulta — API subir audio TTS
|
||||
* Description: Endpoint REST acotado para publicar el MP3 TTS de un artículo.
|
||||
* Version: 1.0
|
||||
*
|
||||
* POST /wp-json/fea/v1/subir-audio
|
||||
* multipart/form-data: post_id=<id>, audio=<MP3>, voice_id=<opcional>
|
||||
*
|
||||
* Ver issue gitea.feadulta.com/rafa/feadulta#222.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
const FEA_AUDIO_MAX_BYTES = 26214400; // 25 MiB: límite operativo de producción.
|
||||
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_route('fea/v1', '/subir-audio', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => 'fea_subir_audio_handle',
|
||||
'permission_callback' => 'fea_subir_audio_can_call',
|
||||
// La validación de post_id ocurre en el handler, después de autorizar:
|
||||
// una llamada anónima siempre recibe 401 aunque omita parámetros.
|
||||
'args' => [
|
||||
'post_id' => [
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'voice_id' => [
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
/** Editor o superior: mismo nivel que los endpoints de escritura existentes. */
|
||||
function fea_subir_audio_can_call(WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_not_authenticated',
|
||||
'Debes autenticarte para subir un audio.',
|
||||
['status' => 401]
|
||||
);
|
||||
}
|
||||
if (!current_user_can('edit_others_posts')) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_forbidden',
|
||||
'No tienes permiso para subir audios.',
|
||||
['status' => 403]
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function fea_subir_audio_handle(WP_REST_Request $request) {
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
if (!$post_id) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_invalid_post',
|
||||
'post_id es obligatorio.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
if (!get_post($post_id)) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_post_not_found',
|
||||
'No existe el artículo indicado.',
|
||||
['status' => 404]
|
||||
);
|
||||
}
|
||||
|
||||
$files = $request->get_file_params();
|
||||
$file = $files['audio'] ?? null;
|
||||
$validation = fea_subir_audio_validate_file($file);
|
||||
if (is_wp_error($validation)) return $validation;
|
||||
|
||||
$previous = [
|
||||
'url' => get_post_meta($post_id, 'fea_audio_url', true) ?: null,
|
||||
'voice' => get_post_meta($post_id, 'fea_audio_voice', true) ?: null,
|
||||
'done' => get_post_meta($post_id, 'fea_audio_done', true) ?: null,
|
||||
'sha256' => get_post_meta($post_id, 'fea_audio_sha256', true) ?: null,
|
||||
'error' => get_post_meta($post_id, 'fea_audio_error', true) ?: null,
|
||||
];
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
$upload_dir_filter = 'fea_subir_audio_upload_dir';
|
||||
add_filter('upload_dir', $upload_dir_filter);
|
||||
try {
|
||||
$uploaded = wp_handle_upload($file, [
|
||||
'test_form' => false,
|
||||
'mimes' => ['mp3' => 'audio/mpeg'],
|
||||
'unique_filename_callback' => function ($dir, $name, $ext) use ($post_id) {
|
||||
return $post_id . '.mp3';
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
remove_filter('upload_dir', $upload_dir_filter);
|
||||
}
|
||||
if (!empty($uploaded['error']) || empty($uploaded['file'])) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_upload_failed',
|
||||
'No se pudo guardar el audio: ' . ($uploaded['error'] ?? 'error desconocido'),
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
$sha256 = hash_file('sha256', $uploaded['file']);
|
||||
if ($sha256 === false) {
|
||||
wp_delete_file($uploaded['file']);
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_hash_failed',
|
||||
'No se pudo calcular la huella del audio subido.',
|
||||
['status' => 500]
|
||||
);
|
||||
}
|
||||
|
||||
// El path es estable para que los players y el proceso de backfill no dependan
|
||||
// de las carpetas por mes que normalmente crea WordPress.
|
||||
$audio_url = home_url('/wp-content/uploads/tts/' . $post_id . '.mp3');
|
||||
update_post_meta($post_id, 'fea_audio_url', $audio_url);
|
||||
update_post_meta($post_id, 'fea_audio_voice', (string) $request->get_param('voice_id'));
|
||||
update_post_meta($post_id, 'fea_audio_done', '1');
|
||||
update_post_meta($post_id, 'fea_audio_sha256', $sha256);
|
||||
delete_post_meta($post_id, 'fea_audio_error');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'post_id' => $post_id,
|
||||
'audio_url' => $audio_url,
|
||||
'voice_id' => (string) $request->get_param('voice_id'),
|
||||
'sha256' => $sha256,
|
||||
'previous_audio' => $previous,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/** Guarda el binario en uploads/tts sin cambiar globalmente la configuración. */
|
||||
function fea_subir_audio_upload_dir($uploads) {
|
||||
$uploads['subdir'] = '/tts';
|
||||
$uploads['path'] = $uploads['basedir'] . $uploads['subdir'];
|
||||
$uploads['url'] = $uploads['baseurl'] . $uploads['subdir'];
|
||||
if (!wp_mkdir_p($uploads['path'])) {
|
||||
$uploads['error'] = 'No se pudo crear el directorio de audios.';
|
||||
}
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
function fea_subir_audio_validate_file($file) {
|
||||
if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_missing_file',
|
||||
'audio es obligatorio.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
if ((int) $file['error'] !== UPLOAD_ERR_OK) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_file_error',
|
||||
'La subida de audio falló.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
if ((int) $file['size'] <= 0 || (int) $file['size'] > FEA_AUDIO_MAX_BYTES) {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_file_too_large',
|
||||
'El audio debe ocupar entre 1 byte y 25 MiB.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
$type = wp_check_filetype((string) ($file['name'] ?? ''), ['mp3' => 'audio/mpeg']);
|
||||
if (($type['ext'] ?? '') !== 'mp3' || ($type['type'] ?? '') !== 'audio/mpeg') {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_unsupported_type',
|
||||
'Solo se admiten archivos MP3 (audio/mpeg).',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
if (strtolower((string) ($file['type'] ?? '')) !== 'audio/mpeg') {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_unsupported_type',
|
||||
'audio debe declararse como audio/mpeg.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
|
||||
if ($finfo) finfo_close($finfo);
|
||||
if ($mime !== false && $mime !== 'audio/mpeg') {
|
||||
return new WP_Error(
|
||||
'fea_subir_audio_invalid_audio',
|
||||
'audio debe contener datos MPEG válidos.',
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integración local para POST /wp-json/fea/v1/crear-traduccion (#222).
|
||||
# Requiere un post ES temporal SIN traducción inglesa; el caller lo elimina
|
||||
# junto con la traducción creada al terminar.
|
||||
set -euo pipefail
|
||||
|
||||
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
|
||||
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
|
||||
: "${FEA_TEST_ES_ID:?Falta FEA_TEST_ES_ID}"
|
||||
|
||||
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/crear-traduccion"
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
assert_status() {
|
||||
local expected="$1" actual="$2" label="$3" response_file="$4"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
|
||||
cat "$response_file" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
|
||||
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/no-source.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'lang=en' "$endpoint")"
|
||||
assert_status 400 "$status" 'falta es_id' "$tmpdir/no-source.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/no-lang.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" "$endpoint")"
|
||||
assert_status 400 "$status" 'falta lang' "$tmpdir/no-lang.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/bad-lang.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=de' "$endpoint")"
|
||||
assert_status 400 "$status" 'lang no admitido' "$tmpdir/bad-lang.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/bad-status.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'status=private' "$endpoint")"
|
||||
assert_status 400 "$status" 'status no admitido' "$tmpdir/bad-status.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/no-such-source.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'es_id=999999999' -F 'lang=en' "$endpoint")"
|
||||
assert_status 404 "$status" 'post ES inexistente' "$tmpdir/no-such-source.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/created.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=Translation API test' --form-string 'content=<p>Body API test</p>' -F 'excerpt=Excerpt API test' -F 'status=draft' -F 'model=test-model' "$endpoint")"
|
||||
assert_status 201 "$status" 'creación de traducción' "$tmpdir/created.json"
|
||||
|
||||
python3 - "$tmpdir/created.json" "$FEA_TEST_ES_ID" <<'PY'
|
||||
import json, sys
|
||||
payload = json.load(open(sys.argv[1]))
|
||||
assert int(payload['es_id']) == int(sys.argv[2]), payload
|
||||
assert payload['lang'] == 'en', payload
|
||||
assert int(payload['translation_id']) > 0, payload
|
||||
assert payload['created'] is True, payload
|
||||
assert payload['url'].startswith(('http://', 'https://')), payload
|
||||
print('PASS: traducción creada', payload['translation_id'])
|
||||
PY
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/idempotent.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=No debe crear otro post' "$endpoint")"
|
||||
assert_status 200 "$status" 'idempotencia' "$tmpdir/idempotent.json"
|
||||
|
||||
python3 - "$tmpdir/created.json" "$tmpdir/idempotent.json" <<'PY'
|
||||
import json, sys
|
||||
created = json.load(open(sys.argv[1]))
|
||||
again = json.load(open(sys.argv[2]))
|
||||
assert again['translation_id'] == created['translation_id'], (created, again)
|
||||
assert again['created'] is False, again
|
||||
print('PASS: idempotencia', again['translation_id'])
|
||||
PY
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integración local para POST /wp-json/fea/v1/subir-audio (#222).
|
||||
# Requiere FEA_TEST_URL, FEA_TEST_AUTH y FEA_TEST_POST_ID (post temporal).
|
||||
set -euo pipefail
|
||||
|
||||
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
|
||||
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
|
||||
: "${FEA_TEST_POST_ID:?Falta FEA_TEST_POST_ID}"
|
||||
|
||||
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-audio"
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
# MP3 pequeño y válido (silencio MPEG-1 Layer III), sin depender de ffmpeg.
|
||||
printf '\377\373\220\144' > "$tmpdir/audio.mp3"
|
||||
dd if=/dev/zero bs=1 count=413 >> "$tmpdir/audio.mp3" 2>/dev/null
|
||||
printf 'no es un mp3' > "$tmpdir/not-audio.txt"
|
||||
# Un byte por encima del límite de aplicación de 25 MiB. Se comprueba antes
|
||||
# del tipo de fichero, por lo que puede ser disperso y no necesita ser audio.
|
||||
truncate -s 26214401 "$tmpdir/too-large.mp3"
|
||||
|
||||
assert_status() {
|
||||
local expected="$1" actual="$2" label="$3" response_file="$4"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
|
||||
cat "$response_file" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
|
||||
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/no-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")"
|
||||
assert_status 400 "$status" 'falta post_id' "$tmpdir/no-post.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/no-audio.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" "$endpoint")"
|
||||
assert_status 400 "$status" 'falta audio' "$tmpdir/no-audio.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/too-large.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/too-large.mp3;type=audio/mpeg" "$endpoint")"
|
||||
assert_status 400 "$status" 'audio por encima de 25 MiB' "$tmpdir/too-large.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/wrong-type.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/not-audio.txt;type=text/plain" "$endpoint")"
|
||||
assert_status 400 "$status" 'extensión o mime no MP3' "$tmpdir/wrong-type.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/no-such-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'post_id=999999999' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
|
||||
assert_status 404 "$status" 'post inexistente' "$tmpdir/no-such-post.json"
|
||||
|
||||
status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
|
||||
assert_status 201 "$status" 'subida válida' "$tmpdir/success.json"
|
||||
|
||||
python3 - "$tmpdir/success.json" "$FEA_TEST_POST_ID" "$tmpdir/audio.mp3" <<'PY'
|
||||
import hashlib, json, sys
|
||||
payload = json.load(open(sys.argv[1]))
|
||||
assert int(payload['post_id']) == int(sys.argv[2]), payload
|
||||
assert payload['audio_url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload
|
||||
assert payload['voice_id'] == 'NicoFeadulta2026', payload
|
||||
assert payload['sha256'] == hashlib.sha256(open(sys.argv[3], 'rb').read()).hexdigest(), payload
|
||||
assert 'previous_audio' in payload, payload
|
||||
print('PASS: audio subido', payload['audio_url'])
|
||||
PY
|
||||
|
||||
# La siguiente subida del mismo post debe exponer el estado anterior, para que
|
||||
# el cliente pueda restaurarlo si su flujo posterior falla.
|
||||
status="$(curl -sS -o "$tmpdir/replaced.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
|
||||
assert_status 201 "$status" 'reemplazo de audio' "$tmpdir/replaced.json"
|
||||
|
||||
python3 - "$tmpdir/replaced.json" "$FEA_TEST_POST_ID" <<'PY'
|
||||
import json, sys
|
||||
payload = json.load(open(sys.argv[1]))
|
||||
assert payload['previous_audio']['url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload
|
||||
assert payload['previous_audio']['done'] == '1', payload
|
||||
print('PASS: estado anterior devuelto')
|
||||
PY
|
||||
Reference in New Issue
Block a user