From c37bb44e0ac60f094e86832e395450a657ae2891 Mon Sep 17 00:00:00 2001 From: alexjanousekGSA Date: Wed, 26 Feb 2025 11:11:47 -0500 Subject: [PATCH] Added unit tests for new utc conversion logic --- Makefile | 5 + app/assets/javascripts/activityChart.js | 8 +- app/main/views/dashboard.py | 46 ++++++- tests/app/main/views/test_dashboard.py | 155 ++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 66ce809bb..8fa5364a0 100644 --- a/Makefile +++ b/Makefile @@ -162,3 +162,8 @@ upload-static: # @cf map-route notify-admin ${DNS_NAME} --hostname www # @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www # @echo "Failwhale is disabled" + +.PHONY: test-single +test-single: export NEW_RELIC_ENVIRONMENT=test +test-single: ## Run a single test file + poetry run pytest $(TEST_FILE) diff --git a/app/assets/javascripts/activityChart.js b/app/assets/javascripts/activityChart.js index efdda477f..17fcae7ce 100644 --- a/app/assets/javascripts/activityChart.js +++ b/app/assets/javascripts/activityChart.js @@ -213,7 +213,13 @@ return; } - var url = type === 'service' ? `/services/${currentServiceId}/daily-stats.json` : `/services/${currentServiceId}/daily-stats-by-user.json`; + var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + var url = type === 'service' + ? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}` + : `/services/${currentServiceId}/daily-stats-by-user.json`; + + return fetch(url) .then(response => { if (!response.ok) { diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 6b5edf6f4..3250c0102 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -1,7 +1,8 @@ import calendar -from datetime import datetime +from datetime import datetime, timedelta from functools import partial from itertools import groupby +from zoneinfo import ZoneInfo from flask import Response, abort, jsonify, render_template, request, session, url_for from flask_login import current_user @@ -80,10 +81,47 @@ def service_dashboard(service_id): @user_has_permissions() def get_daily_stats(service_id): date_range = get_stats_date_range() - stats = service_api_client.get_service_notification_statistics_by_day( - service_id, start_date=date_range["start_date"], days=date_range["days"] + days = date_range["days"] + user_timezone = request.args.get("timezone", "UTC") + + stats_utc = service_api_client.get_service_notification_statistics_by_day( + service_id, + start_date=date_range["start_date"], + days=days, ) - return jsonify(stats) + + local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days) + return jsonify(local_stats) + + +def get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days): + tz = ZoneInfo(user_timezone) + today_local = datetime.now(tz).date() + start_local = today_local - timedelta(days=days - 1) + + # Generate exactly days local dates, each with zeroed stats + days_list = [ + (start_local + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(days) + ] + aggregator = { + d: { + "sms": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + } + for d in days_list + } + + # Convert each UTC timestamp to local date and iterate + for utc_ts, data in stats_utc.items(): + utc_dt = datetime.strptime(utc_ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=ZoneInfo("UTC")) + local_day = utc_dt.astimezone(tz).strftime("%Y-%m-%d") + + if local_day in aggregator: + for msg_type in ["sms", "email"]: + for status in ["delivered", "failure", "pending", "requested"]: + aggregator[local_day][msg_type][status] += data[msg_type][status] + + return aggregator @main.route("/services//daily-stats-by-user.json") diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index 461888c5c..fbf2f6d5e 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -1,6 +1,8 @@ import copy import json from datetime import datetime +from unittest.mock import patch +from zoneinfo import ZoneInfo import pytest from flask import url_for @@ -12,6 +14,7 @@ from app.main.views.dashboard import ( aggregate_template_usage, format_monthly_stats_to_list, get_dashboard_totals, + get_local_daily_stats_for_last_x_days, get_tuples_of_financial_years, ) from tests import ( @@ -1945,3 +1948,155 @@ def test_service_dashboard_shows_batched_jobs( assert len(rows) == 1 assert job_table_body is not None + + +@patch("app.main.views.dashboard.datetime", wraps=datetime) +def test_simple_local_conversion(mock_dt): + stats_utc = { + "2025-02-24T15:00:00Z": { + "sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + }, + "2025-02-25T07:00:00Z": { + "sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + }, + } + + # Mock today's date in local time: 2025-02-25 at 10:00 + mock_dt.now.return_value = datetime(2025, 2, 25, 10, 0, 0, tzinfo=ZoneInfo("America/New_York")) + + local_stats = get_local_daily_stats_for_last_x_days( + stats_utc, + "America/New_York", + days=2 + ) + + assert len(local_stats) == 2 + assert "2025-02-24" in local_stats + assert "2025-02-25" in local_stats + + assert local_stats["2025-02-24"]["sms"]["delivered"] == 1 + assert local_stats["2025-02-24"]["sms"]["requested"] == 1 + assert local_stats["2025-02-25"]["sms"]["delivered"] == 2 + assert local_stats["2025-02-25"]["sms"]["requested"] == 2 + + +def test_no_timestamps_returns_zeroed_days(): + stats_utc = {} + + class MockDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 2, 26, 8, 0, 0, tzinfo=tz) + + with patch("app.main.views.dashboard.datetime", MockDateTime): + local_stats = get_local_daily_stats_for_last_x_days( + stats_utc, "America/New_York", days=3 + ) + assert list(local_stats.keys()) == ["2025-02-24", "2025-02-25", "2025-02-26"] + for day in local_stats: + for msg_type in ["sms", "email"]: + for status in ["delivered", "failure", "pending", "requested"]: + assert local_stats[day][msg_type][status] == 0 + + +def test_timestamp_in_future_time_zone(): + stats_utc = { + "2025-02-25T01:00:00Z": { + "sms": {"delivered": 5, "failure": 0, "pending": 0, "requested": 5}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + } + } + + class MockDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 2, 25, 10, 0, 0, tzinfo=tz) + + with patch("app.main.views.dashboard.datetime", MockDateTime): + local_stats = get_local_daily_stats_for_last_x_days( + stats_utc, "Asia/Shanghai", days=1 + ) + + assert list(local_stats.keys()) == ["2025-02-25"] + assert local_stats["2025-02-25"]["sms"]["delivered"] == 5 + + +def test_many_timestamps_one_local_day(): + stats_utc = { + "2025-02-24T05:00:00Z": { + "sms": {"delivered": 2, "failure": 1, "pending": 0, "requested": 3}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + }, + "2025-02-24T09:30:00Z": { + "sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1}, + "email": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2}, + }, + "2025-02-24T16:59:59Z": { + "sms": {"delivered": 4, "failure": 0, "pending": 0, "requested": 4}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + }, + } + + class MockDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 2, 24, 20, 0, 0, tzinfo=tz) + + with patch("app.main.views.dashboard.datetime", MockDateTime): + local_stats = get_local_daily_stats_for_last_x_days( + stats_utc, "America/New_York", days=1 + ) + + assert list(local_stats.keys()) == ["2025-02-24"] + assert local_stats["2025-02-24"]["sms"]["delivered"] == 7 + assert local_stats["2025-02-24"]["sms"]["requested"] == 8 + assert local_stats["2025-02-24"]["email"]["delivered"] == 2 + assert local_stats["2025-02-24"]["email"]["requested"] == 2 + + +def test_local_conversion_phoenix(): + """Test aggregator logic in Mountain Time, no DST (America/Phoenix).""" + stats_utc = { + "2025-02-25T01:00:00Z": { + "sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + }, + "2025-02-25T12:00:00Z": { + "sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2}, + "email": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1}, + }, + } + + with patch("app.main.views.dashboard.datetime", wraps=datetime) as mock_dt: + mock_dt.now.return_value = datetime(2025, 2, 25, 12, 0, 0, tzinfo=ZoneInfo("America/Phoenix")) + local_stats = get_local_daily_stats_for_last_x_days(stats_utc, "America/Phoenix", days=1) + + assert len(local_stats) == 1 + (day_key,) = local_stats.keys() + sms_delivered = local_stats[day_key]["sms"]["delivered"] + assert sms_delivered in (2, 3, 4) + + +def test_local_conversion_honolulu(): + """Test aggregator logic in Hawaii (Pacific/Honolulu).""" + stats_utc = { + "2025-02-25T03:00:00Z": { + "sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + }, + "2025-02-25T21:00:00Z": { + "sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1}, + "email": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2}, + }, + } + + with patch("app.main.views.dashboard.datetime", wraps=datetime) as mock_dt: + mock_dt.now.return_value = datetime(2025, 2, 25, 12, 0, 0, tzinfo=ZoneInfo("Pacific/Honolulu")) + local_stats = get_local_daily_stats_for_last_x_days(stats_utc, "Pacific/Honolulu", days=1) + + assert len(local_stats) == 1 + (day_key,) = local_stats.keys() + total_requested = local_stats[day_key]["sms"]["requested"] + assert total_requested in (3, 1, 4)