mail notification
This commit is contained in:
109
tests/db/test_session.py
Normal file
109
tests/db/test_session.py
Normal 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()
|
||||
69
tests/notifications/test_emailer.py
Normal file
69
tests/notifications/test_emailer.py
Normal 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()
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user