Merge pull request #406 from f3rs3n/fix/notification-outcome-reporting

Clarify backup outcomes and diagnostic presentation
This commit is contained in:
MacRimi
2026-10-01 21:23:01 +02:00
committed by GitHub
25 changed files with 1955 additions and 105 deletions
@@ -0,0 +1,88 @@
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
import datetime
import sqlite3
import tempfile
import threading
import time
import types
import typing
from html.parser import HTMLParser
from pathlib import Path
from notification_fixture import templates, SCRIPTS, EmailChannel, extract
def visible(markup):
class Text(HTMLParser):
def __init__(self): super().__init__(); self.parts = []; self.tags = []
def handle_data(self, data): self.parts.append(data)
def handle_starttag(self, tag, attrs): self.tags.append(tag)
parser = Text(); parser.feed(markup)
return '\n'.join(p.strip() for p in parser.parts if p.strip()), parser.tags
def restore_event(warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname':'node-a', 'guests':'3', 'stubs':'1', 'stale_nodes':'0',
'components':'2', 'duration':'2m', 'warnings':warnings}),
'notification_manager':types.SimpleNamespace(emit_event=lambda **kw:events.append(kw)),
'jsonify':lambda value:value}
handler = extract(SCRIPTS/'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
assert status == 200 and len(events) == 1
return events[0]
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
captured = []
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
def sink(title, body, severity, data):
markup = channel._format_html(title, body, severity, data)
text, tags = visible(markup)
captured.append(dict(title=title, body=body, severity=severity, data=dict(data), html=markup, text=text, tags=tags))
return {'success':True}
manager = types.SimpleNamespace(_config={'email.rich_format':'true'}, _lock=threading.RLock(),
_channels={'email':types.SimpleNamespace(send=sink)},
_group_limiter=types.SimpleNamespace(allow=lambda group:True),
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
_record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0},
is_event_enabled=lambda event:True)
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
resolve_notification_hostname=lambda host, config:host or 'node-a',
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
_should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'}))
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
db = Path(scratch)/'pending.sqlite'
rows = []
if quiet:
conn = sqlite3.connect(db)
conn.execute('CREATE TABLE quiet_pending (id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)')
conn.commit(); conn.close()
qns = dict(vars(typing), sqlite3=sqlite3, DB_PATH=db, time=time, datetime=datetime.datetime,
_resolve_display_hostname=lambda config:'node-a', runtime_message=templates.runtime_message,
EVENT_EMOJI=templates.EVENT_EMOJI, CATEGORY_EMOJI=templates.CATEGORY_EMOJI)
for name in ('_buffer_quiet_event', '_flush_quiet_for_channel', '_compose_digest_body'):
setattr(manager,name,types.MethodType(extract(SCRIPTS/'notification_manager.py',name,'NotificationManager',qns),manager))
for earlier in quiet_before:
manager._dispatch_event(types.SimpleNamespace(**earlier,source='inert',entity_type='node',entity_id='',event_id='inert',fingerprint='inert'))
if manual:
assert not quiet
result = manager.send_notification(event_type, severity, '', '', dict(data))
assert result['success']
else:
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
manager._dispatch_event(event)
if quiet:
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
assert len(rows)==1+len(quiet_before) and not captured
manager._flush_quiet_for_channel('email', manager._channels['email'])
conn=sqlite3.connect(db); remaining=conn.execute('SELECT count(*) FROM quiet_pending').fetchone()[0]; conn.close()
assert remaining==0
assert len(captured)==1
if quiet: captured[0]['buffered']=rows
return captured[0]
@@ -0,0 +1,51 @@
"""Assertion-free inert actual consumers; no operational host imports."""
import ast
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
# Display-name resolution is an infrastructure boundary, never load manager.
templates._get_hostname = lambda: 'node-a'
from notification_channels import EmailChannel
LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')
def extract(path, name, owner, ns):
tree = ast.parse(path.read_text())
nodes = tree.body if owner is None else next(n.body for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def receive(message, severity='info', title='Backup', kind='vzdump'):
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
methods = {name: extract(SCRIPTS / 'notification_events.py', name, 'ProxmoxHookWatcher', ns)
for name in ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')}
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = methods['_classify_pve']
_map_severity = staticmethod(methods['_map_severity'])
_backup_outcome = staticmethod(methods['_backup_outcome'])
process_webhook = methods['process_webhook']
def __init__(self): self._queue = Queue()
target = Receiver()
result = target.process_webhook({'fields': {'type': kind}, 'severity': severity, 'title': title, 'message': message})
assert result['accepted'] and len(target._queue.items) == 1
return target._queue.items[0]
def email(event_type, data, severity='INFO', language='en'):
result = templates.render_template(event_type, data, language)
channel = object.__new__(EmailChannel)
channel.subject_prefix = '[ProxMenux]'
context = {**data, 'severity': severity, '_event_type': event_type, '_group': result['group'], '_notification_language': language}
return result, channel._format_html(result['title'], result['body'], severity, context)
@@ -130,6 +130,18 @@ class CommandDescriptionsTests(unittest.TestCase):
for key in ("temperatureAlertTitle", "temperatureAlertBody",
"recordedReason", "recordedDetails"):
fallback.setdefault(key, source_fallback[key])
# Slovak remains the exact upstream catalog; model the
# pending outcome-key generator additions in disposable
# copies rather than modifying its curated values.
if lang == 'sk':
local = temporary['runtime']['notifications']
source = catalog('en')['runtime']['notifications']
for key, value in source['backup'].items():
local.setdefault('backup', {}).setdefault(key, value)
local['channels']['email']['status'].setdefault(
'unconfirmed', source['channels']['email']['status']['unconfirmed'])
local['channels']['email']['status'].setdefault(
'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings'])
path.write_text(json.dumps(temporary, ensure_ascii=False))
# Model steady state after the bot fills these intentional new
# messages; keep repository locales and all other leaves intact.
@@ -0,0 +1,116 @@
"""Backup-only maintainer contract at actual render/dispatch/email seams.
Scope-approved seams: receiver, template lookup, rich enrichment, inert queued
and manual delivery with an email capture sink. No host operations or sends.
"""
import unittest
from notification_fixture import templates, LANGUAGES
from notification_final_fixture import deliver
class BackupSplitTests(unittest.TestCase):
def test_recovery_default_is_upstream_resolved_without_proof(self):
for language in LANGUAGES:
data = {'hostname': 'alias', 'category': 'temperature',
'reason': 'Temperature high (recovered)', 'duration': '2m',
'original_severity': 'WARNING'}
rendered = templates.render_template('error_resolved', data, language)
expected = templates.runtime_message('templates.error_resolved.title', language,
hostname='alias', category='temperature', entity_suffix='')
self.assertEqual(rendered['title'], expected)
rich, _ = templates.enrich_with_emojis('error_resolved', rendered['title'], rendered['body'], data)
self.assertTrue(rich.startswith('✅ '))
result = deliver('error_resolved', data, 'OK', language)
self.assertIn('background:#f0fdf4;', result['html'])
def test_pre_guest_native_subject_keeps_only_cause_once_with_display_alias(self):
from notification_fixture import receive
raw_host = 'pve-production.internal.example'
message = ('Details\n=======\nVMID Name Status Time Size Filename\n'
'\nTotal running time: 0s\nTotal size: 0 B')
for raw in (message, 'ERROR: unable to activate storage PBS\n' + message):
event = receive(raw, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: unable to activate storage PBS')
event.data['hostname'] = 'display-alias {rack.location}'
for language in LANGUAGES:
for manual in (False, True):
result = deliver(event.event_type, event.data, event.severity, language, manual=manual)
self.assertNotIn(raw_host, result['text'])
self.assertNotIn('vzdump backup status', result['text'])
self.assertEqual(result['text'].count('unable to activate storage PBS'), 1)
self.assertIn('display-alias {rack.location}', result['title'])
self.assertEqual(result['data']['pve_title'], event.data['pve_title'])
self.assertEqual(result['data']['pve_message'], raw)
# No global hostname or guest-name substitution: actual diagnostics are
# authoritative, even when they happen to contain the original hostname.
event = receive(message, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: cannot connect to ' + raw_host)
result = deliver(event.event_type, {**event.data, 'hostname': 'alias'}, event.severity)
self.assertEqual(result['body'].count('cannot connect to ' + raw_host), 1)
self.assertNotIn('vzdump backup status', result['body'])
def test_backup_legacy_keys_keep_meaning_and_unknown_uses_new_keys(self):
# Frozen upstream contract; no Git history required in shipped tests.
legacy = {'en': {'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})', 'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}', 'label': 'Backup complete'}, 'de': {'title': '{hostname} → {storage}: Sicherung abgeschlossen – {vmname} ({vmid})', 'body': 'Die Sicherung von {vmname} (ID: {vmid}) wurde am {storage} erfolgreich abgeschlossen.\nGröße: {size}', 'label': 'Sicherung abgeschlossen'}, 'es': {'title': '{hostname} → {storage}: Backup completado — {vmname} ({vmid})', 'body': 'El backup de {vmname} (ID: {vmid}) se ha completado correctamente en {storage}.\nTamaño: {size}', 'label': 'Backup completado'}, 'fr': {'title': '{hostname} → {storage}\xa0: Sauvegarde terminée — {vmname} ({vmid})', 'body': "La sauvegarde de {vmname} (ID\xa0: {vmid}) s'est terminée avec succès le {storage}.\nTaille\xa0: {size}", 'label': 'Sauvegarde terminée'}, 'it': {'title': '{hostname} → {storage}: Backup completato — {vmname} ({vmid})', 'body': 'Backup di {vmname} (ID: {vmid}) completato con successo su {storage}.\nTaglia: {size}', 'label': 'Backup completato'}, 'pt': {'title': '{hostname} → {storage}: Backup concluído — {vmname} ({vmid})', 'body': 'Backup de {vmname} (ID: {vmid}) concluído com sucesso em {storage}.\nTamanho: {size}', 'label': 'Backup concluído'}, 'sk': {'title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', 'body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', 'label': 'Záloha bola dokončená'}, 'sv': {'title': '{hostname} → {storage}: Säkerhetskopiering klar — {vmname} ({vmid})', 'body': 'Säkerhetskopiering av {vmname} (ID: {vmid}) slutfördes framgångsrikt på {storage}.\nStorlek: {size}', 'label': 'Säkerhetskopieringen är klar'}}
for language, expected in legacy.items():
self.assertEqual(templates._load_runtime_catalog(language)['templates']['backup_complete'], expected)
result = templates.render_template('backup_complete', {'hostname': 'alias {rack.location}'}, language)
self.assertIn('alias {rack.location}', result['title'])
self.assertNotIn('()', result['title'])
new_title = templates.runtime_message('backup.unconfirmedTitle', language, hostname='alias {rack.location}')
self.assertTrue(new_title)
self.assertEqual(result['title'], new_title)
self.assertIn(templates.runtime_message('backup.unconfirmedBody', language), result['body'])
# Absent/blank/non-string translation uses English per-key fallback.
from unittest.mock import patch
english = templates._load_runtime_catalog('en')
for value in (None, '', {}, []):
missing = {'backup': {'unconfirmedTitle': value, 'unconfirmedBody': value}}
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else missing):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
# Both catalogs missing: new outcome text still has explicit EN defaults.
with patch.object(templates, '_load_runtime_catalog', return_value={}):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
def test_restore_keeps_original_ready_line_and_success_icon_with_warnings(self):
from notification_final_fixture import restore_event
ready = {'en': 'The node is now fully ready to use.', 'de': 'Der Knoten ist nun vollständig einsatzbereit.', 'es': 'El nodo está listo para usarse.', 'fr': 'Le nœud est maintenant entièrement prêt à être utilisé.', 'it': "Il nodo è ora completamente pronto per l'uso.", 'pt': 'O nó agora está totalmente pronto para uso.', 'sk': 'Uzol je teraz úplne pripravený na použitie.', 'sv': 'Noden är nu helt redo att användas.'}
for warning in ('', 'missing module zfs'):
event = restore_event(warning)
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language)
self.assertTrue(result['title'].startswith('✅ '))
self.assertIn(ready[language], result['body'])
self.assertIn(ready[language], result['text'])
self.assertIn('2m', result['text'])
if warning:
self.assertIn(warning, result['text'])
quiet = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertIn('✅', quiet['body'])
self.assertIn(' ' + ready[language], quiet['body'])
self.assertIn('white-space:pre-wrap;', quiet['html'])
def test_spanish_outcome_and_restore_titles_are_capitalized_and_failure_is_exact(self):
expected = {'confirmed': 'Backup completado', 'completed_with_warnings': 'Backup completado con advertencias',
'unconfirmed': 'Resultado del backup sin confirmar', 'failed': 'Backup fallido'}
for outcome, title in expected.items():
result = templates.render_template('backup_complete', {'hostname': 'alias', 'backup_outcome': outcome}, 'es')
self.assertEqual(result['title'], 'alias: ' + title)
failure = templates.render_template('backup_fail', {'hostname': 'alias'}, 'es')
self.assertEqual(failure['title'], 'alias: Backup fallido')
restore = templates.render_template('system_restore_completed', {'hostname': 'alias'}, 'es')
self.assertEqual(restore['title'], 'alias: Restauración del host finalizada')
def test_recovery_only_quiet_release_keeps_upstream_summary_contract(self):
result = deliver('error_resolved', {'hostname': 'alias', 'category': 'temperature',
'reason': 'old (recovered)', 'duration': '2m'}, 'OK', quiet=True)
self.assertIn(templates.runtime_message('digest.lead', 'en', count=1).strip(), result['body'])
self.assertIn(templates.runtime_message('digest.footer', 'en'), result['body'])
self.assertIn('✅', result['body'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,204 @@
"""Whole-PR outcome corrections, inert producer/actual email consumers."""
import html
import unittest
from notification_fixture import templates, receive, email, LANGUAGES
REPORT = """Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
"""
class CorrectionTests(unittest.TestCase):
def test_reversed_finish_is_not_completion_evidence(self):
event = receive('INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Starting Backup of VM 100 (qemu)')
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
def test_interleaved_complete_logs_keep_both_finished_guests(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: web\n'
'INFO: Starting Backup of VM 101 (lxc)\nINFO: CT Name: db\n'
'INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Finished Backup of VM 101 (00:01:02)')
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('✅ VM web (100)', result['body'])
self.assertIn('✅ CT db (101)', result['body'])
self.assertNotIn('❔', result['body'])
self.assertIn('00:01:01', result['body'])
def test_backup_identity_has_event_scoped_mail_compatible_wrapping(self):
result, markup = email('backup_complete', {'hostname': 'n' * 64,
'backup_outcome': 'confirmed', 'pve_message': 'INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: customerproductionpostgresqlreplicaeuropewestdatacenter01\nINFO: Finished Backup of VM 100 (00:01:01)'})
self.assertIn('table-layout:fixed;', markup)
title_tag = markup.split('<h2 style="', 1)[1].split('"', 1)[0]
self.assertIn('overflow-wrap:break-word;', title_tag)
self.assertIn('word-wrap:break-word;', title_tag)
_, unrelated = email('node_reconnect', {'hostname': 'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated)
def test_catalog_parity_accepts_eventual_valid_slovak_keys(self):
from notification_fixture import extract, SCRIPTS
import copy
import string
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
ns = {'string': string}
ns['_placeholders'] = extract(path, '_placeholders', None, ns)
parity = extract(path, 'test_runtime_catalog_keys_and_placeholders_match', 'RuntimeCatalogTests', ns)
probe = unittest.TestCase()
probe.catalogs = {lang: copy.deepcopy(templates._load_runtime_catalog(lang)) for lang in LANGUAGES}
parity(probe) # shipped missing keys remain allowed
probe.catalogs['sk']['backup'] = copy.deepcopy(probe.catalogs['en']['backup'])
probe.catalogs['sk']['channels']['email']['status']['unconfirmed'] = probe.catalogs['en']['channels']['email']['status']['unconfirmed']
parity(probe) # generation of exactly the pending keys is legal
probe.catalogs['sk']['backup']['confirmedTitle'] = 'Missing hostname token'
with self.assertRaises(AssertionError): parity(probe)
def test_actual_neutral_style_is_not_success_green(self):
for event, severity, data in (('backup_complete', 'INFO', {'backup_outcome': 'unconfirmed'}),):
result, markup = email(event, data, severity)
self.assertIn('background:#f9fafb;', markup)
self.assertNotIn('background:#f0fdf4;', markup)
def test_actual_restore_endpoint_warnings_and_counts_reach_email(self):
from notification_fixture import extract, SCRIPTS
from types import SimpleNamespace
for warnings in ('', 'missing module zfs'):
events = []
ns = {'request': SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '3', 'stubs': '1', 'stale_nodes': '0',
'components': '2', 'duration': '2m', 'warnings': warnings}),
'notification_manager': SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda value: value}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
self.assertEqual(status, 200)
event = events[0]
for language in LANGUAGES:
result, markup = email(event['event_type'], event['data'], event['severity'], language)
self.assertIn('2m', markup)
for line in result['body'].splitlines():
if line.strip(): self.assertIn(line.strip(), html.unescape(markup))
if warnings: self.assertIn(warnings, markup)
def test_raw_display_hostname_is_substituted_exactly_once(self):
for event_type, outcome in (('backup_complete', 'confirmed'), ('backup_complete', 'failed'), ('backup_fail', 'failed')):
for hostname in ('Sala {rack} – Zürich', 'node-{vmid}', 'Sala {rack.location}'):
for language in LANGUAGES:
result, markup = email(event_type, {'hostname': hostname,
'backup_outcome': outcome, 'pve_message': REPORT}, language=language)
self.assertIn(hostname, result['title'])
self.assertIn(hostname, html.unescape(markup))
def test_malformed_numeric_report_is_queued_uncertain_and_renderable(self):
for size in ('1..5 GiB', '..5 GiB'):
message = REPORT.replace('1 GiB ', size.ljust(9)).split('Total size:')[0]
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(size, result['body'])
def test_unknown_backup_type_keeps_explicit_err_but_cannot_certify_ok(self):
event = receive(REPORT.replace('ok ', 'err '), kind='')
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.severity, 'INFO')
self.assertEqual(event.data['backup_outcome'], 'failed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('FAILED', markup)
self.assertEqual(receive(REPORT, kind='').data['backup_outcome'], 'unconfirmed')
def test_confirmed_metadata_only_context_is_retained(self):
data = {'hostname': 'node-a', 'backup_outcome': 'confirmed',
'vmid': '100', 'vmname': 'web {literal}', 'storage': 'PBS', 'size': '1 GiB'}
for language in LANGUAGES:
result, markup = email('backup_complete', data, language=language)
self.assertIn('web {literal} (100)', result['title'])
self.assertIn('1 GiB', result['body'])
self.assertIn('1 GiB', markup)
def test_explicit_guest_failure_overrides_only_the_linked_ok_row(self):
other = '101 db ok 1m 1s 1 GiB ct/101/2026-09-29T17:00:00Z'
message = REPORT.replace('\n\nTotal', '\n' + other + '\n\nTotal')
diagnostic = '100: 2026-09-29 17:00:00 ERROR: Backup of VM 100 failed - archive write failed'
for severity in ('info', 'error'):
event = receive(message + '\n' + diagnostic, severity)
self.assertEqual(event.data.get('vmid'), '100')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn('❌ VM web (100)', result['body'])
self.assertNotIn('✅ VM web (100)', markup)
self.assertIn('✅ CT db (101)', result['body'])
self.assertIn('web (100)', result['title'])
def test_official_week_month_year_durations_stay_confirmed(self):
for duration in ('1w', '1w 1m 1s', '1M', '1y'):
# Fixed-column widths are unchanged for these bounded values.
message = REPORT.replace('1m 1s ', duration.ljust(9))
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed', duration)
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(duration, result['body'])
def test_prefixed_error_diagnostics_survive_both_source_severities(self):
diagnostic = '100: 2026-09-29 17:00:00 ERROR: archive write failed: permission denied'
for severity in ('info', 'error'):
event = receive(REPORT + '\n' + diagnostic, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(diagnostic, result['body'])
self.assertIn(diagnostic, html.unescape(markup))
# A job-level error must not invent a failed guest.
self.assertIn('✅ VM web (100)', result['body'])
def test_official_completed_report_warning_is_distinct_and_retained(self):
warning = '100: 2026-09-29 17:00:00 WARN: unable to add notes - permission denied'
event = receive(REPORT + '\n' + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(warning, result['body'])
self.assertIn(warning, html.unescape(markup))
def test_batch_and_abort_failure_titles_have_no_empty_guest_slot(self):
failed = REPORT.replace('ok ', 'err ')
failed = failed.replace('\n\nTotal', '\n101 db err 1m 1s 0 B null\n\nTotal')
for message in (failed, REPORT.replace('ok ', 'todo '),
REPORT.split('100 web')[0] + '\nTotal running time: 0s'):
event = receive(message + '\nINFO: vzdump --storage PBS', 'error')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertNotIn('()', result['title'])
self.assertIn('PBS', result['title'])
self.assertNotIn('web (100)', result['title'])
def test_subject_only_setup_failure_survives_actual_email(self):
reason = 'unable to activate storage PBS'
title = 'vzdump backup status (node-a): backup failed: ' + reason
message = REPORT.split('100 web')[0] + '\nTotal running time: 0s\nTotal size: 0 B'
event = receive(message, 'error', title)
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(reason, result['body'])
self.assertIn(reason, html.unescape(markup))
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,136 @@
"""Final review contracts at actual locale/manual/queued/quiet email seams.
All operational dependencies are inert; no manager or route module import.
"""
import ast
import copy
import html
import unittest
from unittest.mock import patch
from notification_fixture import templates, SCRIPTS, LANGUAGES, receive
from notification_final_fixture import deliver, restore_event
# Literal native send_notification body, produced by pinned PVE Perl helpers.
NATIVE_REPORT = 'Details\n=======\n' + '''VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: no log available
'''
class FinalCorrectionsTests(unittest.TestCase):
def test_runtime_backup_assertions_accept_generated_and_missing_slovak_title(self):
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
tree = ast.parse(path.read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'RuntimeCatalogTests')
method = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == 'test_special_formatters_digest_and_test_message_are_slovak')
start = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='backup' for t in n.targets))
stop = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='manager' for t in n.targets))
block = ast.Module(body=method.body[start:stop], type_ignores=[])
english = templates._load_runtime_catalog('en')
for title in (None, '{hostname}: GENERATED_SK potvrdené'):
sk = copy.deepcopy(templates._load_runtime_catalog('sk'))
if title is not None: sk.setdefault('backup', {})['confirmedTitle'] = title
else: sk.get('backup', {}).pop('confirmedTitle', None)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else sk):
exec(compile(block,str(path),'exec'), {'self':self, 'notification_templates':templates})
def test_native_multiline_failure_diagnostics_survive_receiver_dispatch_once(self):
subject = 'vzdump backup status (node-a): backup failed: multiple problems'
for diagnostic, report in (
('external provider job cleanup failed\njob-abort hook permission denied', NATIVE_REPORT),
('job interrupted\njob-abort hook permission denied', NATIVE_REPORT.replace('ok ', 'todo ')),
('unable to initialize external provider\njob-abort hook permission denied', NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z\n', '')),
):
event = receive(diagnostic + '\n' + report, 'error', subject)
self.assertEqual((event.event_type, event.severity), ('backup_fail', 'CRITICAL'))
for language in LANGUAGES:
result = deliver(event.event_type, event.data, event.severity, language)
for line in diagnostic.splitlines():
self.assertEqual(result['body'].count(line), 1)
self.assertEqual(result['text'].count(line), 1)
if 'ok ' in report:
self.assertIn('✅ VM web (100)', result['body'])
def test_restore_endpoint_quiet_release_keeps_warning_counts_and_truthful_footer(self):
reason = 'missing module zfs <raw> & {literal}'
event = restore_event(reason)
self.assertEqual(event['severity'], 'WARNING')
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertEqual(result['severity'], 'INFO')
buffered_body = result['buffered'][0][3]
self.assertEqual(result['text'].count(reason), 1)
for line in buffered_body.splitlines():
if line.strip(): self.assertIn(line.strip(), result['text'])
self.assertIn('2m', result['text'])
self.assertNotIn(templates.runtime_message('digest.footer', language), result['body'])
self.assertNotIn('script', result['tags'])
def test_raw_restore_cells_use_event_scoped_mail_wrapping(self):
token = 'b' * 64
event = restore_event('Boot check: recorded token ' + token + '; verification pending')
for language in LANGUAGES:
results = [deliver(event['event_type'],event['data'],event['severity'],language,quiet=quiet) for quiet in (False,True)]
for result in results:
self.assertIn('table-layout:fixed;', result['html'])
self.assertIn('word-wrap:break-word;', result['html'])
self.assertIn('overflow-wrap:break-word;', result['html'])
self.assertEqual(result['text'].count(token), 1)
unrelated = deliver('node_reconnect', {'hostname':'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated['html'])
def test_manual_failed_and_unconfirmed_backup_keep_short_actionable_reason(self):
for outcome, reason in (('failed','PBS permission denied for datastore remote'),
('unconfirmed','Task status unavailable: upstream API timed out')):
for language in LANGUAGES:
result = deliver('backup_complete',{'hostname':'node-a','vmid':'100','vmname':'web',
'storage':'PBS','backup_outcome':outcome,'reason':reason},'INFO',language,manual=True)
self.assertEqual(result['text'].count(reason), 1)
self.assertNotIn('>COMPLETED</span>', result['html'])
def test_backup_reason_threshold_and_raw_body_deduplication(self):
for event in ('backup_complete', 'backup_fail'):
for length in (79, 80, 81, 120):
prefix = '<raw> & {rack.location} '
reason = prefix + 'b' * (length - len(prefix))
self.assertEqual(len(reason), length)
for raw in ('', reason):
data = {'hostname':'node-a','backup_outcome':'failed','reason':reason,'pve_message':raw}
for manual in (False, True):
result = deliver(event,data,'INFO','en',manual=manual)
self.assertEqual(result['text'].count(reason), 1, (event,length,raw,manual))
self.assertNotIn('raw', result['tags'])
def test_quiet_restore_after_preview_limit_is_not_omitted_or_called_info(self):
event = restore_event('missing module zfs')
earlier = [dict(event_type='service_fail',severity='WARNING',data={'hostname':'node-a','service_name':f'unit-{i}','reason':'recorded'}) for i in range(8)]
result = deliver(event['event_type'],event['data'],event['severity'],quiet=True,quiet_before=earlier)
self.assertEqual(result['text'].count('missing module zfs'), 1)
self.assertNotIn(templates.runtime_message('digest.lead','en',count=9), result['body'])
self.assertEqual(result['data']['_count'], 9)
def test_native_error_block_does_not_deduplicate_a_substring_of_inventory(self):
event = receive('web\nexternal provider job cleanup failed\n' + NATIVE_REPORT,
'error','vzdump backup status (node-a): backup failed: multiple problems')
result = deliver(event.event_type,event.data,event.severity)
self.assertEqual(result['body'].splitlines().count('web'), 1)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,159 @@
"""Maintainer acceptance at inert actual notification consumers."""
import unittest
from notification_fixture import templates, receive, LANGUAGES, SCRIPTS, extract
from notification_final_fixture import deliver
NATIVE_REPORT = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: 2026-09-29 17:00:00 INFO: Starting Backup of VM 100 (qemu)
100: 2026-09-29 17:01:01 INFO: Finished Backup of VM 100 (00:01:01)
'''
class MaintainerFollowupTests(unittest.TestCase):
def test_completed_with_warnings_requires_independent_completion(self):
warning = '\n100: 2026-09-29 17:00:01 WARN: file changed during backup'
event = receive(NATIVE_REPORT + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
self.assertEqual((event.event_type,event.severity), ('backup_complete','INFO'))
for manual in (False,True):
for lang in LANGUAGES:
result = deliver(event.event_type,event.data,event.severity,lang,manual=manual)
label = templates.runtime_message('backup.warningTitle',lang,hostname='node-a')
self.assertIn(label,result['title'])
status = templates.runtime_message('channels.email.status.completed_with_warnings',lang)
self.assertIn(status,result['text'])
self.assertIn('file changed during backup',result['text'])
# Manual sends intentionally skip channel emoji enrichment.
if not manual: self.assertTrue(result['title'].startswith('💾⚠️'))
rich, _ = templates.enrich_with_emojis(event.event_type,result['title'],result['body'],event.data)
self.assertTrue(rich.startswith('💾⚠️'))
self.assertEqual(receive('WARN: file changed during backup').data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT.split('Total running time:')[0]+warning).data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT+warning+'\nERROR: cleanup failed').data['backup_outcome'],'failed')
self.assertEqual(receive(NATIVE_REPORT+warning,'warning').data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive('INFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'+warning).data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive(NATIVE_REPORT+warning,kind='').data['backup_outcome'],'unconfirmed')
def test_null_filename_failed_guest_uses_own_start_identity(self):
report = NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z',
'100 web err 1m 1s 0 B null')
for kind, prefix in (('qemu','VM'),('lxc','CT')):
own = report.replace('VM 100 (qemu)',f'VM 100 ({kind})')
# Unrelated guest appears first and must never supply the failed type.
message = f'INFO: Starting Backup of VM 999 ({"lxc" if kind == "qemu" else "qemu"})\n' + own
event = receive(message,'error','vzdump backup status (raw-node): backup failed')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'],kind)
result = deliver(event.event_type,event.data,event.severity)
self.assertIn(f'{prefix} web (100)',result['title'])
self.assertIn(f'❌ {prefix} web (100)',result['body'])
for message in (report.replace('Starting Backup of VM 100','Starting Backup of VM 999'),
report+'\nINFO: Starting Backup of VM 100 (lxc)'):
self.assertEqual(templates._parse_vzdump_message(message)['vms'][0]['type'],'')
def test_native_subject_keeps_cause_without_host_envelope(self):
subject = 'vzdump backup status (raw-host): backup failed: multiple problems'
event = receive('ERROR: archive write failed\n'+NATIVE_REPORT,'error',subject)
event.data['hostname']='configured-alias'
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertNotIn(subject,result['text'])
self.assertEqual(result['text'].count('ERROR: archive write failed'),1)
self.assertIn('configured-alias',result['title'])
setup=receive('Details\n=======\nVMID Name Status Time Size Filename\n\nTotal running time: 0s\nTotal size: 0 B','error',subject.replace('multiple problems','unable to open storage'))
result=deliver(setup.event_type,setup.data,setup.severity)
self.assertEqual(result['text'].count('unable to open storage'),1)
self.assertNotIn(setup.data['pve_title'],result['text'])
unique=receive(NATIVE_REPORT,'error',subject.replace('multiple problems','job-end hook denied'))
result=deliver(unique.event_type,unique.data,unique.severity)
self.assertIn('job-end hook denied',result['body'])
self.assertNotIn('vzdump backup status',result['body'])
def test_backup_diagnostics_are_bounded_with_principal_cause_and_notice(self):
raw = NATIVE_REPORT + '\n' + '\n'.join(f'WARN: repeated warning {i}' for i in range(80)) + '\nERROR: principal archive write failure'
event=receive(raw)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertIn('ERROR: principal archive write failure',result['body'])
self.assertLessEqual(len('\n'.join(line for line in result['body'].splitlines() if line.startswith(('WARN:','ERROR:')))),1024)
self.assertLessEqual(sum(line.startswith(('WARN:','ERROR:')) for line in result['body'].splitlines()),8)
notice=templates.runtime_message('backup.diagnosticsOmitted',lang,count=73)
self.assertIn(notice,result['body'])
self.assertEqual(event.data['pve_message'],raw)
long=receive('ERROR: '+ 'b'*5000,'error','vzdump backup status (node): backup failed')
result=deliver(long.event_type,long.data,long.severity)
self.assertLess(len(result['body']),1400)
self.assertIn('ERROR: '+ 'b'*100,result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted','en',count=1),result['body'])
def test_real_quiet_digest_retains_each_backup_outcome_icon(self):
samples=[(NATIVE_REPORT,'confirmed','💾✅'),(NATIVE_REPORT+'\nWARN: changed file','completed_with_warnings','💾⚠️'),(NATIVE_REPORT+'\nERROR: write failed','failed','💾❌'),('INFO: Starting Backup of VM 100 (qemu)','unconfirmed','💾❔')]
for raw,outcome,icon in samples:
event=receive(raw)
self.assertEqual(event.data['backup_outcome'],outcome)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang,quiet=True)
self.assertIn(icon,result['body'])
self.assertNotIn('💾❔',result['body']) if outcome!='unconfirmed' else None
import types,datetime
ns={'datetime':datetime.datetime,'runtime_message':templates.runtime_message,'EVENT_EMOJI':templates.EVENT_EMOJI,'CATEGORY_EMOJI':templates.CATEGORY_EMOJI}
compose=extract(SCRIPTS/'notification_manager.py','_compose_digest_body','NotificationManager',ns)
target=types.SimpleNamespace(_notification_language=lambda:'en')
rows=[(i,'backup_complete','backup',1,icon+' node: Backup','') for i,(_,_,icon) in enumerate(samples)]
body=compose(target,rows,use_icons=True)
for _,_,icon in samples:self.assertIn(icon,body)
plain=compose(target,rows,use_icons=False)
for _,_,icon in samples:self.assertNotIn(icon,plain)
should=extract(SCRIPTS/'notification_manager.py','_should_buffer_for_digest','NotificationManager',{})
self.assertFalse(should(types.SimpleNamespace(_DIGEST_EXEMPT_EVENTS={'backup_complete'},_config={'email.digest_enabled':'true'}),'email','INFO','backup_complete'))
def test_quiet_restore_details_are_subordinate_in_text_and_email(self):
from notification_final_fixture import restore_event
event=restore_event('missing module zfs')
for lang in LANGUAGES:
result=deliver(event['event_type'],event['data'],event['severity'],lang,quiet=True)
body_lines=result['buffered'][0][3].splitlines()
for line in body_lines:
if line.strip():
self.assertIn(' '+line.strip(),result['body'])
self.assertIn(' '+__import__('html').escape(line.strip()),result['html'])
self.assertIn('white-space:pre-wrap;',result['html'])
self.assertNotIn(templates.runtime_message('digest.footer',lang),result['body'])
def test_concrete_subject_cause_already_in_error_log_is_not_repeated(self):
event=receive(NATIVE_REPORT+'\n100: ERROR: job-end hook denied','error',
'vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['text'].count('job-end hook denied'),1)
self.assertIn('100: ERROR: job-end hook denied',result['body'])
def test_job_level_subject_cause_survives_warning_cap(self):
raw=NATIVE_REPORT+'\n'+'\n'.join('WARN: repeated diagnostic '+str(i) for i in range(80))
event=receive(raw,'error','vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['body'].count('job-end hook denied'),1)
self.assertNotIn('vzdump backup status',result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted',lang,count=73),result['body'])
def test_backup_quiet_email_uses_event_scoped_wrapping(self):
event=receive(NATIVE_REPORT+'\nWARN: changed file')
result=deliver(event.event_type,event.data,event.severity,'sv',quiet=True)
self.assertTrue(result['data'].get('_backup_summary'))
self.assertIn('table-layout:fixed;',result['html'])
self.assertIn('overflow-wrap:break-word;',result['html'])
unrelated=deliver('node_reconnect',{'hostname':'node-a'},'OK')
self.assertNotIn('table-layout:fixed;',unrelated['html'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,399 @@
"""Inert producer-to-renderer checks for notification outcome claims."""
import ast
import copy
import json
import re
import time
import sys
import types
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import patch
from notification_fixture import templates as actual_templates
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
CATALOG = ROOT / 'AppImage/messages/en/common.json'
EXPECTED = {'system_restore_completed': {'body': 'Post-restore tasks completed in background.\n\nGuests applied: {guests}\nBind-mount stubs: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}\nThe node is now fully ready to use.'}}
def extract(path, name, owner=None, namespace=None):
tree = ast.parse(path.read_text())
nodes = tree.body
if owner:
nodes = next(n.body for n in nodes if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
ns = namespace if namespace is not None else {}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def renderer(catalog, translated=None):
path = SCRIPTS / 'notification_templates.py'
tree = ast.parse(path.read_text())
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
def lookup(obj, key):
for part in key.split('.'):
obj = obj.get(part) if isinstance(obj, dict) else None
return obj
def message(key, language='en', **values):
source = translated if language == 'it' and translated is not None else catalog
namespace = source['runtime']['notifications']
value = lookup(namespace, key) or lookup(catalog['runtime']['notifications'], key) or ''
return value.format_map(type('Safe', (dict,), {'__missing__': lambda self, k: ''})(values))
ns = {'TEMPLATES': templates, 'Dict': dict, 'Any': Any, 'time': time, 're': re,
'_get_hostname': lambda: 'node-a',
'_load_runtime_catalog': lambda lang: (translated if lang == 'it' and translated is not None
else catalog)['runtime']['notifications'],
'_catalog_value': lookup, 'runtime_message': message}
from typing import Optional
ns['Optional'] = Optional
extract(path, '_parse_vzdump_table', namespace=ns)
extract(path, '_parse_vzdump_message', namespace=ns)
extract(path, '_format_vzdump_body', namespace=ns)
return templates, extract(path, 'render_template', namespace=ns)
class OutcomeWording(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalog = json.loads(CATALOG.read_text())
cls.templates, render = renderer(cls.catalog)
cls.render = staticmethod(render)
def test_spanish_restore_uses_maintainer_guests_terminology(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_spanish_restore', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = module.render_template('system_restore_completed', {
'hostname':'node-a','guests':3,'stubs':0,'stale_nodes':0,
'components':1,'duration':'2m','warnings_block':''}, 'es')
self.assertIn('Guests aplicados: 3', result['body'])
self.assertNotIn('invitados', result['body'].lower())
def test_settings_labels_stay_at_upstream_values_in_all_locales(self):
# Frozen from develop eb7cc548; CI shallow checkouts have no base history.
labels = {'en': {'backup_complete': 'Backup complete', 'error_resolved': 'Recovery notification'}, 'de': {'backup_complete': 'Sicherung abgeschlossen', 'error_resolved': 'Wiederherstellungsbenachrichtigung'}, 'es': {'backup_complete': 'Backup completado', 'error_resolved': 'Notificación de recuperación'}, 'fr': {'backup_complete': 'Sauvegarde terminée', 'error_resolved': 'Notification de récupération'}, 'it': {'backup_complete': 'Backup completato', 'error_resolved': 'Notifica di recupero'}, 'pt': {'backup_complete': 'Backup concluído', 'error_resolved': 'Notificação de recuperação'}, 'sk': {'backup_complete': 'Záloha bola dokončená', 'error_resolved': 'Problém bol vyriešený'}, 'sv': {'backup_complete': 'Säkerhetskopieringen är klar', 'error_resolved': 'Återställningsmeddelande'}}
for lang in labels:
path = f'AppImage/messages/{lang}/common.json'
current = json.loads((ROOT / path).read_text())
for event in ('backup_complete', 'error_resolved'):
with self.subTest(lang=lang, event=event):
expected = labels[lang][event]
self.assertEqual(current['runtime']['notifications']['templates'][event]['label'], expected)
if lang == 'en': self.assertEqual(self.templates[event]['label'], expected)
def test_exact_four_english_leaves_match_source_and_catalog(self):
for event, fields in EXPECTED.items():
for field, value in fields.items():
with self.subTest(event=event, field=field):
self.assertEqual(self.templates[event][field], value)
self.assertEqual(self.catalog['runtime']['notifications']['templates'][event][field], value)
def test_webhook_backup_outcome_is_evidence_based_without_rerouting(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
row_ok = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','OK','00:01:00','1.5 GiB','archive')
row_warning = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','WARNINGS','00:01:00','1.5 GiB','archive')
row_error = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','ERROR','00:01:00','1.5 GiB','archive')
row_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','err','00:01:00','1.5 GiB','archive')
truncated = 'INFO: Log output was too long to be displayed. Please see task log for details.'
cases = [
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_err+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\n'+header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_err+'\nTotal running time: 00:01:00\n'+truncated, 'failed'),
('vzdump', 'warning', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'completed_with_warnings'),
('vzdump', 'info', header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'warning', header+'\n'+row_err+'\nTotal running time: 00:01:00', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00\n'+truncated, 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\nERROR: archive write failed', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', header+'\n'+row_error, 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00', 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok[:30], 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'confirmed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nWARNING: skipped file', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: TASK OK\n104 alpha WARNINGS: 1', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Finished Backup of VM 105 (00:01:00)', 'confirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed for VM 104', 'failed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'failed'),
('', 'warning', 'Backup scheduled', 'unconfirmed'),
('', 'info', 'Backup complete', 'unconfirmed'),
]
for kind, severity, message, expected in cases:
with self.subTest(message=message, severity=severity):
receiver = Receiver()
receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': 'Backup', 'message': message})
event = receiver._queue.items[0]
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.data['backup_outcome'], expected)
def test_backup_classifier_preserves_confirmed_and_unverified_paths(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
samples = [('vzdump', 'info', 'Backup', 'job finished'),
('vzdump', 'warning', 'Backup', 'job incomplete'),
('', 'warning', 'backup job', 'Backup scheduled')]
for kind, severity, title, message in samples:
with self.subTest(kind=kind, message=message):
event, entity, _ = classify(None, kind, severity, title, message)
self.assertEqual((event, entity), ('backup_complete', 'vm'))
receiver = Receiver()
reply = receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': title, 'message': message})
self.assertEqual(reply['event_type'], event)
self.assertEqual(len(receiver._queue.items), 1)
emitted = receiver._queue.items[0]
self.assertEqual(emitted.severity, 'WARNING' if severity == 'warning' else 'INFO')
output = self.render(emitted.event_type, emitted.data, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertEqual(output['body'].splitlines()[-1], message) # raw PVE body retained
self.assertEqual(classify(None, 'vzdump', 'error', 'Backup', 'failed')[0], 'backup_fail')
def test_incomplete_vzdump_log_does_not_certify_a_guest(self):
from typing import Dict, Optional
ns = {'re': re, 'Dict': Dict, 'Optional': Optional, 'Any': Any,
'runtime_message': lambda key, lang, **kw: key}
extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_table', namespace=ns)
parser = extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_message', namespace=ns)
formatter = extract(SCRIPTS / 'notification_templates.py', '_format_vzdump_body', namespace=ns)
incomplete = parser('INFO: Starting Backup of VM 104 (qemu)')
table_header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
table_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','err','00:01:00','1.5 GiB','archive')
failed_table = parser(table_header+'\n'+table_err+'\nTotal running time: 00:01:00')
self.assertEqual(failed_table['vms'][0]['status'].lower(), 'error')
self.assertIn('❌', formatter(failed_table, False, 'en'))
self.assertEqual(incomplete['vms'][0]['status'], 'unknown')
self.assertNotIn('✅', formatter(incomplete, False, 'en'))
mixed = parser('INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Starting Backup of VM 105 (lxc)')
self.assertEqual([vm['status'] for vm in mixed['vms']], ['ok', 'unknown'])
formatted = formatter(mixed, False, 'en')
self.assertEqual(formatted.count('✅'), 1)
self.assertNotIn('❌', formatted)
conflicting = parser('INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed\nINFO: Finished Backup of VM 104 (00:01:00)')
self.assertEqual(conflicting['vms'][0]['status'], 'error')
def test_backup_render_preserves_success_and_marks_unconfirmed_and_failure(self):
for outcome, message, title_part, body_part in (
('confirmed', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'Backup complete', '✅'),
('unconfirmed', 'INFO: Starting Backup of VM 104 (qemu)', 'Backup outcome unconfirmed', '❔'),
('failed', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'Backup error reported', '❌'),
):
with self.subTest(outcome=outcome):
output = self.render('backup_complete', {'hostname': 'node-a', 'pve_type': 'vzdump',
'pve_message': message, 'pve_title': 'Backup complete', 'backup_outcome': outcome}, 'en')
self.assertIn(title_part, output['title'])
self.assertIn(body_part, output['body'])
if outcome != 'confirmed': self.assertNotIn('Backup complete', output['title'])
output = self.render('backup_complete', {'hostname': 'node-a', 'vmname': 'vm', 'vmid': '104'}, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertNotIn('successfully', output['body'])
conflict = self.render('backup_complete', {'hostname': 'node-a','backup_outcome':'failed',
'pve_message':'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nERROR: archive write failed'}, 'en')
self.assertIn('ERROR: archive write failed', conflict['body'])
self.assertNotIn('Backup complete', conflict['title'])
def test_confirmed_title_keeps_single_guest_and_destination_without_misnaming_batches(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_backup_title', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
actual_render = module.render_template
log = ('INFO: starting new backup job: vzdump 104 --storage PBS-Cloud --mode snapshot\n'
'INFO: Starting Backup of VM 104 (qemu)\nINFO: VM Name: Alpha\n'
'INFO: Finished Backup of VM 104 (00:01:00)')
single = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log}, 'en')
self.assertIn('PBS-Cloud',single['title'])
self.assertIn('VM Alpha (104)',single['title'])
batch = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log+'\nINFO: Starting Backup of VM 105 (lxc)\n'
'INFO: Finished Backup of VM 105 (00:01:00)'}, 'en')
self.assertIn('PBS-Cloud',batch['title'])
self.assertNotIn('Alpha (104)',batch['title'])
no_context = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed'}, 'en')
self.assertEqual(no_context['title'], 'node-a: Backup complete')
named = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log.replace('Alpha','Alpha {literal}')}, 'en')
self.assertIn('Alpha {literal} (104)', named['title'])
def test_html_email_badge_and_backup_status_are_context_specific(self):
import html
path = SCRIPTS / 'notification_channels.py'
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
with self.subTest(lang=lang):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
english = self.catalog['runtime']['notifications']
def text(key, data=None, **values):
def lookup(source):
value = source['channels']
for part in key.split('.'):
value = value.get(part) if isinstance(value, dict) else None
return value
return (lookup(catalog) or lookup(english) or '').format(**values)
ns = {'Dict': dict, 'Optional': __import__('typing').Optional,
'_runtime_text': text, '_runtime_notification_text': lambda key, data=None: ''}
build = extract(path, '_build_detail_rows', 'EmailChannel', ns)
fmt = extract(path, '_format_html', 'EmailChannel', ns)
class Email:
from notification_channels import EmailChannel
_SEV_STYLE = EmailChannel._SEV_STYLE
_SEV_DEFAULT = EmailChannel._SEV_DEFAULT
subject_prefix = 'ProxMenux'
_build_detail_rows = staticmethod(build)
unrelated = fmt(Email(), 'Reconnected', 'Body', 'OK', {'_event_type': 'node_reconnect',
'_notification_language': lang, '_group': 'cluster'})
self.assertIn('>' + catalog['channels']['email']['severity']['ok'].upper() + '</span>', unrelated)
for outcome, status in [('confirmed', 'completed'), ('unconfirmed','unconfirmed'), ('failed','failed')]:
email = fmt(Email(), 'Backup', 'Details', 'INFO', {'_event_type': 'backup_complete',
'backup_outcome': outcome, '_notification_language': lang, '_group': 'backup'})
label = catalog['channels']['email']['status'].get(status) or english['channels']['email']['status'][status]
self.assertIn(label, html.unescape(email))
badge_label = label.upper()
self.assertIn('>' + badge_label + '</span>', html.unescape(email))
if outcome == 'failed':
self.assertIn('color:#dc2626;font-weight:600;', email)
self.assertIn('background:#fef2f2;', email)
elif outcome == 'unconfirmed':
self.assertIn('background:#f9fafb;', email)
else:
self.assertIn('background:#f0fdf4;', email)
def test_actual_all_locale_rendering_and_fallback_for_backup_outcomes(self):
import importlib.util
module_path = SCRIPTS / 'notification_templates.py'
spec = importlib.util.spec_from_file_location('isolated_notification_templates', module_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # templates and catalogs only; no manager or sends
samples = {
'confirmed': 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)',
'unconfirmed': 'INFO: Starting Backup of VM 104 (qemu)',
'failed': 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed',
}
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
for state, message in samples.items():
with self.subTest(lang=lang, state=state):
data = {'hostname':'node-with-a-long-name','backup_outcome':state,'pve_type':'vzdump',
'pve_message':message, 'pve_title':'Backup complete', '_notification_language':lang}
result = module.render_template('backup_complete', data, lang)
if state != 'unconfirmed':
key = 'confirmedTitle' if state == 'confirmed' else 'errorTitle'
expected_title = (catalog.get('backup', {}).get(key) or
self.catalog['runtime']['notifications']['backup'][key]).format(hostname=data['hostname'])
self.assertTrue(result['title'].startswith(expected_title), result['title'])
else:
self.assertEqual(result['title'], module.runtime_message('backup.unconfirmedTitle', lang, hostname=data['hostname']))
self.assertNotIn('{hostname}', result['title'])
if state == 'unconfirmed':
source = (catalog if catalog.get('backup', {}).get('unconfirmedBody')
else self.catalog['runtime']['notifications'])
self.assertIn(source['backup']['unconfirmedBody'], result['body'])
if state == 'failed':
self.assertIn(catalog.get('backup', {}).get('errorBody') or
self.catalog['runtime']['notifications']['backup']['errorBody'], result['body'])
enriched, _ = module.enrich_with_emojis('backup_complete', result['title'], result['body'], data)
self.assertTrue(enriched.startswith({'confirmed':'💾✅','unconfirmed':'💾❔','failed':'💾❌'}[state]))
restore = module.render_template('system_restore_completed', {'hostname':'node', 'guests':4,
'stubs':1,'stale_nodes':2,'components':1,'duration':'2m','warnings_block':'Missing module'},lang)
self.assertIn('Missing module',restore['body'])
if lang == 'en': self.assertIn('fully ready',restore['body'].lower())
def test_warning_and_clean_restore_keep_only_reported_outcome(self):
for warnings in ('', '⚠️ Boot sanity: missing modules\n'):
with self.subTest(warnings=warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '2', 'stubs': '0', 'stale_nodes': '0',
'components': 'none', 'duration': '2m',
'warnings': 'missing modules' if warnings else ''}),
'notification_manager': types.SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda obj: obj}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', namespace=ns)
response, status = handler()
self.assertEqual((status, response['event_type']), (200, 'system_restore_completed'))
event = events[0]
self.assertEqual(event['severity'], 'WARNING' if warnings else 'INFO')
result = self.render(event['event_type'], event['data'], 'en')
self.assertIn('Post-restore tasks completed', result['body'])
self.assertIn('fully ready', result['body'])
if warnings: self.assertIn('missing modules', result['body'])
def test_rich_backup_icon_tracks_outcome_and_digest_default_is_neutral(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
def assign(name):
return ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)))
icons = assign('EVENT_EMOJI')
self.assertNotIn('✅', icons['backup_complete'])
ns = {'TEMPLATES': assign('TEMPLATES'), 'EVENT_EMOJI': icons,
'Dict': dict, 'Any': Any,
'CATEGORY_EMOJI': assign('CATEGORY_EMOJI'), 'SEVERITY_ICONS': assign('SEVERITY_ICONS'),
'FIELD_EMOJI': assign('FIELD_EMOJI'), '_localized_template_labels': lambda *a: {},
'_lxc_update_label_icons': lambda *a: {}}
enrich = extract(SCRIPTS / 'notification_templates.py', 'enrich_with_emojis', namespace=ns)
for state, icon in [('confirmed', '💾✅'), ('unconfirmed', '💾❔'), ('failed', '💾❌')]:
with self.subTest(state=state):
title, body = enrich('backup_complete', 'node-a: Backup', 'Backup report',
{'backup_outcome': state, '_notification_language': 'en'})
self.assertTrue(title.startswith(icon), title)
def test_uncertain_outcomes_have_no_blanket_success_icon(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
icon_map = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'EVENT_EMOJI' for t in n.targets)))
self.assertNotIn('✅', icon_map['backup_complete']) # buffered digest has no outcome metadata
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,50 @@
"""Principal diagnostics must not be deduplicated against inventory substrings."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
def noisy_report(name='ordinary-web', storage='PBS', filename='null', cause='denied'):
# Frozen native Perl notifier/log-reader shape; fixed-column table is
# source-modeled from the official plaintext renderer, not a live PVE run.
return ('\nDetails\n=======\nVMID Name Status Time Size Filename\n'
f'100 {name} err 1m 1s 0 B {filename}\n\n'
'Total running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\n'
f'vzdump --all 1 --storage {storage} --mode snapshot\n\n' +
'\n'.join(f'100: 2026-09-29 17:00:00 ERROR: earlier diagnostic {i}' for i in range(12)) +
f'\n100: 2026-09-29 17:00:00 ERROR: {cause}\n\n')
class PrincipalInventoryTests(unittest.TestCase):
def assert_principal(self, raw, cause):
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'},
event.severity, language, manual=manual)
exact = [line for line in result['body'].splitlines()
if line.strip() == cause or line.rstrip().endswith('ERROR: ' + cause)]
self.assertEqual(len(exact), 1)
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
self.assertNotIn('raw-host', result['text'])
self.assertIn('alias {rack.location}', result['text'])
self.assertEqual(result['text'].count('ERROR: ' + cause), 1)
def test_late_principal_survives_incidental_guest_storage_and_archive(self):
for name, storage, filename in (('ordinary-web','PBS','null'), ('denied','PBS','null'),
('ordinary-web','denied','null'), ('ordinary-web','PBS','denied.tar')):
with self.subTest(name=name, storage=storage, filename=filename):
self.assert_principal(noisy_report(name,storage,filename), 'denied')
def test_raw_principal_braces_and_markup_survive_once(self):
cause = 'denied {rack.location} <native>'
self.assert_principal(noisy_report('denied', cause=cause), cause)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,192 @@
"""Frozen PVE 9.2 report through inert actual receiver and renderers.
No git history, host-management import, notification send or generated fixture.
"""
import ast
import importlib.util
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
PVE92 = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
'''
def receiver():
tree = ast.parse((SCRIPTS / 'notification_events.py').read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ProxmoxHookWatcher')
names = ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
for name in names:
node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), '<inert-webhook>', 'exec'), ns)
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = ns['_classify_pve']
_map_severity = staticmethod(ns['_map_severity'])
_backup_outcome = staticmethod(ns['_backup_outcome'])
process_webhook = ns['process_webhook']
def __init__(self): self._queue = Queue()
return Receiver()
def event_for(message, severity='info'):
target = receiver()
target.process_webhook({'fields': {'type': 'vzdump'}, 'severity': severity,
'title': 'Backup', 'message': message})
return target._queue.items[0]
class PVE92Tests(unittest.TestCase):
def test_exact_maintainer_report_is_confirmed(self):
event = event_for(PVE92)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('Backup complete', result['title'])
self.assertIn('web (100)', result['title'])
self.assertIn('✅', result['body'])
def test_failed_guest_identity_does_not_come_from_timestamp_or_ok_guest(self):
failed = '101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z'
ok = PVE92.splitlines()[3]
for rows in (ok + '\n' + failed, failed + '\n' + ok):
for severity in ('info', 'error'):
with self.subTest(rows=rows, severity=severity):
message = ('INFO: 100 01:01:06 OK\nINFO: Starting Backup of VM 100 (qemu)\n'
+ PVE92.replace(ok, rows))
event = event_for(message, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
self.assertEqual(event.data.get('vmname'), 'db')
self.assertEqual(event.data.get('vmid'), '101')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertNotIn('01:01:06', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_pbs_prefix_is_used_in_parsed_type_and_title(self):
for prefix, kind, label in (('vm', 'qemu', 'VM'), ('ct', 'lxc', 'CT'),
('other', '', 'VM/CT')):
with self.subTest(prefix=prefix):
message = PVE92.replace('vm/100/', prefix + '/100/')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'], kind)
event = event_for(message)
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn(label + ' web (100)', result['title'])
def test_slovak_uses_normal_locale_resolution_without_source_sentence_overrides(self):
from unittest.mock import patch
data = {'hostname': 'node-a', 'category': 'temperature', 'entity_suffix': '',
'reason': 'old', 'duration': '3d', 'original_severity': 'WARNING',
'vmname': 'web', 'vmid': '100', 'storage': 'PBS', 'size': '1 GiB',
'guests': 3, 'stubs': 0, 'stale_nodes': 0, 'components': 1,
'warnings_block': ''}
slovak = templates._load_runtime_catalog('sk')
english = templates._load_runtime_catalog('en')
for event, field in (('system_restore_completed', 'body'),):
with self.subTest(event=event, field=field):
value = slovak['templates'][event][field]
result = templates.render_template(event, data, 'sk')
self.assertEqual(result[field], value.format(**data))
self.assertNotIn(value, (SCRIPTS / 'notification_templates.py').read_text())
# Independently updated and absent leaves use the usual provider.
import copy
future = copy.deepcopy(slovak)
future['templates'][event][field] = 'REVIEWED {hostname}'
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field], 'REVIEWED node-a')
future['templates'][event].pop(field)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field],
templates.render_template(event, data, 'en')[field])
def test_complete_table_beats_only_truncated_supplemental_log(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\n' + PVE92 +
'\nLogs\n====\nINFO: Log output was too long to be displayed. Please see task log for details.')
self.assertEqual(event_for(message).data['backup_outcome'], 'confirmed')
for message, severity, expected in (
(PVE92.replace('ok ', 'OK '), 'info', 'confirmed'),
(message, 'warning', 'completed_with_warnings'),
(message, 'error', 'failed'),
(message + '\nERROR: archive write failed', 'info', 'failed'),
(message + '\nWARNING: skipped file', 'info', 'completed_with_warnings'),
(PVE92.replace('ok ', 'WARNINGS '), 'info', 'unconfirmed'),
):
with self.subTest(message=message, severity=severity):
self.assertEqual(event_for(message, severity).data['backup_outcome'], expected)
def test_incomplete_and_unrelated_sections_cannot_certify_table(self):
for message in (
PVE92.split('Total running time:')[0],
PVE92.replace('vm/100/2026-09-29T17:00:00Z', ''),
PVE92.replace('\n\nTotal', '\n\nLogs\n======\nTotal'),
PVE92.replace('\n\nTotal', '\n\nUnrelated section\n100 web ok\nTotal'),
PVE92.replace('1m 1s 1 GiB', 'nonsense 1 GiB'),
PVE92.replace('1 GiB vm/', 'garbage vm/'),
PVE92.replace('1m 1s 1 GiB vm/', '1m 1s'),
):
with self.subTest(message=message):
# Complete guest logs cannot rescue a genuinely incomplete table.
message += '\nINFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'
self.assertEqual(event_for(message).data['backup_outcome'], 'unconfirmed')
def test_blank_line_between_rows_does_not_hide_a_failure(self):
message = PVE92.replace('\n\nTotal',
'\n\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
event = event_for(message)
self.assertEqual(event.data['backup_outcome'], 'failed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_changed_titles_and_rows_reach_actual_html_email_in_all_locales(self):
import html
from notification_channels import EmailChannel
email = object.__new__(EmailChannel)
email.subject_prefix = '[ProxMenux]'
failed = PVE92.replace('\n\nTotal',
'\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
for message, severity in ((PVE92, 'info'), (failed, 'info'), (failed, 'error')):
with self.subTest(lang=lang, severity=severity, message=message):
event = event_for(message, severity)
result = templates.render_template(event.event_type, event.data, lang)
context = {**event.data, '_event_type': event.event_type,
'_notification_language': lang, '_group': result['group']}
markup = html.unescape(email._format_html(result['title'], result['body'], event.severity, context))
self.assertIn(result['title'], markup)
if event.data['backup_outcome'] == 'failed':
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertIn('❌ CT db (101)', markup)
status = templates.runtime_message('channels.email.status.failed', lang)
else:
self.assertIn('VM web (100)', result['title'])
self.assertIn('✅ VM web (100)', markup)
status = templates.runtime_message('channels.email.status.completed', lang)
badge = (templates.runtime_message('channels.email.severity.critical', lang)
if event.event_type == 'backup_fail' else status)
self.assertIn('>' + badge.upper() + '</span>', markup)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,24 @@
"""Review regressions at actual consumer seams; no operational imports."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
class ReviewCorrectionTests(unittest.TestCase):
def test_subject_equivalent_late_error_is_reserved_before_cap(self):
cause = 'job-end hook denied'
# Frozen native Perl notifier/log-reader output; Rust table source-modeled.
raw = '\nDetails\n=======\nVMID Name Status Time Size Filename \n100 web err 1m 1s 0 B null \n\nTotal running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\nvzdump --all 1 --storage PBS --mode snapshot\n\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 0\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 1\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 2\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 3\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 4\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 5\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 6\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 7\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 8\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 9\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 10\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 11\n100: 2026-09-29 17:00:00 ERROR: job-end hook denied\n\n\n'
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'}, event.severity, language, manual=manual)
self.assertEqual(result['text'].count(cause), 1)
self.assertNotIn('raw-host', result['text'])
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
if __name__ == '__main__': unittest.main()