mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
Clusters E+H of plan 38 (plans 24/29/33). - security_policy_service: require-2FA policy (ok/nudge/enforce), SSO-exempt, grace window (plan 29); restored can_operate_app/can_admin_app on resource_grant_service for the member-action callers — 23 tests green - notifications/digest: per-user daily/weekly digest rollups over queued_digest deliveries, respecting cadence + last_sent_at (plans 24/33) - notifications/service: deep-link action_path/action_label on send + inbox category/severity filters (plan 24 Phase 5); catalog link builder - chat_webhook_service: org chat/webhook connections, HMAC-signed delivery, category fan-out through the notify bus (plan 33) - restored digest_cadence/digest_last_sent_at on NotificationPreferences Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
7.3 KiB
Python
192 lines
7.3 KiB
Python
"""Proving tests for notification digest rollups (plan 24 Phase 3).
|
|
|
|
Reconstructed alongside the lost ``app/notifications/digest.py``; no surviving
|
|
test, so this file is the executable contract for cadence windows, grouping,
|
|
the empty/single-item guards, and the queued_digest -> sent flip.
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
|
|
import pytest
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
from app import db
|
|
from app.models import User
|
|
from app.models.notification_preferences import NotificationPreferences
|
|
from app.notifications import digest
|
|
from app.notifications.models import Notification, NotificationDelivery
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _capture_transmit(monkeypatch):
|
|
"""Capture the outgoing digest email instead of hitting a real transport."""
|
|
sent = {}
|
|
|
|
def fake_transmit(to_addr, rendered):
|
|
sent['to'] = to_addr
|
|
sent['rendered'] = rendered
|
|
return True, 'digest-msg-1', None
|
|
|
|
monkeypatch.setattr(digest, '_transmit', fake_transmit)
|
|
return sent
|
|
|
|
|
|
def _user(username='dg', cadence='daily', last_sent=None):
|
|
u = User(email=f'{username}@t.local', username=username,
|
|
password_hash=generate_password_hash('x'), role='developer', is_active=True)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
prefs = NotificationPreferences.get_or_create(u.id)
|
|
prefs.digest_cadence = cadence
|
|
prefs.digest_last_sent_at = last_sent
|
|
db.session.commit()
|
|
return u
|
|
|
|
|
|
def _queue(user, event='backup.completed', category='backups', severity='success',
|
|
title='Backup completed: blog', when=None):
|
|
"""Create a notification + a queued_digest email delivery for the user."""
|
|
n = Notification(event_key=event, category=category, severity=severity, title=title)
|
|
n.set_data({'message': title})
|
|
if when is not None:
|
|
n.created_at = when
|
|
db.session.add(n)
|
|
db.session.flush()
|
|
d = NotificationDelivery(
|
|
notification_id=n.id, recipient_user_id=user.id,
|
|
channel=NotificationDelivery.CHANNEL_EMAIL, target=user.email,
|
|
status=NotificationDelivery.STATUS_QUEUED_DIGEST,
|
|
)
|
|
db.session.add(d)
|
|
db.session.commit()
|
|
return d
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Cadence windows
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_is_due_windows(app):
|
|
off = _user('off_u', cadence='off')
|
|
assert digest.is_due(NotificationPreferences.get_or_create(off.id)) is False
|
|
|
|
fresh = _user('fresh_u', cadence='daily', last_sent=None)
|
|
assert digest.is_due(NotificationPreferences.get_or_create(fresh.id)) is True
|
|
|
|
recent = _user('recent_u', cadence='daily',
|
|
last_sent=datetime.utcnow() - timedelta(hours=1))
|
|
assert digest.is_due(NotificationPreferences.get_or_create(recent.id)) is False
|
|
|
|
stale = _user('stale_u', cadence='daily',
|
|
last_sent=datetime.utcnow() - timedelta(days=2))
|
|
assert digest.is_due(NotificationPreferences.get_or_create(stale.id)) is True
|
|
|
|
weekly_mid = _user('weekly_u', cadence='weekly',
|
|
last_sent=datetime.utcnow() - timedelta(days=3))
|
|
assert digest.is_due(NotificationPreferences.get_or_create(weekly_mid.id)) is False
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Send + flip
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_send_groups_flips_and_stamps(app, _capture_transmit):
|
|
u = _user('grp')
|
|
d1 = _queue(u, category='backups', severity='success', title='Backup completed: blog')
|
|
d2 = _queue(u, event='security.alert', category='security', severity='critical',
|
|
title='Security alert: brute force')
|
|
d3 = _queue(u, event='app.deployed', category='apps', severity='success',
|
|
title='Deployed: api')
|
|
|
|
result = digest.send_user_digest(u.id)
|
|
assert result['sent'] is True
|
|
assert result['count'] == 3
|
|
assert '3 updates' in result['subject']
|
|
assert result['message_id'] == 'digest-msg-1'
|
|
|
|
# All held deliveries flipped to sent with the digest's message id.
|
|
for d in (d1, d2, d3):
|
|
db.session.refresh(d)
|
|
assert d.status == NotificationDelivery.STATUS_SENT
|
|
assert d.provider_message_id == 'digest-msg-1'
|
|
assert d.sent_at is not None
|
|
|
|
# Clock stamped so the next tick won't re-send.
|
|
prefs = NotificationPreferences.get_or_create(u.id)
|
|
assert prefs.digest_last_sent_at is not None
|
|
|
|
# Rendered HTML actually grouped by category (real template render).
|
|
html = _capture_transmit['rendered']['html']
|
|
assert 'Security' in html and 'Backups' in html and 'Apps' in html
|
|
assert 'Security alert: brute force' in html
|
|
assert _capture_transmit['to'] == u.email
|
|
|
|
|
|
def test_empty_digest_never_sends(app, _capture_transmit):
|
|
u = _user('empty')
|
|
result = digest.send_user_digest(u.id)
|
|
assert result['sent'] is False
|
|
assert result['reason'] == 'empty'
|
|
# No transport call, no clock stamp.
|
|
assert 'to' not in _capture_transmit
|
|
prefs = NotificationPreferences.get_or_create(u.id)
|
|
assert prefs.digest_last_sent_at is None
|
|
|
|
|
|
def test_single_item_uses_its_own_subject(app, _capture_transmit):
|
|
u = _user('single')
|
|
_queue(u, category='backups', title='Backup completed: solo-site')
|
|
result = digest.send_user_digest(u.id)
|
|
assert result['sent'] is True
|
|
assert result['count'] == 1
|
|
assert result['subject'] == 'Backup completed: solo-site' # no "1 update" shame
|
|
|
|
|
|
def test_transient_send_failure_leaves_deliveries_queued(app, monkeypatch):
|
|
u = _user('fail')
|
|
d = _queue(u)
|
|
monkeypatch.setattr(digest, '_transmit',
|
|
lambda to, rendered: (False, None, 'smtp down'))
|
|
result = digest.send_user_digest(u.id)
|
|
assert result['sent'] is False
|
|
db.session.refresh(d)
|
|
# Still queued for the next attempt — not marked sent.
|
|
assert d.status == NotificationDelivery.STATUS_QUEUED_DIGEST
|
|
prefs = NotificationPreferences.get_or_create(u.id)
|
|
assert prefs.digest_last_sent_at is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The hourly tick
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_run_due_only_mails_elapsed_users(app):
|
|
due = _user('due_u', cadence='daily', last_sent=None)
|
|
_queue(due)
|
|
|
|
not_due = _user('notdue_u', cadence='daily',
|
|
last_sent=datetime.utcnow() - timedelta(minutes=5))
|
|
_queue(not_due)
|
|
|
|
off = _user('off2_u', cadence='off')
|
|
_queue(off)
|
|
|
|
summary = digest.run_due()
|
|
assert summary['sent'] == 1 # only the elapsed daily user
|
|
|
|
# The due user's delivery is sent; the others remain queued.
|
|
due_deliveries = digest.pending_for_user(due.id)
|
|
assert due_deliveries == [] # flipped out of queued_digest
|
|
assert len(digest.pending_for_user(not_due.id)) == 1
|
|
assert len(digest.pending_for_user(off.id)) == 1
|
|
|
|
|
|
def test_run_due_skips_users_with_no_pending(app):
|
|
"""A user whose window elapsed but has nothing queued is skipped (empty
|
|
never sends) and their clock is NOT stamped."""
|
|
u = _user('nopending', cadence='daily', last_sent=None)
|
|
summary = digest.run_due()
|
|
assert summary['sent'] == 0
|
|
assert summary['skipped'] >= 1
|
|
prefs = NotificationPreferences.get_or_create(u.id)
|
|
assert prefs.digest_last_sent_at is None
|