mail notification

This commit is contained in:
Bastian Wagner
2026-08-15 20:47:02 +02:00
parent 7c9e19ba0b
commit 2aba1265af
16 changed files with 519 additions and 2 deletions

109
tests/db/test_session.py Normal file
View File

@@ -0,0 +1,109 @@
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.pool import StaticPool
from app.db.models import Base
from app.db.session import initialize_schema
def test_initialize_schema_creates_fresh_database() -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
initialize_schema(engine)
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns
assert "notification_email" in columns
finally:
engine.dispose()
def test_initialize_schema_adds_missing_columns_without_dropping_existing_rows() -> None:
"""Regression test: a database created before notify_email_enabled/
notification_email existed must gain those columns in place, keeping
every already-stored user row intact -- create_all() alone would not add
columns to a table that already exists."""
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
# Simulate the pre-existing production schema by creating every
# table via the current models, then dropping the two new columns
# back off sync_users the only way sqlite allows: rebuild the table.
Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.execute(text("ALTER TABLE sync_users RENAME TO sync_users_old"))
conn.execute(
text(
"""
CREATE TABLE sync_users (
id INTEGER PRIMARY KEY,
name VARCHAR(120) NOT NULL,
enabled BOOLEAN NOT NULL,
health_state VARCHAR NOT NULL,
mywhoosh_state VARCHAR(32) NOT NULL,
garmin_state VARCHAR(32) NOT NULL,
action_reason TEXT,
mywhoosh_email_enc TEXT NOT NULL,
mywhoosh_password_enc TEXT NOT NULL,
garmin_email_enc TEXT NOT NULL,
garmin_password_enc TEXT NOT NULL,
created_at DATETIME,
updated_at DATETIME
)
"""
)
)
conn.execute(
text(
"""
INSERT INTO sync_users (
id, name, enabled, health_state, mywhoosh_state, garmin_state,
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
garmin_email_enc, garmin_password_enc, created_at, updated_at
)
SELECT id, name, enabled, health_state, mywhoosh_state, garmin_state,
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
garmin_email_enc, garmin_password_enc, created_at, updated_at
FROM sync_users_old
"""
)
)
conn.execute(text("DROP TABLE sync_users_old"))
conn.execute(
text(
"""
INSERT INTO sync_users (
id, name, enabled, health_state, mywhoosh_state, garmin_state,
mywhoosh_email_enc, mywhoosh_password_enc, garmin_email_enc, garmin_password_enc
) VALUES (
1, 'Existing User', 1, 'healthy', 'connected', 'connected',
'enc-mw-email', 'enc-mw-pass', 'enc-garmin-email', 'enc-garmin-pass'
)
"""
)
)
columns_before = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" not in columns_before
initialize_schema(engine)
columns_after = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns_after
assert "notification_email" in columns_after
with engine.connect() as conn:
row = conn.execute(text("SELECT name, notify_email_enabled, notification_email FROM sync_users")).one()
assert row.name == "Existing User"
assert row.notify_email_enabled == 0
assert row.notification_email is None
finally:
engine.dispose()
def test_initialize_schema_is_idempotent() -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
initialize_schema(engine)
initialize_schema(engine)
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns
finally:
engine.dispose()

View File

