diff --git a/src/firebase_functions/private/_alerts_fn.py b/src/firebase_functions/private/_alerts_fn.py index b7796ee..2a9408a 100644 --- a/src/firebase_functions/private/_alerts_fn.py +++ b/src/firebase_functions/private/_alerts_fn.py @@ -14,6 +14,7 @@ """Internal utilities for Firebase Alert function types.""" # pylint: disable=protected-access,cyclic-import +import datetime as _dt import typing as _typing import cloudevents.http as _ce @@ -129,11 +130,39 @@ def trending_issue_details_from_ce_payload(payload: dict): ) +def _parse_digest_date(digest_date: _typing.Any) -> _dt.datetime: + """Parses a Crashlytics digestDate into a timezone-aware UTC datetime.""" + if isinstance(digest_date, dict): + if "year" in digest_date and "month" in digest_date and "day" in digest_date: + year, month, day = ( + digest_date["year"], + digest_date["month"], + digest_date["day"], + ) + if year is None or month is None or day is None: + raise ValueError(f"Invalid calendar date fields cannot be None: {digest_date}") + try: + return _dt.datetime( + int(year), + int(month), + int(day), + tzinfo=_dt.timezone.utc, + ) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid calendar date values in {digest_date}: {err}") from err + if "seconds" in digest_date and digest_date["seconds"] is not None: + return _util.timestamp_conversion(digest_date) + raise ValueError(f"Unrecognized dictionary format for digestDate: {digest_date}") + if isinstance(digest_date, str): + return _util.timestamp_conversion(digest_date) + raise ValueError(f"Invalid digestDate type: {type(digest_date)}") + + def stability_digest_payload_from_ce_payload(payload: dict): from firebase_functions.alerts.crashlytics_fn import StabilityDigestPayload return StabilityDigestPayload( - digest_date=_util.timestamp_conversion(payload["digestDate"]), + digest_date=_parse_digest_date(payload["digestDate"]), trending_issues=[ trending_issue_details_from_ce_payload(issue) for issue in payload["trendingIssues"] ], diff --git a/src/firebase_functions/private/util.py b/src/firebase_functions/private/util.py index d91d13e..fbdef13 100644 --- a/src/firebase_functions/private/util.py +++ b/src/firebase_functions/private/util.py @@ -369,8 +369,30 @@ def normalize_timestamp_string(time: str) -> str: return f"{prefix}.{digits[:6]}{timezone}" -def timestamp_conversion(time: str) -> _dt.datetime: - """Converts an ISO 8601 timestamp and returns a timezone-aware datetime object.""" +def timestamp_conversion(time: str | dict) -> _dt.datetime: + """Converts an ISO 8601 timestamp or dictionary and returns a timezone-aware datetime object.""" + if isinstance(time, dict): + seconds = time.get("seconds") + if seconds is None: + raise ValueError( + f"Unrecognized timestamp dictionary format (missing 'seconds'): {time}" + ) + try: + seconds_int = int(seconds) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid 'seconds' value in timestamp: {seconds}") from err + + nanos_val = time.get("nanos") + if nanos_val is None: + nanos_val = time.get("nanoseconds") + try: + nanos = int(nanos_val) if nanos_val is not None else 0 + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid 'nanos' value in timestamp: {nanos_val}") from err + + base_dt = _dt.datetime.fromtimestamp(seconds_int, tz=_dt.timezone.utc) + return base_dt + _dt.timedelta(microseconds=nanos // 1000) + normalized_time = normalize_timestamp_string(time) if "." not in normalized_time: return _dt.datetime.strptime(normalized_time, "%Y-%m-%dT%H:%M:%S%z") diff --git a/tests/test_alerts_fn.py b/tests/test_alerts_fn.py new file mode 100644 index 0000000..ac23c57 --- /dev/null +++ b/tests/test_alerts_fn.py @@ -0,0 +1,157 @@ +# Copyright 2024 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests for internal alerts utility functions. +""" + +import datetime as _dt + +import pytest + +from firebase_functions.private._alerts_fn import ( + _parse_digest_date, + stability_digest_payload_from_ce_payload, +) + + +def test_parse_digest_date_valid_calendar_date(): + """ + Testing that valid {year, month, day} calendar dates are parsed + into timezone-aware UTC datetime at midnight. + """ + date_dict = {"year": 2026, "month": 5, "day": 15} + expected = _dt.datetime(2026, 5, 15, 0, 0, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(date_dict) == expected + + +def test_parse_digest_date_valid_timestamp_dict(): + """ + Testing that valid timestamp dictionaries are accepted. + """ + ts_dict = {"seconds": 1712750400, "nanos": 0} + expected = _dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(ts_dict) == expected + + ts_dict_nanos = {"seconds": 1712750400, "nanos": 123456000} + expected_nanos = _dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(ts_dict_nanos) == expected_nanos + + +def test_parse_digest_date_valid_iso_string(): + """ + Testing that valid ISO 8601 string representations are supported. + """ + iso_str = "2026-05-15T00:00:00Z" + expected = _dt.datetime(2026, 5, 15, 0, 0, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(iso_str) == expected + + +def test_parse_digest_date_missing_fields_raises(): + """ + Testing that missing calendar-date fields raise ValueError. + """ + incomplete_dates = [ + {"year": 2026, "month": 5}, + {"year": 2026, "day": 15}, + {"month": 5, "day": 15}, + ] + for d in incomplete_dates: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_explicit_none_raises(): + """ + Testing that explicit None calendar-date fields raise ValueError. + """ + none_dates = [ + {"year": None, "month": 5, "day": 15}, + {"year": 2026, "month": None, "day": 15}, + {"year": 2026, "month": 5, "day": None}, + ] + for d in none_dates: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_invalid_values_raises(): + """ + Testing that invalid month, day, or non-integer values raise ValueError. + """ + invalid_dates = [ + {"year": 2026, "month": 13, "day": 15}, + {"year": 2026, "month": 2, "day": 30}, + {"year": 2026, "month": 0, "day": 15}, + {"year": 2026, "month": 5, "day": 32}, + {"year": "invalid", "month": 5, "day": 15}, + ] + for d in invalid_dates: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_unrecognized_dict_raises(): + """ + Testing that empty or unknown dictionaries raise ValueError. + """ + bad_dicts = [ + {}, + {"foo": "bar"}, + {"digestDate": "2026-05-15"}, + ] + for d in bad_dicts: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_invalid_types_raises(): + """ + Testing that non-string and non-dict types raise ValueError. + """ + bad_types = [ + 12345, + 123.45, + ["2026", "05", "15"], + None, + ] + for val in bad_types: + with pytest.raises(ValueError): + _parse_digest_date(val) + + +def test_stability_digest_payload_from_ce_payload(): + """ + Testing stability_digest_payload_from_ce_payload parses full payload with calendar date. + """ + ce_payload = { + "digestDate": {"year": 2026, "month": 5, "day": 15}, + "trendingIssues": [ + { + "type": "crash", + "issue": { + "id": "issue-123", + "title": "NullPointerException", + "subtitle": "MainActivity.kt:42", + "appVersion": "1.0.0", + }, + "eventCount": 42, + "userCount": 10, + } + ], + } + payload = stability_digest_payload_from_ce_payload(ce_payload) + assert payload.digest_date == _dt.datetime(2026, 5, 15, 0, 0, tzinfo=_dt.timezone.utc) + assert len(payload.trending_issues) == 1 + assert payload.trending_issues[0].event_count == 42 + assert payload.trending_issues[0].issue.id == "issue-123" diff --git a/tests/test_util.py b/tests/test_util.py index 71ce614..2d25331 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -18,6 +18,8 @@ import datetime as _dt from os import environ, path +import pytest + from firebase_functions.private.util import ( _unsafe_decode_id_token, deep_merge, @@ -57,6 +59,18 @@ def test_timestamp_conversion_supported_formats(): Testing shared timestamp conversion handles supported RTDB and CloudEvent formats. """ timestamps = [ + ( + {"seconds": 1712750400, "nanos": 0}, + _dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc), + ), + ( + {"seconds": 1712750400, "nanos": 123456000}, + _dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc), + ), + ( + {"seconds": 1712750400, "nanoseconds": 123456000}, + _dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc), + ), ( "2024-04-10T12:00:00.000Z", _dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc), @@ -118,6 +132,23 @@ def test_timestamp_conversion_supported_formats(): assert timestamp_conversion(input_timestamp) == expected_datetime +def test_timestamp_conversion_unrecognized_dict_raises(): + """ + Testing that unrecognized or incomplete timestamp dictionaries raise ValueError. + """ + unrecognized_dicts = [ + {}, + {"foo": "bar"}, + {"nanos": 123456000}, + {"seconds": None}, + {"seconds": "invalid"}, + {"seconds": 1712750400, "nanos": "invalid"}, + ] + for d in unrecognized_dicts: + with pytest.raises(ValueError): + timestamp_conversion(d) + + def test_normalize_document_path(): """ Testing "document" path passed to Firestore event listener