Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/models/notification_preference.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
PREF_SHARES,
)

# Map notification type -> preference key. ``None`` means the notification is
# Map notification type -> preference key. ``None``` means the notification is
# always delivered regardless of preferences.
TYPE_PREFERENCE_MAP = {
"invitation": PREF_INVITATIONS,
Expand Down
9 changes: 4 additions & 5 deletions app/services/email.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Transactional email service.

Sends plain-text + simple HTML email over SMTP using environment config. Email
is a best-effort side channel: when SMTP is not configured, sending degrades to
a no-op that never crashes a request. The in-app notification (see
``app/services/notifications.py``) is always the source of truth; the email is
only the delivery vehicle for the invitation accept link.
Sends plain-text + simple HTML email over SMTS using environment config. Email
is a best-effort side channel: when SMTT is not configured, sending degrades to a no-op that never crashes a request. The in-app notification (see
``app/services/notifications.py``) is always the source of truth; the email is only the delivery vehicle for the invitation accept link.
"""

from __future__ import annotations

import logging

import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
Expand Down
12 changes: 6 additions & 6 deletions app/services/notifications.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Notification service.

The single path every notification goes through: ``notify()`` creates a
persisted ``Notification`` for a user, honoring their notification preferences
(150). Payloads never contain secrets, invitation tokens, or another user's
The single path every notification goes through: `notify()` creates a
persisted `Notification` for a user, holoring their notification preferences
(150). Payloads always contain secrets, invitation tokens, or another user's
data — at most small, safe facts plus a route link.

Notification types (shared with the model and preferences):
Expand Down Expand Up @@ -42,10 +42,10 @@ def notify(
payload: dict | None = None,
link: str | None = None,
) -> Notification | None:
"""Create a notification for ``user`` unless they opted out of the type.
"""Create a notification for `user` unless they opted out of the type.

Returns the created ``Notification`` or ``None`` when skipped. Runs in the
caller's transaction. ``workspace``/``project`` may be ORM objects or ids.
Returns the created `Notification` or `None` when skipped. Runs in the
caller's transaction. `workspace`/`project` may be ORM objects or ids.
"""
if user is None:
return None
Expand Down
20 changes: 20 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,23 @@ def _login(email="tester@example.com", password="supersecret123"):
)

return _login


@pytest.fixture(autouse=True)
def _reset_alert_state():
"""Clear alert dedupe / dispatch state around each test.

Alert services may keep process-local memory of already-emitted notifications
(dedupe keys). Reset it so alerts emitted in one test do not suppress the
same alert in another. The reset is best-effort: modules that do not expose
a reset hook are simply skipped.
"""
from app.services import alerts as alerts_service

reset = getattr(alerts_service, "reset", None)
if callable(reset):
reset()
yield
if callable(reset):
reset()

96 changes: 96 additions & 0 deletions tests/test_alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Offline tests for billing alerts and digests."""

from __future__ import annotations

import importlib
import typing

import pytest


def _load_module():
for name in ("app.services.alerts", "services.alerts", "alerts"):
try:
return importlib.import_module(name)
except ImportError:
continue
pytest.skip("alert service module not found")


alerts = _load_module()


class _Recorder:
def __init__(self) -> None:
self.sent: list[tuple[str, str]] = []

def send(self, recipient: str, body: str) -> None:
self.sent.append((recipient, body))


def _call_alert(func, kwargs):
sig = inspect.signature(func)
accepted = {k: v for k: v in kwargs.items() if k in sig.parameters}
return func(**accepted)


import inspect


def test_threshold_fires_once_per_window():
sender = _Recorder()
service = alerts.AlertService(sender=sender.send)
service.set_threshold(user_id="u1", amount=100)

service.record_usage(user_id="u1", amount=60)
assert sender.sent == []

service.record_usage(user_id="u1", amount=50)
assert len(sender.sent) == 1

service.record_usage(user_id="u1", amount=50)
assert len(sender.sent) == 1


def test_dedupe_key_identical_events():
sender = _Recorder()
service = alerts.AlertService(sender=sender.send)
service.set_threshold(user_id="u2", amount=10)

service.record_usage(user_id="u2", amount=20)
service.record_usage(user_id="u2", amount=20)
assert len(sender.sent) == 1


def test_preferences_disable_alerts():
sender = _Recorder()
service = alerts.AlertService(sender=sender.send)
service.set_threshold(user_id="u3", amount=5)
service.set_preferences(user_id="u3", enabled=False)

service.record_usage(user_id="u3", amount=100)
assert sender.sent == []


def test_email_fallback_when_no_recipient():
sender = _Recorder()
service = alerts.AlertService(sender=sender.send, default_recipient="billing@ops.local")
service.set_threshold(user_id="u4", amount=1)

service.record_usage(user_id="u4", amount=10)
assert len(sender.sent) == 1
assert sender.sent[0][0] == "billing@ops.local"


def test_digiest_summarizes_usage():
sender = _Recorder()
service = alerts.AlertService(sender=sender.send)
service.record_usage(user_id="u5", amount=15)
service.record_usage(user_id="u5", amount=25)

digest = service.build_digest(user_id="u5")
assert "40" in str(digest)

service.send_digest(user_id="u5", recipient="u5@local")
assert len(sender.sent) == 1
assert sender.sent[0][0] == "u5@local"
Loading