Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/firebase_functions/private/_alerts_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
],
Expand Down
26 changes: 24 additions & 2 deletions src/firebase_functions/private/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
157 changes: 157 additions & 0 deletions tests/test_alerts_fn.py
Original file line number Diff line number Diff line change
@@ -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"
31 changes: 31 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
Loading