Redmine/ajax/template_test.php
Gemini 7bf8cdab4e feat(2.0): Fase 3 — testador de templates (preview + dry-run com tradução de 422)
- ajax/template_test.php: action=preview (render com sampleContext) e
  action=dryrun (cria+apaga objeto de teste no Redmine, traduz erros 422
  em dicas acionáveis; issue temporária para log_time/update_status)
- RedmineApi::rawRequest (código+corpo crus) e TemplateEngine::sampleContext
- UI: botão "Testar" + painel por card (preview sempre; teste real opt-in
  com seletor de projeto alvo), via fetch com X-Glpi-Csrf-Token
- validado E2E por HTTP: preview, dry-run cria/deleta (204), render inválido
  capturado, tradução de 422; sem resíduo de teste no Redmine

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:58:17 -03:00

155 lines
6.6 KiB
PHP

<?php
/**
* Testador de templates (Fase 3).
* - action=preview: renderiza o template com DADOS DE EXEMPLO e devolve o JSON final.
* - action=dryrun : teste real — cria e apaga um objeto de teste no Redmine,
* traduzindo os erros 422 em dicas acionáveis.
*
* GLPI 11: o kernel já bootou o core e validou o CSRF do XHR via header
* X-Glpi-Csrf-Token (não incluir inc/includes.php).
*/
Session::checkRight('config', UPDATE);
header('Content-Type: application/json; charset=utf-8');
function rm_out(array $data): void {
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$op = (string) ($_POST['operation'] ?? '');
$act = (string) ($_POST['action'] ?? 'preview');
if (!in_array($op, PluginRedmineTemplateengine::OPERATIONS, true)) {
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Operação inválida.']);
}
$json = (string) ($_POST['template_json'] ?? '');
if ($json !== '' && json_decode($json, true) === null && json_decode(stripslashes($json), true) !== null) {
$json = stripslashes($json);
}
if ($json === '') {
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Template vazio — gere ou edite o template antes de testar.']);
}
// ---- render com dados de exemplo (sempre) ----
$ctx = PluginRedmineTemplateengine::sampleContext($op);
try {
$payload = PluginRedmineTemplateengine::render($json, $ctx);
} catch (\Throwable $e) {
rm_out(['success' => false, 'stage' => 'render', 'message' => 'Erro no render: ' . $e->getMessage()]);
}
if ($act === 'preview') {
rm_out([
'success' => true,
'stage' => 'preview',
'payload' => $payload,
'note' => 'Renderizado com DADOS DE EXEMPLO (chamado #4321, tarefa de 1.5h...) + sua configuração real.',
]);
}
// ---- teste real (dry-run com criação + exclusão) ----
$target = (int) ($_POST['target_project'] ?? 0);
$hints = function (array $errors): array {
$out = [];
foreach ($errors as $e) {
$e = (string) $e;
if (mb_stripos($e, 'não pode ficar vazio') !== false || mb_stripos($e, "can't be blank") !== false) {
$out[] = "$e → inclua/mapeie esse campo no template (veja \"Campos que o Redmine espera\").";
} elseif (mb_stripos($e, 'não está incluso na lista') !== false || mb_stripos($e, 'not included in the list') !== false) {
$out[] = "$e → valor não aceito: confira os valores possíveis no dicionário e se está habilitado no projeto alvo.";
} elseif (mb_stripos($e, 'não é válido') !== false || mb_stripos($e, 'is invalid') !== false) {
$out[] = "$e → formato inválido (tipo errado? identificador com maiúsculas?).";
} else {
$out[] = $e;
}
}
return $out;
};
$reject = function (array $r) use ($hints, $payload) {
return [
'success' => false,
'stage' => 'dryrun',
'message' => "O Redmine RECUSOU o payload (HTTP {$r['code']}).",
'errors' => $hints($r['body']['errors'] ?? [($r['raw'] !== '' ? $r['raw'] : 'sem resposta da API')]),
'sent' => $payload,
];
};
$result = ['success' => false, 'stage' => 'dryrun', 'message' => 'Nada executado.'];
$cleanup = [];
switch ($op) {
case 'create_project':
// nome/identifier de teste para não colidir nem confundir
$payload['project']['name'] = '[TESTE TEMPLATE] ' . ($payload['project']['name'] ?? 'teste');
$payload['project']['identifier'] = 'glpi-tpl-test-' . time();
$r = PluginRedmineRedmineapi::rawRequest('POST', '/projects.json', $payload);
if ($r['code'] === 201 && isset($r['body']['project']['id'])) {
$pid = (int) $r['body']['project']['id'];
$del = PluginRedmineRedmineapi::rawRequest('DELETE', "/projects/{$pid}.json");
$result = ['success' => true, 'stage' => 'dryrun', 'sent' => $payload,
'message' => "O Redmine ACEITOU o payload ✔ (projeto de teste #{$pid} criado e apagado — DELETE {$del['code']})."];
} else {
$result = $reject($r);
}
break;
case 'create_issue':
if ($target <= 0) {
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Escolha o projeto alvo para o teste real.']);
}
$payload['issue']['project_id'] = $target;
$payload['issue']['subject'] = '[TESTE TEMPLATE] ' . ($payload['issue']['subject'] ?? 'teste');
$r = PluginRedmineRedmineapi::rawRequest('POST', '/issues.json', $payload);
if ($r['code'] === 201 && isset($r['body']['issue']['id'])) {
$iid = (int) $r['body']['issue']['id'];
$del = PluginRedmineRedmineapi::rawRequest('DELETE', "/issues/{$iid}.json");
$result = ['success' => true, 'stage' => 'dryrun', 'sent' => $payload,
'message' => "O Redmine ACEITOU o payload ✔ (issue de teste #{$iid} criada e apagada — DELETE {$del['code']})."];
} else {
$result = $reject($r);
}
break;
case 'log_time':
case 'update_status':
if ($target <= 0) {
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Escolha o projeto alvo para o teste real.']);
}
// issue temporária no projeto alvo
$tmp = PluginRedmineRedmineapi::rawRequest('POST', '/issues.json', [
'issue' => ['project_id' => $target, 'subject' => '[TESTE TEMPLATE] alvo temporário'],
]);
if ($tmp['code'] !== 201 || !isset($tmp['body']['issue']['id'])) {
$result = ['success' => false, 'stage' => 'dryrun',
'message' => "Não consegui criar a issue temporária no projeto alvo (HTTP {$tmp['code']}).",
'errors' => $hints($tmp['body']['errors'] ?? [])];
break;
}
$iid = (int) $tmp['body']['issue']['id'];
$cleanup[] = "/issues/{$iid}.json";
if ($op === 'log_time') {
$payload['time_entry']['issue_id'] = $iid;
$r = PluginRedmineRedmineapi::rawRequest('POST', '/time_entries.json', $payload);
$ok = ($r['code'] === 201);
} else {
$r = PluginRedmineRedmineapi::rawRequest('PUT', "/issues/{$iid}.json", $payload);
$ok = ($r['code'] >= 200 && $r['code'] < 300);
}
$result = $ok
? ['success' => true, 'stage' => 'dryrun', 'sent' => $payload,
'message' => "O Redmine ACEITOU o payload ✔ (testado na issue temporária #{$iid}, apagada em seguida)."]
: $reject($r);
break;
}
// limpeza garantida antes de responder
foreach ($cleanup as $path) {
PluginRedmineRedmineapi::rawRequest('DELETE', $path);
}
rm_out($result);