@@ -0,0 +1,69 @@
from unittest.mock import MagicMock, patch
from app.notifications.emailer import EmailNotifier
def _notifier(**overrides) -> EmailNotifier:
defaults = dict(
host="smtp.example.com",
port=587,
username="user@example.com",
password="secret",
from_address="sync@example.com",
use_tls=True,
)
defaults.update(overrides)
return EmailNotifier(**defaults)
def test_unconfigured_notifier_is_not_configured() -> None:
notifier = _notifier(host=None)
assert notifier.configured is False
def test_configured_notifier_is_configured() -> None:
assert _notifier().configured is True
def test_send_skips_silently_when_not_configured() -> None:
notifier = _notifier(host=None)
with patch("app.notifications.emailer.smtplib.SMTP") as smtp_cls:
notifier.send(to_address="user@example.com", subject="s", body="b")
smtp_cls.assert_not_called()
def test_send_uses_starttls_and_login_when_configured() -> None:
notifier = _notifier()
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance) as smtp_cls:
notifier.send(to_address="rider@example.com", subject="Action required", body="Check the dashboard")
smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10)
smtp_instance.starttls.assert_called_once()
smtp_instance.login.assert_called_once_with("user@example.com", "secret")
assert smtp_instance.send_message.call_count == 1
sent_message = smtp_instance.send_message.call_args[0][0]
assert sent_message["To"] == "rider@example.com"
assert sent_message["From"] == "sync@example.com"
assert sent_message["Subject"] == "Action required"
def test_send_skips_login_without_credentials() -> None:
notifier = _notifier(username=None, password=None)
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
notifier.send(to_address="rider@example.com", subject="s", body="b")
smtp_instance.login.assert_not_called()
def test_send_skips_starttls_when_disabled() -> None:
notifier = _notifier(use_tls=False)
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
notifier.send(to_address="rider@example.com", subject="s", body="b")
smtp_instance.starttls.assert_not_called()

View File

@@ -28,3 +28,11 @@ class FakeGarminUploader:
if self.error is not None:
raise self.error
return self.result
class FakeNotifier:
def __init__(self) -> None:
self.sent: list[dict] = []
def send(self, *, to_address: str, subject: str, body: str) -> None:
self.sent.append({"to_address": to_address, "subject": subject, "body": body})

View File

@@ -10,7 +10,7 @@ from app.mywhoosh.client import MyWhooshDeviceConflictError
from app.mywhoosh.models import MyWhooshActivity
from app.sync.manager import SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier
@pytest.mark.asyncio
@@ -229,6 +229,82 @@ async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser
assert reloaded.health_state == HealthState.ACTION_REQUIRED
@pytest.mark.asyncio
async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
assert len(notifier.sent) == 1
assert notifier.sent[0]["to_address"] == "alerts@example.com"
assert "another device" in notifier.sent[0]["body"]
@pytest.mark.asyncio
async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None:
user_id = None
with session_factory() as session:
user = _create_user(session, cipher)
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
assert notifier.sent == []
@pytest.mark.asyncio
async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
await manager.sync_user(user_id)
assert len(notifier.sent) == 1
@pytest.mark.asyncio
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
session_factory, cipher, settings, seeded_user: SyncUser

View File

@@ -366,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None:
assert response.status_code == 400
def test_create_user_without_notifications_requires_no_email(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={"csrf_token": csrf, **_create_payload()},
follow_redirects=False,
)
assert response.status_code == 303
def test_create_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={"csrf_token": csrf, **_create_payload(notify_email_enabled="on")},
)
assert response.status_code == 400
def test_create_user_persists_notification_preferences(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={
"csrf_token": csrf,
**_create_payload(notify_email_enabled="on", notification_email="alerts@example.com"),
},
follow_redirects=False,
)
assert response.status_code == 303
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user is not None
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
def test_update_user_can_enable_notifications_without_reentering_passwords(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
edit_page = client.get(f"/users/{user_id}/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
f"/users/{user_id}",
data={
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@example.com",
"mywhoosh_password": "",
"garmin_email": "max-garmin@example.com",
"garmin_password": "",
"enabled": "on",
"notify_email_enabled": "on",
"notification_email": "alerts@example.com",
},
follow_redirects=True,
)
assert response.status_code == 200
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user is not None
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
def test_update_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
edit_page = client.get(f"/users/{user_id}/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
f"/users/{user_id}",
data={
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@example.com",
"mywhoosh_password": "",
"garmin_email": "max-garmin@example.com",
"garmin_password": "",
"enabled": "on",
"notify_email_enabled": "on",
"notification_email": "",
},
)
assert response.status_code == 400
def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)