Files
TimeTracker/tests/test_services/test_notification_service.py
T
Dries Peeters 91127bf188 feat: smart in-app notifications, value dashboard stats, and search scope helpers
Smart notifications (opt-in under user settings): NotificationService builds candidates from the user's local day and active timers; GET /api/notifications and POST /api/notifications/dismiss; migration 150 adds user columns and user_smart_notification_dismissals. /api/summary/today uses the same local-day totals. Client polls from smart-notifications.js; toastManager.show gains onDismiss for server dismiss sync. Config and env.example document SMART_NOTIFY_* variables.

Value dashboard: StatsService with Redis-backed caching, GET /api/stats/value-dashboard, dashboard template and dashboard-enhancements polling alongside existing widgets.

API v1 token search now uses apply_project_scope and apply_client_scope on queries; scope_filter adds apply_project_scope; tests extended for the new helper.
2026-04-15 12:15:23 +02:00

231 lines
7.7 KiB
Python

"""Tests for NotificationService smart notification eligibility."""
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import insert, update
from zoneinfo import ZoneInfo
from app import db
from app.models import TimeEntry, User, UserSmartNotificationDismissal
from app.services.notification_service import (
KIND_LONG_TIMER,
KIND_NO_TRACKING,
NotificationService,
get_today_summary_for_user,
parse_hhmm,
user_local_today_bounds_utc,
)
def _commit_user_prefs(user_id: int, **values):
db.session.execute(update(User).where(User.id == user_id).values(**values))
db.session.commit()
def _insert_time_entry(**kwargs):
"""Insert via Core to avoid ORM audit hooks on TimeEntry."""
defaults = {
"notes": "notif_test",
"source": "manual",
"billable": True,
"paid": False,
"break_seconds": 0,
"tags": None,
"client_id": None,
"task_id": None,
"invoice_number": None,
"paused_at": None,
}
row = {**defaults, **kwargs}
if "created_at" not in row:
row["created_at"] = row["start_time"]
if "updated_at" not in row:
row["updated_at"] = row["start_time"]
db.session.execute(insert(TimeEntry.__table__).values(**row))
db.session.commit()
@pytest.mark.unit
def test_parse_hhmm():
assert parse_hhmm("16:00") == (16, 0)
assert parse_hhmm("09:30") == (9, 30)
assert parse_hhmm("") is None
assert parse_hhmm("25:00") is None
@pytest.mark.unit
def test_build_disabled_returns_empty(app, user):
with app.app_context():
_commit_user_prefs(user.id, smart_notifications_enabled=False)
user = db.session.get(User, user.id)
out = NotificationService.build_for_user(user)
assert out["notifications"] == []
assert out["meta"]["enabled"] is False
@pytest.mark.unit
def test_long_timer_notification(app, user, project):
with app.app_context():
_commit_user_prefs(
user.id,
smart_notifications_enabled=True,
smart_notify_long_timer=True,
smart_notify_no_tracking=False,
smart_notify_daily_summary=False,
timezone="UTC",
)
user = db.session.get(User, user.id)
now_utc = datetime(2026, 6, 10, 18, 0, 0, tzinfo=timezone.utc)
started = datetime(2026, 6, 10, 12, 0, 0)
_insert_time_entry(
user_id=user.id,
project_id=project.id,
start_time=started,
end_time=None,
duration_seconds=None,
)
out = NotificationService.build_for_user(user, now_utc=now_utc)
kinds = [n["kind"] for n in out["notifications"]]
assert KIND_LONG_TIMER in kinds
@pytest.mark.unit
def test_no_tracking_in_slot(app, user, project):
with app.app_context():
_commit_user_prefs(
user.id,
smart_notifications_enabled=True,
smart_notify_no_tracking=True,
smart_notify_long_timer=False,
smart_notify_daily_summary=False,
timezone="Europe/Rome",
)
user = db.session.get(User, user.id)
now_utc = datetime(2026, 6, 10, 14, 5, 0, tzinfo=timezone.utc)
with patch("app.utils.timezone.now_in_user_timezone") as m_now:
m_now.return_value = datetime(2026, 6, 10, 16, 5, tzinfo=ZoneInfo("Europe/Rome"))
out = NotificationService.build_for_user(user, now_utc=now_utc)
kinds = [n["kind"] for n in out["notifications"]]
assert KIND_NO_TRACKING in kinds
@pytest.mark.unit
def test_no_tracking_suppressed_when_timer_active(app, user, project):
with app.app_context():
_commit_user_prefs(
user.id,
smart_notifications_enabled=True,
smart_notify_no_tracking=True,
smart_notify_long_timer=False,
smart_notify_daily_summary=False,
timezone="Europe/Rome",
)
user = db.session.get(User, user.id)
now_utc = datetime(2026, 6, 10, 14, 5, 0, tzinfo=timezone.utc)
_insert_time_entry(
user_id=user.id,
project_id=project.id,
start_time=datetime(2026, 6, 10, 15, 0, 0),
end_time=None,
duration_seconds=None,
)
with patch("app.utils.timezone.now_in_user_timezone") as m_now:
m_now.return_value = datetime(2026, 6, 10, 16, 5, tzinfo=ZoneInfo("Europe/Rome"))
out = NotificationService.build_for_user(user, now_utc=now_utc)
kinds = [n["kind"] for n in out["notifications"]]
assert KIND_NO_TRACKING not in kinds
@pytest.mark.unit
def test_dismissal_hides_kind(app, user, project):
with app.app_context():
_commit_user_prefs(
user.id,
smart_notifications_enabled=True,
smart_notify_long_timer=True,
smart_notify_no_tracking=False,
smart_notify_daily_summary=False,
timezone="UTC",
)
user = db.session.get(User, user.id)
now_utc = datetime(2026, 6, 10, 18, 0, 0, tzinfo=timezone.utc)
_, _, local_date = user_local_today_bounds_utc(user)
db.session.execute(
insert(UserSmartNotificationDismissal.__table__).values(
user_id=user.id,
local_date=local_date,
kind=KIND_LONG_TIMER,
dismissed_at=datetime.utcnow(),
)
)
db.session.commit()
_insert_time_entry(
user_id=user.id,
project_id=project.id,
start_time=datetime(2026, 6, 10, 12, 0, 0),
end_time=None,
duration_seconds=None,
)
out = NotificationService.build_for_user(user, now_utc=now_utc)
assert all(n["kind"] != KIND_LONG_TIMER for n in out["notifications"])
@pytest.mark.unit
def test_max_per_day_truncates(app, user, project):
with app.app_context():
app.config["SMART_NOTIFY_MAX_PER_DAY"] = 1
_commit_user_prefs(
user.id,
smart_notifications_enabled=True,
smart_notify_long_timer=True,
smart_notify_no_tracking=True,
smart_notify_daily_summary=True,
timezone="Europe/Rome",
)
user = db.session.get(User, user.id)
now_utc = datetime(2026, 6, 10, 14, 5, 0, tzinfo=timezone.utc)
_insert_time_entry(
user_id=user.id,
project_id=project.id,
start_time=datetime(2026, 6, 10, 12, 0, 0),
end_time=None,
duration_seconds=None,
)
with patch("app.utils.timezone.now_in_user_timezone") as m_now:
m_now.return_value = datetime(2026, 6, 10, 16, 5, tzinfo=ZoneInfo("Europe/Rome"))
out = NotificationService.build_for_user(user, now_utc=now_utc)
assert len(out["notifications"]) <= 1
if out["notifications"]:
assert out["notifications"][0]["kind"] == KIND_LONG_TIMER
@pytest.mark.unit
def test_get_today_summary_for_user(app, user, project):
with app.app_context():
_commit_user_prefs(user.id, timezone="UTC")
user = db.session.get(User, user.id)
start = datetime(2026, 6, 10, 10, 0, 0)
end = datetime(2026, 6, 10, 11, 0, 0)
_insert_time_entry(
user_id=user.id,
project_id=project.id,
start_time=start,
end_time=end,
duration_seconds=3600,
)
frozen_local = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
with patch("app.utils.timezone.now_in_user_timezone", return_value=frozen_local):
s = get_today_summary_for_user(user)
assert s["hours"] == 1.0
assert s["projects"] == 1