feat(email): add admin notification settings

This commit is contained in:
2026-05-05 13:38:34 +08:00
parent a780c1bf52
commit 73d476bcea
21 changed files with 929 additions and 17 deletions
+26
View File
@@ -13,6 +13,9 @@ from backend.migrations import (
run_pending_migrations,
)
from backend.migration_steps.account_lockout import apply as apply_account_lockout
from backend.migration_steps.email_notification_settings import (
apply as apply_email_notification_settings,
)
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
@@ -75,13 +78,36 @@ def test_existing_migrations_are_registered_in_order() -> None:
assert [migration.id for migration in MIGRATIONS] == [
"2026050401_add_account_lockout",
"2026050402_add_task_thread_id",
"2026050501_add_email_notification_settings",
]
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
"backend.migration_steps.account_lockout",
"backend.migration_steps.task_thread_id",
"backend.migration_steps.email_notification_settings",
]
def test_email_notification_settings_migration_creates_settings_table() -> None:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
with engine.connect() as conn:
apply_email_notification_settings(conn)
columns = {
row[1] for row in conn.execute(text("PRAGMA table_info(email_notification_settings)"))
}
assert {
"smtp_server",
"smtp_port",
"smtp_sender_email",
"smtp_sender_password",
"smtp_use_ssl",
"notify_token_expiring",
"notify_check_in_success",
} <= columns
def test_account_lockout_migration_adds_missing_user_fields() -> None:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import ValidationError
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.api import admin as admin_api
from backend.dependencies import get_current_user, get_db
from backend.models import Base, User
from backend.schemas.email_settings import EmailNotificationSettingsUpdate
from backend.services import email_settings_service
from backend.services.email_service import EmailService
from backend.services.email_settings_service import EmailSettingsService
def make_session():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = session_factory()
return engine, session_factory, session
def test_email_settings_default_row_uses_environment_defaults_and_masks_password(
monkeypatch,
) -> None:
engine, _, session = make_session()
monkeypatch.setattr(email_settings_service.settings, "SMTP_SERVER", "smtp.example.test")
monkeypatch.setattr(email_settings_service.settings, "SMTP_PORT", 2525)
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_EMAIL", "mailer@example.com")
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_PASSWORD", "secret")
monkeypatch.setattr(email_settings_service.settings, "SMTP_USE_SSL", False)
snapshot = EmailSettingsService.get_snapshot(session)
assert snapshot.smtp_server == "smtp.example.test"
assert snapshot.smtp_port == 2525
assert snapshot.smtp_sender_email == "mailer@example.com"
assert snapshot.smtp_sender_password == "secret"
assert snapshot.has_smtp_sender_password is True
session.close()
engine.dispose()
def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> None:
engine, _, session = make_session()
monkeypatch.setattr(email_settings_service.settings, "SMTP_SERVER", "smtp.example.test")
monkeypatch.setattr(email_settings_service.settings, "SMTP_PORT", 2525)
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_EMAIL", "mailer@example.com")
monkeypatch.setattr(email_settings_service.settings, "SMTP_SENDER_PASSWORD", "")
monkeypatch.setattr(email_settings_service.settings, "SMTP_USE_SSL", False)
EmailSettingsService.get_snapshot(session)
EmailSettingsService.update_settings(
session,
EmailNotificationSettingsUpdate(
smtp_server="smtp.changed.test",
smtp_port=587,
smtp_sender_email="ops@example.com",
smtp_use_ssl=True,
notify_token_expiring=False,
notify_check_in_success=False,
smtp_sender_password="new-secret",
),
)
updated = EmailSettingsService.get_snapshot(session)
assert updated.smtp_server == "smtp.changed.test"
assert updated.smtp_port == 587
assert updated.smtp_sender_email == "ops@example.com"
assert updated.smtp_sender_password == "new-secret"
assert updated.notify_token_expiring is False
assert updated.notify_check_in_success is False
EmailSettingsService.update_settings(
session,
EmailNotificationSettingsUpdate(
smtp_server="smtp.changed.test",
smtp_port=587,
smtp_sender_email="ops@example.com",
smtp_use_ssl=True,
notify_token_expiring=False,
notify_check_in_success=False,
clear_smtp_sender_password=True,
),
)
cleared = EmailSettingsService.get_snapshot(session)
assert cleared.smtp_sender_password in ("", None)
assert cleared.has_smtp_sender_password is False
session.close()
engine.dispose()
def test_admin_email_settings_route_rejects_non_admin() -> None:
engine, session_factory, session = make_session()
app = FastAPI()
app.include_router(admin_api.router, prefix="/api/admin")
def override_get_db():
yield session
async def override_get_current_user() -> User:
return User(id=1, alias="user", role="user", is_approved=True)
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
client = TestClient(app)
response = client.get("/api/admin/email_settings")
assert response.status_code == 403
session.close()
engine.dispose()
def test_token_expiring_notification_can_be_disabled(monkeypatch) -> None:
engine, session_factory, session = make_session()
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
EmailSettingsService.update_settings(
session,
EmailNotificationSettingsUpdate(
smtp_server="smtp.example.test",
smtp_port=465,
smtp_sender_email="mailer@example.com",
smtp_use_ssl=True,
notify_token_expiring=False,
notify_check_in_success=True,
),
)
sent: dict[str, object] = {}
def fake_send(*args, **kwargs) -> bool:
sent["called"] = True
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
user = User(alias="Alice", email="alice@example.com")
assert EmailService.notify_token_expiring(user, "1000") is False
assert sent == {}
session.close()
engine.dispose()
def test_success_check_in_notification_can_be_disabled(monkeypatch) -> None:
engine, session_factory, session = make_session()
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
EmailSettingsService.update_settings(
session,
EmailNotificationSettingsUpdate(
smtp_server="smtp.example.test",
smtp_port=465,
smtp_sender_email="mailer@example.com",
smtp_use_ssl=True,
notify_token_expiring=True,
notify_check_in_success=False,
),
)
sent: dict[str, object] = {}
def fake_send(*args, **kwargs) -> bool:
sent["called"] = True
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
user = User(alias="Alice", email="alice@example.com")
assert (
EmailService.notify_check_in_result(
user,
{"thread_id": "thread-1"},
True,
"打卡成功",
)
is False
)
assert sent == {}
session.close()
engine.dispose()
def test_email_settings_update_validates_sender_email() -> None:
with pytest.raises(ValidationError):
EmailNotificationSettingsUpdate(
smtp_server="smtp.example.test",
smtp_port=465,
smtp_sender_email="not-an-email",
smtp_use_ssl=True,
notify_token_expiring=True,
notify_check_in_success=True,
)
+8
View File
@@ -29,6 +29,7 @@ def test_frontend_has_business_app_structure() -> None:
"views/admin/AdminRecordsView.vue",
"views/admin/AdminLogsView.vue",
"views/admin/AdminStatsView.vue",
"views/admin/AdminEmailSettingsView.vue",
"utils/format.ts",
]
@@ -53,10 +54,17 @@ def test_frontend_routes_cover_user_and_admin_workflows() -> None:
"/admin/records",
"/admin/logs",
"/admin/stats",
"/admin/email-settings",
]:
assert path in router
def test_frontend_admin_api_covers_email_settings() -> None:
api = (SRC_ROOT / "api" / "index.ts").read_text(encoding="utf-8")
assert "/api/admin/email_settings" in api
def test_frontend_replaces_starter_component() -> None:
app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8")