contentpanel - Aushangworkflow um docx Vorlage erweitern
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""Real PostgreSQL + PHP + renderer; private files and captured mail, no real recipients."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from fpdf import FPDF
|
||||
|
||||
from test_pipeline import Gateway, _GatewayHandler, make_text_pdf
|
||||
|
||||
|
||||
@unittest.skipUnless(os.getenv('CONTENTPANEL_TEST_QUEUE_DSN'), 'Requires an isolated PostgreSQL test database')
|
||||
class QueueTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temp = tempfile.TemporaryDirectory(prefix='contentpanel_queue_test_')
|
||||
cls.addClassCleanup(cls.temp.cleanup)
|
||||
cls.root = Path(cls.temp.name)
|
||||
insertion = Path('intranet_repo/projects/contentpanel/insertion')
|
||||
shutil.copytree(Path('/var/www/html') / insertion, cls.root / insertion, ignore=shutil.ignore_patterns('news'))
|
||||
cls.news = cls.root / insertion / 'news/png'
|
||||
cls.pdf = cls.root / 'input.pdf'
|
||||
cls.driver = cls.root / 'driver.php'
|
||||
cls.driver.write_text('''<?php
|
||||
require __DIR__ . '/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php';
|
||||
function send_intranet_mail($recipient, $subject, $message): bool {
|
||||
if (file_exists(__DIR__ . '/mail-throw')) throw new RuntimeException('SMTP unavailable');
|
||||
$sent = !file_exists(__DIR__ . '/mail-fail');
|
||||
file_put_contents(__DIR__ . '/mail.jsonl', json_encode([$recipient, $subject, $message, $sent]) . "\\n", FILE_APPEND);
|
||||
return $sent;
|
||||
}
|
||||
$pdo = new PDO(getenv('CONTENTPANEL_TEST_QUEUE_DSN'), 'postgres', 'queue-test-only',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||||
contentpanel_queue_schema($pdo);
|
||||
switch ($argv[1]) {
|
||||
case 'setup':
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS admin_messages (id BIGSERIAL PRIMARY KEY,
|
||||
type TEXT, sender_name TEXT, sender_email TEXT, subject TEXT, message TEXT,
|
||||
status TEXT CHECK (status IN ('pending', 'done')), created_at TIMESTAMPTZ, processed_at TIMESTAMPTZ)");
|
||||
$pdo->exec('TRUNCATE contentpanel_jobs, admin_messages RESTART IDENTITY');
|
||||
break;
|
||||
case 'enqueue':
|
||||
echo json_encode(contentpanel_enqueue_news($pdo, ['mail' => 'owner@example.test', 'first_name' => 'Test'],
|
||||
__DIR__ . '/input.pdf', ['base' => '2030-12-31-' . $argv[2], 'title' => $argv[2],
|
||||
'date' => '2026-09-02', 'mode' => 'template']));
|
||||
break;
|
||||
case 'work': echo json_encode(contentpanel_queue_once($pdo)); break;
|
||||
case 'query': echo json_encode($pdo->query($argv[2])->fetchAll()); break;
|
||||
}
|
||||
''', encoding='utf-8')
|
||||
|
||||
def php(self, *args, timeout=60):
|
||||
result = subprocess.run(['php', str(self.driver), *args], capture_output=True, text=True, timeout=timeout)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads(result.stdout) if result.stdout else None
|
||||
|
||||
def query(self, sql):
|
||||
return self.php('query', sql)
|
||||
|
||||
def setUp(self):
|
||||
self.php('setup')
|
||||
for name in ('mail.jsonl', 'mail-fail', 'mail-throw'):
|
||||
(self.root / name).unlink(missing_ok=True)
|
||||
if self.news.exists():
|
||||
shutil.rmtree(self.news)
|
||||
make_text_pdf(self.pdf)
|
||||
self.gateway = Gateway()
|
||||
self.gateway.__enter__()
|
||||
self.addCleanup(self.gateway.__exit__, None, None, None)
|
||||
|
||||
def messages(self):
|
||||
return [json.loads(line) for line in (self.root / 'mail.jsonl').read_text().splitlines()]
|
||||
|
||||
def test_success_survives_upload_file_removal_and_logs_and_mails(self):
|
||||
self.assertIn('Hintergrund', self.php('enqueue', 'Erfolg'))
|
||||
self.pdf.unlink() # HTTP temporary files disappear when the upload response ends.
|
||||
self.assertTrue(self.php('work'))
|
||||
rows = self.query('SELECT pdf IS NULL AS cleared, result, mailed_at IS NOT NULL AS mailed FROM contentpanel_jobs')
|
||||
self.assertTrue(rows[0]['cleared'])
|
||||
self.assertTrue(rows[0]['mailed'])
|
||||
self.assertTrue(json.loads(rows[0]['result'])['ok'])
|
||||
self.assertEqual(self.messages()[0][0], 'owner@example.test')
|
||||
self.assertIn('veröffentlicht', self.messages()[0][1])
|
||||
inbox = self.query('SELECT * FROM admin_messages')
|
||||
self.assertEqual(len(inbox), 1)
|
||||
self.assertEqual(inbox[0]['type'], 'system')
|
||||
self.assertIn('Mailserver übergeben', inbox[0]['message'])
|
||||
self.assertTrue((self.news / '2030-12-31-Erfolg_page_1.png').is_file())
|
||||
self.assertFalse(list(self.news.glob('.upload-*')))
|
||||
|
||||
def test_failure_also_logs_and_mails_without_publishing(self):
|
||||
self.pdf.write_bytes(b'%PDF-broken')
|
||||
self.php('enqueue', 'Fehler')
|
||||
self.assertTrue(self.php('work'))
|
||||
row = self.query('SELECT result, pdf IS NULL AS cleared FROM contentpanel_jobs')[0]
|
||||
self.assertFalse(json.loads(row['result'])['ok'])
|
||||
self.assertTrue(row['cleared'])
|
||||
self.assertIn('fehlgeschlagen', self.messages()[0][1])
|
||||
self.assertNotIn('/opt/', self.messages()[0][2])
|
||||
self.assertEqual(len(self.query('SELECT * FROM admin_messages')), 1)
|
||||
self.assertFalse(list(self.news.glob('*.png')))
|
||||
|
||||
def test_mail_retry_does_not_render_or_log_the_job_again(self):
|
||||
(self.root / 'mail-fail').touch()
|
||||
self.php('enqueue', 'MailRetry')
|
||||
self.php('work')
|
||||
row = self.query('SELECT mailed_at FROM contentpanel_jobs')[0]
|
||||
self.assertIsNone(row['mailed_at'])
|
||||
self.assertIn('Versand fehlgeschlagen', self.query('SELECT message FROM admin_messages')[0]['message'])
|
||||
calls = len(_GatewayHandler.calls)
|
||||
(self.root / 'mail-fail').unlink()
|
||||
self.query("UPDATE contentpanel_jobs SET retry_at = NOW() - INTERVAL '1 second' RETURNING id")
|
||||
self.assertFalse(self.php('work'))
|
||||
self.assertEqual(len(_GatewayHandler.calls), calls)
|
||||
self.assertEqual(len(self.query('SELECT * FROM admin_messages')), 1)
|
||||
self.assertEqual([mail[3] for mail in self.messages()], [False, True])
|
||||
self.assertTrue(self.query('SELECT mailed_at IS NOT NULL AS sent FROM contentpanel_jobs')[0]['sent'])
|
||||
|
||||
def test_conversion_failure_publishes_all_original_pages(self):
|
||||
document = FPDF()
|
||||
for text in ('Originalanfang', 'Originalende'):
|
||||
document.add_page()
|
||||
document.set_font('Helvetica', size=18)
|
||||
document.multi_cell(0, 10, text + '. Bitte bewahrt die urspruengliche Gestaltung dieses Aushangs vollstaendig.')
|
||||
document.output(str(self.pdf))
|
||||
self.php('enqueue', 'OriginalFallback')
|
||||
with patch.object(_GatewayHandler, 'response_content', {'title': 'Erfundener Termin 2099', 'paragraphs': ['Einladung.']}):
|
||||
self.assertTrue(self.php('work'))
|
||||
result = json.loads(self.query('SELECT result FROM contentpanel_jobs')[0]['result'])
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertTrue(result['fallback'])
|
||||
pages = sorted(self.news.glob('*OriginalFallback_page_*.png'))
|
||||
self.assertEqual(len(pages), 2)
|
||||
text = subprocess.run(['tesseract', str(pages[-1]), 'stdout', '-l', 'deu'],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
self.assertIn('Originalende', text)
|
||||
self.assertIn('Original-PDF', self.messages()[0][2])
|
||||
self.assertIn('Original-PDF', self.query('SELECT message FROM admin_messages')[0]['message'])
|
||||
|
||||
def test_mail_exception_does_not_block_the_next_job(self):
|
||||
(self.root / 'mail-throw').touch()
|
||||
self.php('enqueue', 'MailAusfall')
|
||||
self.assertTrue(self.php('work'))
|
||||
(self.root / 'mail-throw').unlink()
|
||||
self.query("UPDATE contentpanel_jobs SET retry_at = NOW() - INTERVAL '1 second' RETURNING id")
|
||||
self.php('enqueue', 'Weiter')
|
||||
self.assertTrue(self.php('work'))
|
||||
self.assertEqual(len(self.messages()), 2)
|
||||
self.assertTrue(all(row['sent'] for row in self.query('SELECT mailed_at IS NOT NULL AS sent FROM contentpanel_jobs')))
|
||||
|
||||
def blocked_worker(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
original = _GatewayHandler.do_POST
|
||||
def delayed(handler):
|
||||
entered.set()
|
||||
release.wait(20)
|
||||
try:
|
||||
original(handler)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
context = patch.object(_GatewayHandler, 'do_POST', delayed)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
self.addCleanup(release.set)
|
||||
process = subprocess.Popen(['php', str(self.driver), 'work'], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, start_new_session=True)
|
||||
def cleanup():
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.communicate(timeout=5)
|
||||
self.addCleanup(cleanup)
|
||||
self.assertTrue(entered.wait(10), 'Worker did not reach the gateway')
|
||||
return process, release
|
||||
|
||||
def test_global_lock_keeps_workers_serial_while_uploads_continue(self):
|
||||
self.php('enqueue', 'Zuerst')
|
||||
process, release = self.blocked_worker()
|
||||
start = time.monotonic()
|
||||
self.assertFalse(self.php('work', timeout=3))
|
||||
self.php('enqueue', 'Danach', timeout=3)
|
||||
self.assertLess(time.monotonic() - start, 3)
|
||||
self.assertEqual(self.query('SELECT attempts FROM contentpanel_jobs ORDER BY id'), [{'attempts': 1}, {'attempts': 0}])
|
||||
release.set()
|
||||
stdout, stderr = process.communicate(timeout=30)
|
||||
self.assertEqual(process.returncode, 0, stderr)
|
||||
self.assertEqual(stdout, 'true')
|
||||
self.assertTrue(self.php('work'))
|
||||
subjects = [row['subject'] for row in self.query('SELECT subject FROM admin_messages ORDER BY id')]
|
||||
self.assertTrue(subjects[0].endswith('Zuerst'))
|
||||
self.assertTrue(subjects[1].endswith('Danach'))
|
||||
|
||||
def test_killed_worker_is_recovered_without_duplicate_completion(self):
|
||||
self.php('enqueue', 'Neustart')
|
||||
process, release = self.blocked_worker()
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.communicate(timeout=5)
|
||||
release.set()
|
||||
self.assertIsNone(self.query('SELECT result FROM contentpanel_jobs')[0]['result'])
|
||||
self.assertTrue(self.php('work'))
|
||||
self.assertEqual(self.query('SELECT attempts FROM contentpanel_jobs')[0]['attempts'], 2)
|
||||
self.assertEqual(len(self.query('SELECT * FROM admin_messages')), 1)
|
||||
self.assertEqual(len(self.messages()), 1)
|
||||
self.assertFalse(list(self.news.glob('.upload-*')))
|
||||
|
||||
def test_repeated_interruptions_use_original_and_expired_jobs_fail_without_llm(self):
|
||||
for change in ("attempts = 2", "payload = jsonb_set(payload, '{base}', '\"2000-01-01-Abgelaufen\"')"):
|
||||
self.php('enqueue', 'Abbruch')
|
||||
self.query('UPDATE contentpanel_jobs SET ' + change + ' WHERE result IS NULL RETURNING id')
|
||||
self.assertTrue(self.php('work'))
|
||||
self.assertEqual(_GatewayHandler.calls, [])
|
||||
self.assertEqual(len(self.messages()), 2)
|
||||
self.assertIn('Original-PDF', self.messages()[0][2])
|
||||
self.assertIn('fehlgeschlagen', self.messages()[1][1])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user