Files
ProxMenux/AppImage/scripts/tests/test_notification_delivery_dedup.py
T
MacRimiandClaude Opus 5.5 4437a671d2 ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:51:12 +02:00

161 lines
5.1 KiB
Python

import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import notification_manager # noqa: E402
from notification_events import NotificationEvent # noqa: E402
class RecordingChannel:
def __init__(self, results=None):
self.results = list(results or [True])
self.calls = 0
self.lock = threading.Lock()
def send(self, title, body, severity, data):
with self.lock:
self.calls += 1
success = self.results.pop(0) if self.results else True
time.sleep(0.1)
return {'success': success, 'error': '' if success else 'temporary failure'}
class NotificationDeliveryDedupTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / 'health_monitor.db'
conn = sqlite3.connect(str(self.db_path))
conn.execute('''
CREATE TABLE notification_last_sent (
fingerprint TEXT PRIMARY KEY,
last_sent_ts INTEGER NOT NULL,
count INTEGER DEFAULT 1
)
''')
conn.execute('''
CREATE TABLE notification_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
title TEXT,
message TEXT,
severity TEXT,
sent_at TEXT NOT NULL,
success INTEGER DEFAULT 1,
error_message TEXT,
source TEXT DEFAULT 'server'
)
''')
conn.commit()
conn.close()
self.db_patch = mock.patch.object(
notification_manager, 'DB_PATH', self.db_path,
)
self.db_patch.start()
self.addCleanup(self.db_patch.stop)
self.ai_context_patch = mock.patch.object(
notification_manager, 'enrich_context_for_ai', return_value='',
)
self.ai_context_patch.start()
self.addCleanup(self.ai_context_patch.stop)
self.ai_rewrite_patch = mock.patch.object(
notification_manager, '_format_with_ai_bounded', return_value=None,
)
self.ai_rewrite_patch.start()
self.addCleanup(self.ai_rewrite_patch.stop)
def _manager(self, channel):
manager = notification_manager.NotificationManager()
manager._enabled = True
manager._config = {
'email.enabled': 'true',
'email.rich_format': 'false',
'ai_enabled': 'false',
}
manager._channels = {'email': channel}
return manager
@staticmethod
def _event():
return NotificationEvent(
event_type='lxc_update_applied',
severity='INFO',
data={
'hostname': 'pve-test',
'vmid': 210,
'ct_name': 'docker-frontend',
'target': 'Docker Engine',
'result': 'succeeded',
'duration': '10s',
'details': 'Update completed',
},
source='manual',
entity='ct',
entity_id='210:same-run',
)
def test_concurrent_managers_deliver_same_fingerprint_once(self):
channel = RecordingChannel()
managers = [self._manager(channel), self._manager(channel)]
barrier = threading.Barrier(3)
def dispatch(manager):
barrier.wait()
manager._dispatch_event(self._event())
threads = [
threading.Thread(target=dispatch, args=(manager,))
for manager in managers
]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=5)
self.assertEqual(channel.calls, 1)
conn = sqlite3.connect(str(self.db_path))
history_count = conn.execute(
'SELECT COUNT(*) FROM notification_history WHERE success = 1'
).fetchone()[0]
claim_count = conn.execute(
'SELECT COUNT(*) FROM notification_delivery_claims'
).fetchone()[0]
conn.close()
self.assertEqual(history_count, 1)
self.assertEqual(claim_count, 0)
def test_failed_delivery_releases_claim_for_retry(self):
channel = RecordingChannel([False, True])
manager = self._manager(channel)
manager._dispatch_event(self._event())
manager._dispatch_event(self._event())
self.assertEqual(channel.calls, 2)
conn = sqlite3.connect(str(self.db_path))
successes = conn.execute(
'SELECT success FROM notification_history ORDER BY id'
).fetchall()
claim_count = conn.execute(
'SELECT COUNT(*) FROM notification_delivery_claims'
).fetchone()[0]
conn.close()
self.assertEqual(successes, [(0,), (1,)])
self.assertEqual(claim_count, 0)
if __name__ == '__main__':
unittest.main()