diff --git a/.vscode/bruno-collections/Predictive Services API/FCM/Get Notification Settings.bru b/.vscode/bruno-collections/Predictive Services API/FCM/Get Notification Settings.bru new file mode 100644 index 0000000000..81d8ae5911 --- /dev/null +++ b/.vscode/bruno-collections/Predictive Services API/FCM/Get Notification Settings.bru @@ -0,0 +1,19 @@ +meta { + name: Get Notification Settings + type: http + seq: 1 +} + +get { + url: {{API_BASE_URL}}/device/notification-settings?device_id=9242842270074025 + body: none + auth: bearer +} + +params:query { + device_id: 9242842270074025 +} + +auth:bearer { + token: {{AUTH_TOKEN}} +} diff --git a/.vscode/bruno-collections/Predictive Services API/FCM/Update Notification Settings.bru b/.vscode/bruno-collections/Predictive Services API/FCM/Update Notification Settings.bru new file mode 100644 index 0000000000..5af98c2a82 --- /dev/null +++ b/.vscode/bruno-collections/Predictive Services API/FCM/Update Notification Settings.bru @@ -0,0 +1,22 @@ +meta { + name: Update Notification Settings + type: http + seq: 2 +} + +post { + url: {{API_BASE_URL}}/device/notification-settings + body: json + auth: bearer +} + +auth:bearer { + token: {{AUTH_TOKEN}} +} + +body:json { + { + "device_id": "9242842270074025", + "fire_zone_source_ids": ["24"] + } +} diff --git a/.vscode/bruno-collections/Predictive Services API/environments/dev.bru b/.vscode/bruno-collections/Predictive Services API/environments/dev.bru new file mode 100644 index 0000000000..bc24c4ca1b --- /dev/null +++ b/.vscode/bruno-collections/Predictive Services API/environments/dev.bru @@ -0,0 +1,6 @@ +vars { + API_BASE_URL: https://wps-pr-5246-e1e498-dev.apps.silver.devops.gov.bc.ca/api +} +vars:secret [ + AUTH_TOKEN +] diff --git a/backend/packages/wps-api/pyproject.toml b/backend/packages/wps-api/pyproject.toml index 6ca5ded2a7..f9b0acafae 100644 --- a/backend/packages/wps-api/pyproject.toml +++ b/backend/packages/wps-api/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "gdal==3.12.3", "wps-wf1", "earthaccess>=0.15.1", + "firebase-admin>=7.3.0", ] [project.optional-dependencies] diff --git a/backend/packages/wps-api/src/app/auto_spatial_advisory/nats_consumer.py b/backend/packages/wps-api/src/app/auto_spatial_advisory/nats_consumer.py index 22cbd80b2f..0c6ae19e27 100644 --- a/backend/packages/wps-api/src/app/auto_spatial_advisory/nats_consumer.py +++ b/backend/packages/wps-api/src/app/auto_spatial_advisory/nats_consumer.py @@ -4,25 +4,28 @@ import asyncio import json -from datetime import datetime import logging +from datetime import datetime from typing import List -from starlette.background import BackgroundTasks + import nats -from nats.js.api import StreamConfig, RetentionPolicy from nats.aio.msg import Msg +from nats.js.api import RetentionPolicy, StreamConfig +from starlette.background import BackgroundTasks +from wps_shared import config +from wps_shared.utils.time import get_utc_datetime +from wps_shared.wps_logging import configure_logging + from app.auto_spatial_advisory.nats_config import ( + hfi_classify_durable_group, server, - stream_name, sfms_file_subject, + stream_name, subjects, - hfi_classify_durable_group, ) from app.auto_spatial_advisory.process_hfi import RunType from app.auto_spatial_advisory.process_stats import process_sfms_hfi_stats from app.nats_publish import publish -from wps_shared.wps_logging import configure_logging -from wps_shared.utils.time import get_utc_datetime logger = logging.getLogger(__name__) @@ -102,6 +105,13 @@ async def closed_cb(): if __name__ == "__main__": configure_logging() + creds_json = config.get("FCM_CREDS") + if creds_json: + from firebase_admin import credentials, initialize_app + + initialize_app(credentials.Certificate(json.loads(creds_json))) + else: + raise ValueError("FCM_CREDS is not set — Firebase cannot be initialized.") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) asyncio.run(run()) diff --git a/backend/packages/wps-api/src/app/auto_spatial_advisory/process_stats.py b/backend/packages/wps-api/src/app/auto_spatial_advisory/process_stats.py index b124e37a1f..e9f671dc3e 100644 --- a/backend/packages/wps-api/src/app/auto_spatial_advisory/process_stats.py +++ b/backend/packages/wps-api/src/app/auto_spatial_advisory/process_stats.py @@ -1,5 +1,10 @@ +import logging from datetime import date, datetime +from wps_shared.db.crud.auto_spatial_advisory import mark_run_parameter_complete +from wps_shared.db.database import get_async_write_session_scope +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum + from app.auto_spatial_advisory.critical_hours import calculate_critical_hours from app.auto_spatial_advisory.hfi_minimum_wind_speed import process_hfi_min_wind_speed from app.auto_spatial_advisory.hfi_percent_conifer import process_hfi_percent_conifer @@ -8,8 +13,9 @@ from app.auto_spatial_advisory.process_hfi import RunType, process_hfi from app.auto_spatial_advisory.process_high_hfi_area import process_high_hfi_area from app.auto_spatial_advisory.process_zone_status import process_zone_statuses -from wps_shared.db.crud.auto_spatial_advisory import mark_run_parameter_complete -from wps_shared.db.database import get_async_write_session_scope +from app.fcm.notifications import trigger_notifications + +logger = logging.getLogger(__name__) async def process_sfms_hfi_stats(run_type: RunType, run_datetime: datetime, for_date: date): @@ -24,3 +30,16 @@ async def process_sfms_hfi_stats(run_type: RunType, run_datetime: datetime, for_ async with get_async_write_session_scope() as session: await mark_run_parameter_complete(session, run_type, run_datetime, for_date) + + try: + async with get_async_write_session_scope() as session: + await trigger_notifications( + session, RunTypeEnum(run_type.value), run_datetime, for_date + ) + except Exception: + logger.exception( + "Failed to send FCM notifications for run_type=%s run_datetime=%s for_date=%s.", + run_type, + run_datetime, + for_date, + ) diff --git a/backend/packages/wps-api/src/app/fcm/__init__.py b/backend/packages/wps-api/src/app/fcm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/packages/wps-api/src/app/fcm/notifications.py b/backend/packages/wps-api/src/app/fcm/notifications.py new file mode 100644 index 0000000000..fa6fa270ae --- /dev/null +++ b/backend/packages/wps-api/src/app/fcm/notifications.py @@ -0,0 +1,145 @@ +import asyncio +import logging +from datetime import date, datetime, timedelta, timezone + +from firebase_admin import exceptions as firebase_exceptions +from firebase_admin import messaging +from sqlalchemy.ext.asyncio import AsyncSession +from wps_shared import config +from wps_shared.db.crud.auto_spatial_advisory import ZoneAdvisoryStatus, get_zones_with_advisories +from wps_shared.db.crud.fcm import get_device_tokens_for_zone, update_device_tokens_are_active +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum +from wps_shared.utils.time import get_vancouver_now + +logger = logging.getLogger(__name__) + +FCM_BATCH_SIZE = 500 + + +def build_notification_content(zone_with_advisory: ZoneAdvisoryStatus, for_date: date): + return f"{for_date.strftime('%a, %B %-d')} - An advisory has been issued for {zone_with_advisory.placename_label}" + + +def build_notification_title(zone_with_advisory: ZoneAdvisoryStatus): + zone = zone_with_advisory.placename_label.split("-")[0] + return f"Fire Behaviour Advisory, {zone}" + + +def build_fcm_message( + for_date: date, zone_with_advisory: ZoneAdvisoryStatus, device_tokens: list[str] +): + title = build_notification_title(zone_with_advisory) + content = build_notification_content(zone_with_advisory, for_date) + tag = f"advisory-{zone_with_advisory.source_identifier}" + ttl = timedelta(days=2) + apns_expiration = str(int((datetime.now(timezone.utc) + ttl).timestamp())) + message = messaging.MulticastMessage( + notification=messaging.Notification(title=title, body=content), + android=messaging.AndroidConfig( + ttl=ttl, notification=messaging.AndroidNotification(tag=tag) + ), + apns=messaging.APNSConfig( + headers={"apns-expiration": apns_expiration}, + payload=messaging.APNSPayload(aps=messaging.Aps(thread_id=tag)), + ), + tokens=device_tokens, + ) + + return message + + +async def trigger_notifications( + session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date +) -> None: + if run_type == RunTypeEnum.actual: + return + + vancouver_now = get_vancouver_now() + + if for_date != vancouver_now.date(): + logger.info("Skipping FCM notifications: for_date=%s is not today", for_date) + return + + if config.get("ENVIRONMENT") == "production": + if vancouver_now.hour >= 12: + logger.info( + "Skipping FCM notifications: current Vancouver time hour=%d is at or after noon", + vancouver_now.hour, + ) + return + + logger.info("Checking for warnings/advisories to send FCM notifications for") + zones_with_advisories = await get_zones_with_advisories( + session, run_type, run_datetime, for_date + ) + logger.info( + f"{len(zones_with_advisories)} have warnings/advisories, checking for devices to notify" + ) + for zone_with_advisory in zones_with_advisories: + if not zone_with_advisory.placename_label: + logger.error( + "Skipping FCM notification: missing placename_label for zone source_identifier=%s", + zone_with_advisory.source_identifier, + ) + continue + device_tokens = await get_device_tokens_for_zone( + session, zone_with_advisory.source_identifier + ) + if len(device_tokens) == 0: + logger.info(f"No devices subscribed to {zone_with_advisory.placename_label}") + continue + logger.info( + f"{len(device_tokens)} are subscribed to {zone_with_advisory.placename_label} about to notify" + ) + for i in range(0, len(device_tokens), FCM_BATCH_SIZE): + batch = device_tokens[i : i + FCM_BATCH_SIZE] + message = build_fcm_message(for_date, zone_with_advisory, batch) + try: + logger.info(f"Notifiying {len(batch)} devices") + # messaging.send_each_for_multicast is a synchronous blocking call + response = await asyncio.to_thread(messaging.send_each_for_multicast, message) + except firebase_exceptions.FirebaseError: + logger.exception( + "FCM send failed for zone=%s date=%s token_count=%d", + zone_with_advisory.placename_label, + for_date, + len(batch), + ) + continue + await handle_fcm_response( + session, for_date, zone_with_advisory.placename_label, batch, response + ) + + +async def handle_fcm_response( + session: AsyncSession, + for_date: date, + placename_label: str, + device_tokens: list[str], + response: messaging.BatchResponse, +): + logger.info( + f"Received FCM response with successful notifications sent: {response.success_count}" + ) + # Only deactivate permanently invalid tokens (UnregisteredError — token is no longer registered). + # Transient failures (quota, server errors) are not deactivated; the token remains valid. + # Deactivated tokens are re-activated when the app re-registers on open. + permanently_failed = [ + device_tokens[idx] + for idx, resp in enumerate(response.responses) + if not resp.success and isinstance(resp.exception, messaging.UnregisteredError) + ] + transient_failed_count = response.failure_count - len(permanently_failed) + + if response.failure_count > 0: + logger.warning( + "FCM send for zone=%s date=%s: %d permanent failures, %d transient failures out of %d tokens", + placename_label, + for_date, + len(permanently_failed), + transient_failed_count, + len(device_tokens), + ) + + if permanently_failed: + await update_device_tokens_are_active(session, permanently_failed, False) diff --git a/backend/packages/wps-api/src/app/main.py b/backend/packages/wps-api/src/app/main.py index a5cabe88b2..19c8770403 100644 --- a/backend/packages/wps-api/src/app/main.py +++ b/backend/packages/wps-api/src/app/main.py @@ -88,7 +88,6 @@ # We recommend adjusting this value in production. profiles_sample_rate=0.5, ) - # This is the api app. api = FastAPI(title="Predictive Services API", description=API_INFO, version="0.0.0") diff --git a/backend/packages/wps-api/src/app/routers/fcm.py b/backend/packages/wps-api/src/app/routers/fcm.py index 6982670537..2d220c1712 100644 --- a/backend/packages/wps-api/src/app/routers/fcm.py +++ b/backend/packages/wps-api/src/app/routers/fcm.py @@ -96,6 +96,7 @@ async def update_notification_settings( if not found: logger.error("Notification settings update for unknown device_id: %s", request.device_id) raise HTTPException(status_code=404, detail=f"Device not found: {request.device_id}") + await session.flush() fire_zone_source_ids = await get_notification_settings_for_device( session, request.device_id ) diff --git a/backend/packages/wps-api/src/app/tests/auto_spatial_advisory/test_process_stats.py b/backend/packages/wps-api/src/app/tests/auto_spatial_advisory/test_process_stats.py index a98678066f..5c81207dd9 100644 --- a/backend/packages/wps-api/src/app/tests/auto_spatial_advisory/test_process_stats.py +++ b/backend/packages/wps-api/src/app/tests/auto_spatial_advisory/test_process_stats.py @@ -1,106 +1,105 @@ -import pytest -from unittest.mock import AsyncMock, patch +from collections.abc import Iterator +from contextlib import ExitStack +from dataclasses import dataclass from datetime import datetime -from app.auto_spatial_advisory.process_stats import process_sfms_hfi_stats +from typing import cast +from unittest.mock import AsyncMock, patch + +import pytest + from app.auto_spatial_advisory.process_hfi import RunType +from app.auto_spatial_advisory.process_stats import process_sfms_hfi_stats + +RUN_DATETIME = datetime(2025, 1, 1, 12, 0, 0) +FOR_DATE = datetime(2025, 1, 1).date() + +PROCESSING_STEPS = [ + "process_hfi", + "process_hfi_elevation", + "process_high_hfi_area", + "process_fuel_type_hfi_by_shape", + "process_hfi_min_wind_speed", + "process_hfi_percent_conifer", + "calculate_critical_hours", + "process_zone_statuses", +] + + +@dataclass +class ProcessStatsMocks: + process_hfi: AsyncMock + process_hfi_elevation: AsyncMock + process_high_hfi_area: AsyncMock + process_fuel_type_hfi_by_shape: AsyncMock + process_hfi_min_wind_speed: AsyncMock + process_hfi_percent_conifer: AsyncMock + calculate_critical_hours: AsyncMock + process_zone_statuses: AsyncMock + mark_run_parameter_complete: AsyncMock + trigger_notifications: AsyncMock + + +@pytest.fixture +def mocks() -> Iterator[ProcessStatsMocks]: + """Patch all processing steps, session scope, and notifications.""" + with ExitStack() as stack: + + def patch_async(target: str) -> AsyncMock: + return cast(AsyncMock, stack.enter_context(patch(target, new_callable=AsyncMock))) + + base = "app.auto_spatial_advisory.process_stats." + mock_scope = stack.enter_context(patch(base + "get_async_write_session_scope")) + mock_scope.return_value.__aenter__.return_value = AsyncMock() + yield ProcessStatsMocks( + process_hfi=patch_async(base + "process_hfi"), + process_hfi_elevation=patch_async(base + "process_hfi_elevation"), + process_high_hfi_area=patch_async(base + "process_high_hfi_area"), + process_fuel_type_hfi_by_shape=patch_async(base + "process_fuel_type_hfi_by_shape"), + process_hfi_min_wind_speed=patch_async(base + "process_hfi_min_wind_speed"), + process_hfi_percent_conifer=patch_async(base + "process_hfi_percent_conifer"), + calculate_critical_hours=patch_async(base + "calculate_critical_hours"), + process_zone_statuses=patch_async(base + "process_zone_statuses"), + mark_run_parameter_complete=patch_async(base + "mark_run_parameter_complete"), + trigger_notifications=patch_async(base + "trigger_notifications"), + ) + + +@pytest.mark.anyio +async def test_forecast_run_marks_complete(mocks: ProcessStatsMocks): + await process_sfms_hfi_stats(RunType.FORECAST, RUN_DATETIME, FOR_DATE) + mocks.mark_run_parameter_complete.assert_awaited_once() + mocks.trigger_notifications.assert_awaited_once() + + +@pytest.mark.anyio +async def test_marks_run_complete_on_success(mocks: ProcessStatsMocks): + await process_sfms_hfi_stats(RunType.ACTUAL, RUN_DATETIME, FOR_DATE) + mocks.mark_run_parameter_complete.assert_awaited_once() @pytest.mark.anyio -@patch("app.auto_spatial_advisory.process_stats.process_hfi", new_callable=AsyncMock) -@patch("app.auto_spatial_advisory.process_stats.process_hfi_elevation", new_callable=AsyncMock) -@patch("app.auto_spatial_advisory.process_stats.process_high_hfi_area", new_callable=AsyncMock) -@patch( - "app.auto_spatial_advisory.process_stats.process_fuel_type_hfi_by_shape", new_callable=AsyncMock -) -@patch("app.auto_spatial_advisory.process_stats.process_hfi_min_wind_speed", new_callable=AsyncMock) -@patch( - "app.auto_spatial_advisory.process_stats.process_hfi_percent_conifer", new_callable=AsyncMock -) -@patch("app.auto_spatial_advisory.process_stats.calculate_critical_hours", new_callable=AsyncMock) -@patch( - "app.auto_spatial_advisory.process_stats.mark_run_parameter_complete", new_callable=AsyncMock -) -@patch("app.auto_spatial_advisory.process_stats.process_zone_statuses", new_callable=AsyncMock) -@patch("app.auto_spatial_advisory.process_stats.get_async_write_session_scope") -async def test_process_stats_marks_complete( - mock_session_scope, - mock_mark_complete, - *_, -): - mock_session = AsyncMock() - mock_session_scope.return_value.__aenter__.return_value = mock_session - await process_sfms_hfi_stats( - RunType.FORECAST, datetime(2025, 1, 1, 12, 0, 0), datetime(2025, 1, 1).date() - ) - mock_mark_complete.assert_awaited_once() +async def test_calls_trigger_notifications_after_completion(mocks: ProcessStatsMocks): + await process_sfms_hfi_stats(RunType.ACTUAL, RUN_DATETIME, FOR_DATE) + mocks.trigger_notifications.assert_awaited_once() @pytest.mark.anyio -@pytest.mark.parametrize( - "fail_step", - [ - "process_hfi", - "process_hfi_elevation", - "process_high_hfi_area", - "process_fuel_type_hfi_by_shape", - "process_hfi_min_wind_speed", - "process_hfi_percent_conifer", - "calculate_critical_hours", - "process_zone_statuses", - ], -) -@patch( - "app.auto_spatial_advisory.process_stats.mark_run_parameter_complete", new_callable=AsyncMock -) -@patch( - "app.auto_spatial_advisory.process_stats.get_async_write_session_scope", new_callable=AsyncMock -) -@patch("app.auto_spatial_advisory.process_stats.process_hfi", new_callable=AsyncMock) -@patch("app.auto_spatial_advisory.process_stats.process_hfi_elevation", new_callable=AsyncMock) -@patch("app.auto_spatial_advisory.process_stats.process_high_hfi_area", new_callable=AsyncMock) -@patch( - "app.auto_spatial_advisory.process_stats.process_fuel_type_hfi_by_shape", new_callable=AsyncMock -) -@patch("app.auto_spatial_advisory.process_stats.process_hfi_min_wind_speed", new_callable=AsyncMock) -@patch( - "app.auto_spatial_advisory.process_stats.process_hfi_percent_conifer", new_callable=AsyncMock -) -@patch("app.auto_spatial_advisory.process_stats.calculate_critical_hours", new_callable=AsyncMock) -@patch("app.auto_spatial_advisory.process_stats.process_zone_statuses", new_callable=AsyncMock) -async def test_process_stats_does_not_mark_complete_on_failure( - mock_zone_statuses, - mock_critical_hours, - mock_percent_conifer, - mock_min_wind_speed, - mock_fuel_type_hfi_by_shape, - mock_high_hfi_area, - mock_hfi_elevation, - mock_hfi, - mock_session_scope, - mock_mark_complete, - fail_step, -): - mock_session = AsyncMock() - mock_session_scope.return_value.__aenter__.return_value = mock_session - - # map step names to mocks for easy access - step_mocks = { - "process_hfi": mock_hfi, - "process_hfi_elevation": mock_hfi_elevation, - "process_high_hfi_area": mock_high_hfi_area, - "process_fuel_type_hfi_by_shape": mock_fuel_type_hfi_by_shape, - "process_hfi_min_wind_speed": mock_min_wind_speed, - "process_hfi_percent_conifer": mock_percent_conifer, - "calculate_critical_hours": mock_critical_hours, - "process_zone_statuses": mock_zone_statuses, - } - - # simulate failure in the specified step - step_mocks[fail_step].side_effect = Exception("fail") +async def test_notification_failure_does_not_prevent_completion(mocks: ProcessStatsMocks): + """A Firebase error in trigger_notifications is swallowed — run is still marked complete.""" + mocks.trigger_notifications.side_effect = Exception("Firebase down") + + await process_sfms_hfi_stats(RunType.ACTUAL, RUN_DATETIME, FOR_DATE) + + mocks.mark_run_parameter_complete.assert_awaited_once() + + +@pytest.mark.anyio +@pytest.mark.parametrize("fail_step", PROCESSING_STEPS) +async def test_processing_failure_prevents_completion(mocks: ProcessStatsMocks, fail_step: str): + """A failure in any processing step propagates and prevents marking the run complete.""" + getattr(mocks, fail_step).side_effect = Exception("fail") with pytest.raises(Exception): - await process_sfms_hfi_stats( - RunType.FORECAST, datetime(2025, 1, 1, 12, 0, 0), datetime(2025, 1, 1).date() - ) + await process_sfms_hfi_stats(RunType.ACTUAL, RUN_DATETIME, FOR_DATE) - mock_mark_complete.assert_not_called() + mocks.mark_run_parameter_complete.assert_not_called() diff --git a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py index 6f9cf9e986..27a1743ada 100644 --- a/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py +++ b/backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py @@ -248,6 +248,30 @@ def test_post_notification_settings_unknown_device_returns_404(): assert response.status_code == 404 +@pytest.mark.usefixtures("mock_jwt_decode") +def test_post_notification_settings_flushes_before_read(): + """session.flush() must be called before reading back settings to ensure pending + inserts are visible within the same transaction (autoflush=False).""" + client = TestClient(app.main.app) + + with patch(DB_SESSION) as mock_session_scope: + mock_session = mock_session_scope.return_value.__aenter__.return_value + call_order = [] + async def mock_flush(): call_order.append("flush") + mock_session.flush = mock_flush + with ( + patch(UPSERT_NOTIFICATION_SETTINGS, side_effect=lambda *_: call_order.append("upsert") or True), + patch(GET_NOTIFICATION_SETTINGS, side_effect=lambda *_: call_order.append("read") or ["5"]), + ): + response = client.post( + API_NOTIFICATION_SETTINGS, + json={"device_id": "test_device_id", "fire_zone_source_ids": ["5"]}, + ) + + assert response.status_code == 200 + assert call_order.index("flush") < call_order.index("read") + + @pytest.mark.usefixtures("mock_jwt_decode") @pytest.mark.parametrize("fire_zone_source_ids", [["5", "10"], []]) def test_post_notification_settings_success(fire_zone_source_ids): diff --git a/backend/packages/wps-api/src/app/tests/fcm/test_notifications.py b/backend/packages/wps-api/src/app/tests/fcm/test_notifications.py new file mode 100644 index 0000000000..d4163c1762 --- /dev/null +++ b/backend/packages/wps-api/src/app/tests/fcm/test_notifications.py @@ -0,0 +1,357 @@ +"""Unit tests for FCM notification logic.""" + +from datetime import date, datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from app.fcm.notifications import ( + build_fcm_message, + build_notification_content, + build_notification_title, + handle_fcm_response, + trigger_notifications, +) +from firebase_admin import exceptions as firebase_exceptions +from firebase_admin import messaging +from wps_shared.db.crud.auto_spatial_advisory import ZoneAdvisoryStatus +from wps_shared.db.models.auto_spatial_advisory import RunTypeEnum + +GET_ZONES = "app.fcm.notifications.get_zones_with_advisories" +GET_TOKENS = "app.fcm.notifications.get_device_tokens_for_zone" +UPDATE_TOKENS = "app.fcm.notifications.update_device_tokens_are_active" +SEND_MULTICAST = "app.fcm.notifications.messaging.send_each_for_multicast" +GET_VANCOUVER_NOW = "app.fcm.notifications.get_vancouver_now" + +FOR_DATE = date(2026, 4, 1) +RUN_GET_VANCOUVER_NOW = datetime(2026, 4, 1) + + +@pytest.mark.parametrize( + "for_date, placename_label, expected", + [ + ( + date(2026, 4, 1), + "K2-Kamloops Zone (Kamloops)", + "Wed, April 1 - An advisory has been issued for K2-Kamloops Zone (Kamloops)", + ), + ( + date(2026, 3, 23), + "C5-Chilcotin Zone", + "Mon, March 23 - An advisory has been issued for C5-Chilcotin Zone", + ), + ], +) +def test_build_notification_content(for_date, placename_label, expected): + zone = ZoneAdvisoryStatus( + advisory_shape_id=1, + source_identifier="42", + placename_label=placename_label, + status="advisory", + ) + assert build_notification_content(zone, for_date) == expected + + +@pytest.mark.parametrize( + "placename_label, expected", + [ + ("K2-Kamloops Zone (Kamloops)", "Fire Behaviour Advisory, K2"), + ("C5-Chilcotin Zone", "Fire Behaviour Advisory, C5"), + ], +) +def test_build_notification_title(placename_label, expected): + zone = ZoneAdvisoryStatus( + advisory_shape_id=1, + source_identifier="42", + placename_label=placename_label, + status="advisory", + ) + assert build_notification_title(zone) == expected + + +ENVIRONMENT = "app.fcm.notifications.config" + +@pytest.mark.anyio +@pytest.mark.parametrize( + "environment, vancouver_hour, expect_skipped", + [ + ("production", 15, True), # afternoon >= 12 in prod → skip + ("production", 12, True), # noon == 12 in prod → skip (boundary) + ("production", 8, False), # morning < 12 in prod → proceed + ("development", 15, False), # afternoon outside prod → proceed + ("development", 8, False), # morning outside prod → proceed + ], +) +async def test_trigger_notifications_afternoon_filter(environment, vancouver_hour, expect_skipped): + """Afternoon Vancouver time (hour >= 12) is skipped only in production.""" + session = AsyncMock() + with ( + patch(GET_ZONES, return_value=[]) as mock_get_zones, + patch(GET_VANCOUVER_NOW) as mock_now, + patch(ENVIRONMENT) as mock_config, + ): + mock_now.return_value.date.return_value = FOR_DATE + mock_now.return_value.hour = vancouver_hour + mock_config.get.return_value = environment + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + if expect_skipped: + mock_get_zones.assert_not_called() + else: + mock_get_zones.assert_called_once() + + +@pytest.mark.anyio +async def test_trigger_notifications_skips_actual(): + """Actual run type should return immediately without querying anything.""" + session = AsyncMock() + with patch(GET_ZONES) as mock_get_zones, patch(GET_VANCOUVER_NOW) as mock_now: + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.actual, RUN_GET_VANCOUVER_NOW, FOR_DATE) + mock_get_zones.assert_not_called() + + +@pytest.mark.anyio +async def test_trigger_notifications_skips_past_date(): + """for_date in the past should return without querying anything.""" + session = AsyncMock() + with patch(GET_ZONES) as mock_get_zones, patch(GET_VANCOUVER_NOW) as mock_now: + mock_now.return_value.date.return_value = date(2026, 4, 2) + await trigger_notifications(session, RunTypeEnum.actual, RUN_GET_VANCOUVER_NOW, FOR_DATE) + mock_get_zones.assert_not_called() + + +@pytest.mark.anyio +async def test_trigger_notifications_no_zones(): + """No zones with advisories means no notifications sent.""" + session = AsyncMock() + with ( + patch(GET_ZONES, return_value=[]), + patch(SEND_MULTICAST) as mock_send, + patch(GET_VANCOUVER_NOW) as mock_now, + ): + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + mock_send.assert_not_called() + + +@pytest.mark.anyio +async def test_trigger_notifications_no_subscribers(): + """Zones with advisories but no subscribed devices send no notifications.""" + session = AsyncMock() + zone = ZoneAdvisoryStatus( + advisory_shape_id=1, source_identifier="42", placename_label="Kamloops", status="advisory" + ) + with ( + patch(GET_ZONES, return_value=[zone]), + patch(GET_TOKENS, return_value=[]), + patch(SEND_MULTICAST) as mock_send, + patch(GET_VANCOUVER_NOW) as mock_now, + ): + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + mock_send.assert_not_called() + + +@pytest.mark.anyio +async def test_trigger_notifications_sends_multicast(): + """Zones with advisories and subscribers triggers a multicast send.""" + session = AsyncMock() + zone = ZoneAdvisoryStatus( + advisory_shape_id=1, source_identifier="42", placename_label="K2-Kamloops Zone (Kamloops)", status="advisory" + ) + tokens = ["token_a", "token_b"] + mock_response = MagicMock(spec=messaging.BatchResponse) + mock_response.failure_count = 0 + + with ( + patch(GET_ZONES, return_value=[zone]), + patch(GET_TOKENS, return_value=tokens), + patch(SEND_MULTICAST, return_value=mock_response) as mock_send, + patch("app.fcm.notifications.handle_fcm_response", new_callable=AsyncMock), + patch(GET_VANCOUVER_NOW) as mock_now, + ): + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + mock_send.assert_called_once() + call_arg = mock_send.call_args[0][0] + assert call_arg.tokens == tokens + assert call_arg.notification.title == "Fire Behaviour Advisory, K2" + assert "K2-Kamloops Zone (Kamloops)" in call_arg.notification.body + + +@pytest.mark.anyio +async def test_trigger_notifications_batches_tokens_over_limit(): + """Token lists larger than FCM_BATCH_SIZE are split into multiple sends.""" + session = AsyncMock() + zone = ZoneAdvisoryStatus( + advisory_shape_id=1, source_identifier="42", placename_label="Kamloops", status="advisory" + ) + tokens = [f"token_{i}" for i in range(501)] + mock_response = MagicMock(spec=messaging.BatchResponse) + mock_response.failure_count = 0 + + with ( + patch(GET_ZONES, return_value=[zone]), + patch(GET_TOKENS, return_value=tokens), + patch(SEND_MULTICAST, return_value=mock_response) as mock_send, + patch("app.fcm.notifications.handle_fcm_response", new_callable=AsyncMock), + patch(GET_VANCOUVER_NOW) as mock_now, + ): + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + assert mock_send.call_count == 2 + first_batch = mock_send.call_args_list[0][0][0].tokens + second_batch = mock_send.call_args_list[1][0][0].tokens + assert len(first_batch) == 500 + assert len(second_batch) == 1 + assert first_batch + second_batch == tokens + + +@pytest.mark.anyio +async def test_trigger_notifications_calls_handle_response(): + """handle_fcm_response is called with the correct arguments after send.""" + session = AsyncMock() + zone = ZoneAdvisoryStatus( + advisory_shape_id=1, source_identifier="42", placename_label="Kamloops", status="advisory" + ) + tokens = ["token_a"] + mock_response = MagicMock(spec=messaging.BatchResponse) + mock_response.failure_count = 0 + + with ( + patch(GET_ZONES, return_value=[zone]), + patch(GET_TOKENS, return_value=tokens), + patch(SEND_MULTICAST, return_value=mock_response), + patch("app.fcm.notifications.handle_fcm_response", new_callable=AsyncMock) as mock_handle, + patch(GET_VANCOUVER_NOW) as mock_now, + ): + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + mock_handle.assert_called_once_with(session, FOR_DATE, "Kamloops", tokens, mock_response) + + +@pytest.mark.anyio +async def test_trigger_notifications_continues_on_send_failure(): + """A send failure for one zone does not abort remaining zones.""" + session = AsyncMock() + zone_a = ZoneAdvisoryStatus( + advisory_shape_id=1, source_identifier="1", placename_label="Zone A", status="advisory" + ) + zone_b = ZoneAdvisoryStatus( + advisory_shape_id=2, source_identifier="2", placename_label="Zone B", status="advisory" + ) + mock_response = MagicMock(spec=messaging.BatchResponse) + mock_response.failure_count = 0 + + with ( + patch(GET_ZONES, return_value=[zone_a, zone_b]), + patch(GET_TOKENS, return_value=["token"]), + patch( + SEND_MULTICAST, + side_effect=[firebase_exceptions.UnavailableError("FCM error", None), mock_response], + ) as mock_send, + patch("app.fcm.notifications.handle_fcm_response", new_callable=AsyncMock) as mock_handle, + patch(GET_VANCOUVER_NOW) as mock_now, + ): + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + assert mock_send.call_count == 2 + mock_handle.assert_called_once() # Only zone_b succeeded + + +@pytest.mark.anyio +async def test_trigger_notifications_skips_zone_with_missing_placename(): + """Zones with no placename_label are skipped — no tokens fetched, no notification sent.""" + session = AsyncMock() + zone = ZoneAdvisoryStatus( + advisory_shape_id=1, source_identifier="42", placename_label=None, status="advisory" + ) + with ( + patch(GET_ZONES, return_value=[zone]), + patch(GET_TOKENS) as mock_get_tokens, + patch(SEND_MULTICAST) as mock_send, + patch(GET_VANCOUVER_NOW) as mock_now, + ): + mock_now.return_value.date.return_value = FOR_DATE + await trigger_notifications(session, RunTypeEnum.forecast, RUN_GET_VANCOUVER_NOW, FOR_DATE) + mock_get_tokens.assert_not_called() + mock_send.assert_not_called() + + +@pytest.mark.anyio +async def test_handle_fcm_response_all_success(): + """All successful responses — no tokens deactivated.""" + session = AsyncMock() + response = MagicMock(spec=messaging.BatchResponse) + response.failure_count = 0 + response.responses = [MagicMock(success=True), MagicMock(success=True)] + + with patch(UPDATE_TOKENS, new_callable=AsyncMock) as mock_update: + await handle_fcm_response(session, date(2026, 4, 1), "Kamloops", ["t1", "t2"], response) + mock_update.assert_not_called() + + +@pytest.mark.anyio +async def test_handle_fcm_response_permanent_failure_deactivates_token(): + """UnregisteredError tokens are permanently deactivated.""" + session = AsyncMock() + resp_ok = MagicMock(success=True) + resp_fail = MagicMock( + success=False, exception=messaging.UnregisteredError("token expired", None) + ) + response = MagicMock(spec=messaging.BatchResponse) + response.failure_count = 1 + response.responses = [resp_ok, resp_fail] + + with patch(UPDATE_TOKENS, new_callable=AsyncMock) as mock_update: + await handle_fcm_response( + session, date(2026, 4, 1), "Kamloops", ["token_good", "token_bad"], response + ) + mock_update.assert_called_once_with(session, ["token_bad"], False) + + +@pytest.mark.anyio +async def test_handle_fcm_response_transient_failure_does_not_deactivate(): + """Transient failures (non-UnregisteredError) do not deactivate the token.""" + session = AsyncMock() + resp_fail = MagicMock( + success=False, exception=firebase_exceptions.UnavailableError("server down", None) + ) + response = MagicMock(spec=messaging.BatchResponse) + response.failure_count = 1 + response.responses = [resp_fail] + + with patch(UPDATE_TOKENS, new_callable=AsyncMock) as mock_update: + await handle_fcm_response(session, date(2026, 4, 1), "Kamloops", ["token_ok"], response) + mock_update.assert_not_called() + + +ZONE = ZoneAdvisoryStatus( + advisory_shape_id=1, + source_identifier="42", + placename_label="K2-Kamloops Zone (Kamloops)", + status="advisory", +) +TOKENS = ["token_a", "token_b", "token_c"] +MSG_DATE = date(2026, 4, 1) +FIXED_NOW = datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc) +EXPECTED_APNS_EXPIRATION = str(int((FIXED_NOW + timedelta(days=2)).timestamp())) + + +def test_build_fcm_message_notification_content(): + msg = build_fcm_message(MSG_DATE, ZONE, TOKENS) + assert isinstance(msg, messaging.MulticastMessage) + assert msg.tokens == TOKENS + assert msg.notification.title == "Fire Behaviour Advisory, K2" + assert "K2-Kamloops Zone (Kamloops)" in msg.notification.body + assert "Wed, April 1" in msg.notification.body + + +def test_build_fcm_message_platform_tags(): + with patch("app.fcm.notifications.datetime") as mock_dt: + mock_dt.now.return_value = FIXED_NOW + msg = build_fcm_message(MSG_DATE, ZONE, TOKENS) + + assert msg.android.notification.tag == "advisory-42" + assert msg.apns.payload.aps.thread_id == msg.android.notification.tag + assert msg.android.ttl == timedelta(days=2) + assert msg.apns.headers["apns-expiration"] == EXPECTED_APNS_EXPIRATION diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/auto_spatial_advisory.py b/backend/packages/wps-shared/src/wps_shared/db/crud/auto_spatial_advisory.py index 03396a5bdc..85e7eb3ce1 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/crud/auto_spatial_advisory.py +++ b/backend/packages/wps-shared/src/wps_shared/db/crud/auto_spatial_advisory.py @@ -35,6 +35,7 @@ from wps_shared.db.models.fuel_type_raster import FuelTypeRaster from wps_shared.db.models.psu import FireCentre from wps_shared.run_type import RunType +from wps_shared.schemas.auto_spatial_advisory import ZoneAdvisoryStatus from wps_shared.schemas.fba import FireShapeStatusDetail, HfiArea, HfiThreshold logger = logging.getLogger(__name__) @@ -885,6 +886,26 @@ async def get_provincial_rollup( return [FireShapeStatusDetail.model_validate(row) for row in result.mappings().all()] +async def get_zones_with_advisories( + session: AsyncSession, run_type: RunTypeEnum, run_datetime: datetime, for_date: date +) -> list[ZoneAdvisoryStatus]: + logger.info("gathering zones with advisories/warnings") + run_parameter_id = await get_run_parameters_id(session, run_type, run_datetime, for_date) + stmt = ( + select( + AdvisoryZoneStatus.advisory_shape_id, + Shape.source_identifier, + Shape.placename_label, + advisory_status_case.label("status"), + ) + .where(AdvisoryZoneStatus.run_parameters == run_parameter_id) + .where(advisory_status_case.isnot(None)) + .join(Shape, Shape.id == AdvisoryZoneStatus.advisory_shape_id) + ) + result = await session.execute(stmt) + return [ZoneAdvisoryStatus.model_validate(row) for row in result.mappings().all()] + + async def get_containing_zone(session: AsyncSession, geometry: str, srid: int): geom = func.ST_Transform(func.ST_GeomFromText(geometry, srid), 3005) stmt = select(Shape.id).filter(func.ST_Contains(Shape.geom, geom)) diff --git a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py index 509906f9bd..ea168a0752 100644 --- a/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py +++ b/backend/packages/wps-shared/src/wps_shared/db/crud/fcm.py @@ -28,6 +28,25 @@ async def update_device_token_is_active(session: AsyncSession, token: str, is_ac return True +async def update_device_tokens_are_active( + session: AsyncSession, tokens: list[str], is_active: bool +) -> int: + if not tokens: + return 0 + stmt = ( + update(DeviceToken) + .where(DeviceToken.token.in_(tokens)) + .values( + is_active=is_active, + updated_at=get_utc_now(), + ) + # No need to synchronize the session: set-based UPDATE + no ORM objects loaded. + .execution_options(synchronize_session=False) + ) + result = await session.execute(stmt) + return result.rowcount or 0 + + async def deactivate_device_tokens(session: AsyncSession, tokens: list[str]) -> int: if not tokens: return 0 diff --git a/backend/packages/wps-shared/src/wps_shared/schemas/auto_spatial_advisory.py b/backend/packages/wps-shared/src/wps_shared/schemas/auto_spatial_advisory.py index a4070b2edc..26e488802f 100644 --- a/backend/packages/wps-shared/src/wps_shared/schemas/auto_spatial_advisory.py +++ b/backend/packages/wps-shared/src/wps_shared/schemas/auto_spatial_advisory.py @@ -1,18 +1,20 @@ -""" Schemas used for serializing and deserializing data that is published on the message queue. -""" -from datetime import datetime, date +"""Schemas used for serializing and deserializing data that is published on the message queue.""" + +from datetime import date, datetime from enum import Enum + from pydantic import BaseModel class SFMSRunType(Enum): - FORECAST = 'forecast' - ACTUAL = 'actual' + FORECAST = "forecast" + ACTUAL = "actual" class SFMSFile(BaseModel): - """ SFMS File - this schema used to store messages on the queue """ - version: str = '0.0.1' + """SFMS File - this schema used to store messages on the queue""" + + version: str = "0.0.1" key: str # S3 key run_type: SFMSRunType # forecast or actual last_modified: datetime # last modified date as provided by windows file system when uploaded @@ -26,3 +28,10 @@ class ManualSFMS(BaseModel): for_date: date runtype: SFMSRunType run_date: date # The date that this run is on, the date folder path in s3 + + +class ZoneAdvisoryStatus(BaseModel): + advisory_shape_id: int + source_identifier: str + placename_label: str | None + status: str diff --git a/backend/packages/wps-tools/src/wps_tools/tif_pusher.py b/backend/packages/wps-tools/src/wps_tools/tif_pusher.py index 358a04970b..e7af5d9f9b 100644 --- a/backend/packages/wps-tools/src/wps_tools/tif_pusher.py +++ b/backend/packages/wps-tools/src/wps_tools/tif_pusher.py @@ -1,9 +1,10 @@ -import sys import asyncio import logging +import sys from datetime import date -import requests + import pandas as pd +import requests from decouple import config from wps_tools.s3 import get_tifs_for_date @@ -34,6 +35,7 @@ async def push_tifs_to_api(start_date: date, end_date: date): processing each tif ordered by their last modified timestamp """ daterange = pd.date_range(start_date, end_date, freq="D").date + logger.info(f"Pushing tifs to: {config('URL')}") for current_date in daterange: tif_objects = await get_tifs_for_date(current_date) @@ -45,7 +47,7 @@ async def push_tifs_to_api(start_date: date, end_date: date): json=post_body, headers={"Secret": config("SECRET"), "Content-Type": "application/json"}, ) - logger.info(response) + logger.info(response.json()) async def main(start_date: date, end_date: date): diff --git a/backend/uv.lock b/backend/uv.lock index df6ebeaa52..c9a0f122d0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.12.3, <4.0" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [manifest] @@ -477,6 +478,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/23/72ecfe284a1da711257ff310b29c6667d0187a608322d58bf1c7a927c7b2/bounded_pool_executor-0.0.3-py3-none-any.whl", hash = "sha256:6f164d64919db1e6a5c187cce281f62bc559a5fed4ce064942e650c227aef190", size = 3371, upload-time = "2019-06-04T19:29:05.152Z" }, ] +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + [[package]] name = "cartopy" version = "0.25.0" @@ -1082,6 +1096,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/14/5ef47002ef19bd5cfbc7a74b21c30ef83f22beb80609314ce0328989ceda/fiona-1.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:15751c90e29cee1e01fcfedf42ab85987e32f0b593cf98d88ed52199ef5ca623", size = 24461486, upload-time = "2024-09-16T20:15:13.399Z" }, ] +[[package]] +name = "firebase-admin" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol" }, + { name = "google-api-core", extra = ["grpc"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-cloud-firestore", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-cloud-storage" }, + { name = "httpx", extra = ["http2"] }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/27/92ae60e01df51ce2de33d74d67a71e2a3c97e7b6bc0402d0a576fc3d20bc/firebase_admin-7.3.0.tar.gz", hash = "sha256:262b9bcc43c8820834d8eee5fc60d15aaedc76976b1988e1740b9579f34f9577", size = 196473, upload-time = "2026-03-19T19:01:09.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/4bffee90a160bf35ad81a1c2cd17811dda5c24e51d2aa5463845561df50c/firebase_admin-7.3.0-py3-none-any.whl", hash = "sha256:4538491d20a0edf12e0ad19c18d31e4e6e2f18d149cdf9ba7f8623ff1e4cda97", size = 138233, upload-time = "2026-03-19T19:01:08.098Z" }, +] + [[package]] name = "fonttools" version = "4.61.1" @@ -1266,6 +1297,135 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/e4/fac19dc34cb686c96011388b813ff7b858a70681e5ce6ce7698e5021b0f4/geopandas-1.1.2-py3-none-any.whl", hash = "sha256:2bb0b1052cb47378addb4ba54c47f8d4642dcbda9b61375638274f49d9f0bb0d", size = 341734, upload-time = "2025-12-22T21:06:12.498Z" }, ] +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, +] + +[[package]] +name = "google-cloud-firestore" +version = "2.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/84/4acfdc4d29de4eae4dd6ac1267611421c2a36975c473b2a86fd2e9752e75/google_cloud_firestore-2.25.0.tar.gz", hash = "sha256:9bca3b504f5473048eeab603b9bec69bbeffcdddc4e5fc65cdcc01b449628fc0", size = 621860, upload-time = "2026-03-12T19:31:06.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/f2/29abde0fcd98f32ce61fc2064a1a3e39b4e64746537409f8ea5521f79afb/google_cloud_firestore-2.25.0-py3-none-any.whl", hash = "sha256:c933a7696b7dd160953d60413ab9481387f6dd8367e77dd750d841689773104a", size = 416714, upload-time = "2026-03-12T19:30:36.674Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, +] + [[package]] name = "greenlet" version = "3.3.1" @@ -1309,6 +1469,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, ] +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, +] + [[package]] name = "gunicorn" version = "25.1.0" @@ -1330,6 +1545,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "herbie-data" version = "2025.12.0" @@ -1409,6 +1637,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/2f/8a0befeed8bbe142d5a6cf3b51e8cbe019c32a64a596b0ebcbc007a8f8f1/hiredis-3.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b442b6ab038a6f3b5109874d2514c4edf389d8d8b553f10f12654548808683bc", size = 23808, upload-time = "2025-10-14T16:33:04.965Z" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1437,6 +1674,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "hypothesis" version = "6.151.9" @@ -2114,6 +2365,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -2790,6 +3085,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -2900,6 +3222,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycares" version = "5.0.1" @@ -3113,6 +3456,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyogrio" version = "0.12.1" @@ -4339,6 +4687,7 @@ dependencies = [ { name = "cryptography" }, { name = "earthaccess" }, { name = "fastapi" }, + { name = "firebase-admin" }, { name = "gdal" }, { name = "geoalchemy2" }, { name = "geopandas" }, @@ -4399,6 +4748,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=46.0.0,<47" }, { name = "earthaccess", specifier = ">=0.15.1" }, { name = "fastapi", specifier = ">=0,<1" }, + { name = "firebase-admin", specifier = ">=7.3.0" }, { name = "gdal", specifier = "==3.12.3" }, { name = "geoalchemy2", specifier = ">=0,<1" }, { name = "geopandas", specifier = ">=1.0.1,<2" }, diff --git a/mobile/asa-go/docs/notification-settings-state-machine.mmd b/mobile/asa-go/docs/notification-settings-state-machine.mmd new file mode 100644 index 0000000000..5a7a66aa23 --- /dev/null +++ b/mobile/asa-go/docs/notification-settings-state-machine.mmd @@ -0,0 +1,46 @@ +stateDiagram-v2 + direction TB + + [*] --> PermissionDenied : permission denied + [*] --> Unregistered : permission granted + + PermissionDenied --> Unregistered : user enables in OS settings + + Unregistered --> Ready : online + deviceId resolved + registerToken() succeeds\n(retried with exponential backoff) + Unregistered --> Unregistered : offline + Unregistered --> RegistrationFailed : online + deviceId resolved + registerToken() exhausts retries + Unregistered --> DeviceIdError : Device.getId() fails + + RegistrationFailed --> Ready : app foreground + registerToken() succeeds + RegistrationFailed --> RegistrationFailed : app foreground + registerToken() fails again + + DeviceIdError --> Unregistered : app restart / retry + + Ready --> Unregistered : new FCM token received (token rotation) + + state Ready { + [*] --> LoadingSubscriptions + LoadingSubscriptions --> Idle : initSubscriptions() succeeds + LoadingSubscriptions --> LoadingSubscriptions : initSubscriptions() fails\n(button stays disabled, no user feedback) + Idle --> Updating : user toggles subscription + Updating --> Idle : server confirms + Updating --> UpdateFailed : server rejects after retries + UpdateFailed --> Updating : user toggles again + } + + note right of Ready + Subscribe button disabled in LoadingSubscriptions\n(selectNotificationSettingsDisabled checks subscriptionsInitialized) + Subscription toggle UI: + Updating → disabled + thumb pulse animation (LoadingSwitch) + UpdateFailed → reverted state + snackbar + inline error on the failed switch (LoadingSwitch) + end note + + note right of Unregistered + Subscribe button UI: + Unregistered + online → spinner in subscribe button + Unregistered + offline → "Unavailable offline" caption + RegistrationFailed → "Unable to register for notifications" caption + PermissionDenied → "Enable notifications in device settings" caption + DeviceIdError → "Unable to identify device" caption + (driven by selectNotificationSettingsDisabledReason) + end note diff --git a/mobile/asa-go/src/App.tsx b/mobile/asa-go/src/App.tsx index 7f6d9e7a00..b688257598 100644 --- a/mobile/asa-go/src/App.tsx +++ b/mobile/asa-go/src/App.tsx @@ -25,6 +25,8 @@ import { selectNetworkStatus, selectRunParameters, selectAuthentication, + selectSettings, + selectPushNotification, } from "@/store"; import { theme } from "@/theme"; import { NavPanel } from "@/utils/constants"; @@ -41,9 +43,8 @@ import { DateTime } from "luxon"; import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { usePushNotifications } from "@/hooks/usePushNotifications"; -import { Capacitor } from "@capacitor/core"; -import { Platform, registerToken } from "@/api/pushNotificationsAPI"; -import { Device } from "@capacitor/device"; +import { useDeviceId } from "@/hooks/useDeviceId"; +import { initSubscriptions } from "@/slices/settingsSlice"; const App = () => { LicenseInfo.setLicenseKey(import.meta.env.VITE_MUI_LICENSE_KEY); @@ -51,7 +52,7 @@ const App = () => { const isPortrait = useIsPortrait(); const dispatch: AppDispatch = useDispatch(); const isSmallScreen = useMediaQuery(theme.breakpoints.down("lg")); - const { idir, isAuthenticated } = useSelector(selectAuthentication); + const { isAuthenticated } = useSelector(selectAuthentication); // local state const [tab, setTab] = useState(NavPanel.MAP); @@ -67,10 +68,13 @@ const App = () => { const { fireCentres } = useSelector(selectFireCentres); const { networkStatus } = useSelector(selectNetworkStatus); const runParameters = useSelector(selectRunParameters); + const { registeredFcmToken } = useSelector(selectPushNotification); + const { subscriptionsInitialized } = useSelector(selectSettings); // hooks const runParameter = useRunParameterForDate(dateOfInterest); - const { initPushNotifications, token } = usePushNotifications(); + const { initPushNotifications } = usePushNotifications(); + const deviceId = useDeviceId(); const selectedFireCentreName = selectedFireShape?.mof_fire_centre_name; const matchingFireCentre = selectedFireCentreName @@ -98,23 +102,16 @@ const App = () => { }, [initPushNotifications, isAuthenticated]); useEffect(() => { - async function handleTokenChange(t: string) { - const deviceId = await Device.getId(); - try { - await registerToken( - Capacitor.getPlatform() as Platform, - t, - deviceId?.identifier, - idir || null, - ); - } catch (e) { - console.error("Failed to register push token", e); - } - } - if (!isNil(token)) { - handleTokenChange(token); - } - }, [token, idir]); + if (!deviceId || !networkStatus.connected || !registeredFcmToken) return; + if (subscriptionsInitialized) return; + dispatch(initSubscriptions(deviceId)); + }, [ + deviceId, + networkStatus.connected, + registeredFcmToken, + subscriptionsInitialized, + dispatch, + ]); useEffect(() => { // Network status is disconnected by default in the networkStatusSlice. Update the status diff --git a/mobile/asa-go/src/api/pushNotificationsAPI.test.ts b/mobile/asa-go/src/api/pushNotificationsAPI.test.ts new file mode 100644 index 0000000000..9633912fed --- /dev/null +++ b/mobile/asa-go/src/api/pushNotificationsAPI.test.ts @@ -0,0 +1,107 @@ +import axios from "@/api/axios"; +import { + getNotificationSettings, + registerToken, + unregisterToken, + updateNotificationSettings, +} from "./pushNotificationsAPI"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; + +vi.mock("@/api/axios", () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe("pushNotificationsAPI", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("registerToken", () => { + it("posts to device/register with correct payload and returns response", async () => { + (axios.post as Mock).mockResolvedValue({ data: { success: true } }); + + const result = await registerToken("android", "my-token", "device-1", "user-1"); + + expect(axios.post).toHaveBeenCalledWith("device/register", { + platform: "android", + token: "my-token", + device_id: "device-1", + user_id: "user-1", + }); + expect(result).toEqual({ success: true }); + }); + + it("passes null user_id when user is not logged in", async () => { + (axios.post as Mock).mockResolvedValue({ data: { success: true } }); + + await registerToken("ios", "my-token", "device-1", null); + + expect(axios.post).toHaveBeenCalledWith("device/register", expect.objectContaining({ user_id: null })); + }); + }); + + describe("unregisterToken", () => { + it("posts to device/unregister with token and returns response", async () => { + (axios.post as Mock).mockResolvedValue({ data: { success: true } }); + + const result = await unregisterToken("my-token"); + + expect(axios.post).toHaveBeenCalledWith("device/unregister", { token: "my-token" }); + expect(result).toEqual({ success: true }); + }); + }); + + describe("getNotificationSettings", () => { + it("gets device/notification-settings with device_id param and returns source ids", async () => { + (axios.get as Mock).mockResolvedValue({ + data: { fire_zone_source_ids: ["1", "2", "3"] }, + }); + + const result = await getNotificationSettings("device-1"); + + expect(axios.get).toHaveBeenCalledWith("device/notification-settings", { + params: { device_id: "device-1" }, + }); + expect(result).toEqual(["1", "2", "3"]); + }); + + it("returns empty array when no subscriptions", async () => { + (axios.get as Mock).mockResolvedValue({ data: { fire_zone_source_ids: [] } }); + + const result = await getNotificationSettings("device-1"); + + expect(result).toEqual([]); + }); + }); + + describe("updateNotificationSettings", () => { + it("posts to device/notification-settings with correct payload and returns updated ids", async () => { + (axios.post as Mock).mockResolvedValue({ + data: { fire_zone_source_ids: ["5", "10"] }, + }); + + const result = await updateNotificationSettings("device-1", ["5", "10"]); + + expect(axios.post).toHaveBeenCalledWith("device/notification-settings", { + device_id: "device-1", + fire_zone_source_ids: ["5", "10"], + }); + expect(result).toEqual(["5", "10"]); + }); + + it("posts empty array to clear all subscriptions", async () => { + (axios.post as Mock).mockResolvedValue({ data: { fire_zone_source_ids: [] } }); + + const result = await updateNotificationSettings("device-1", []); + + expect(axios.post).toHaveBeenCalledWith("device/notification-settings", { + device_id: "device-1", + fire_zone_source_ids: [], + }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/mobile/asa-go/src/api/pushNotificationsAPI.ts b/mobile/asa-go/src/api/pushNotificationsAPI.ts index 9f583ac668..3fea546752 100644 --- a/mobile/asa-go/src/api/pushNotificationsAPI.ts +++ b/mobile/asa-go/src/api/pushNotificationsAPI.ts @@ -29,3 +29,16 @@ export async function unregisterToken( const { data } = await axios.post(url, { token }); return data; } + +export async function getNotificationSettings(deviceId: string): Promise { + const { data } = await axios.get("device/notification-settings", { params: { device_id: deviceId } }); + return data.fire_zone_source_ids; +} + +export async function updateNotificationSettings(deviceId: string, fireZoneSourceIds: string[]): Promise { + const { data } = await axios.post("device/notification-settings", { + device_id: deviceId, + fire_zone_source_ids: fireZoneSourceIds, + }); + return data.fire_zone_source_ids; +} diff --git a/mobile/asa-go/src/app.test.tsx b/mobile/asa-go/src/app.test.tsx index f94c3751d2..dd157463aa 100644 --- a/mobile/asa-go/src/app.test.tsx +++ b/mobile/asa-go/src/app.test.tsx @@ -1,10 +1,11 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import App from "./App"; import { Provider } from "react-redux"; import { createTestStore } from "./testUtils"; import { NavPanel } from "@/utils/constants"; import { useMediaQuery } from "@mui/material"; +import { usePushNotifications } from "@/hooks/usePushNotifications"; // Mock MUI useMediaQuery to control screen size detection vi.mock("@mui/material", async () => { @@ -98,7 +99,36 @@ vi.mock("@/hooks/useRunParameterForDate", () => ({ useRunParameterForDate: () => undefined, })); +vi.mock("@/hooks/usePushNotifications", () => ({ + usePushNotifications: vi.fn().mockReturnValue({ + initPushNotifications: vi.fn().mockResolvedValue(undefined), + retryRegistration: vi.fn().mockResolvedValue(undefined), + currentFcmToken: null, + }), +})); + +vi.mock("@capacitor/device", () => ({ + Device: { getId: vi.fn().mockResolvedValue({ identifier: "device-id" }) }, +})); + +vi.mock("@capacitor/core", () => ({ + Capacitor: { getPlatform: () => "ios" }, +})); + +vi.mock("@/api/pushNotificationsAPI", () => ({ + registerToken: vi.fn(), + Platform: {}, +})); + describe("App", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(usePushNotifications).mockReturnValue({ + initPushNotifications: vi.fn().mockResolvedValue(undefined), + retryRegistration: vi.fn().mockResolvedValue(undefined), + }); + }); + it("renders all main components in initial state", () => { const store = createTestStore(); @@ -296,6 +326,57 @@ describe("App", () => { expect(screen.queryByTestId("side-navigation")).not.toBeInTheDocument(); }); + + it("calls initPushNotifications when authenticated", async () => { + const initPushNotifications = vi.fn().mockResolvedValue(undefined); + vi.mocked(usePushNotifications).mockReturnValue({ + initPushNotifications, + retryRegistration: vi.fn().mockResolvedValue(undefined), + }); + + const store = createTestStore({ + authentication: { + isAuthenticated: true, + authenticating: false, + tokenRefreshed: false, + token: undefined, + idToken: undefined, + idir: undefined, + error: null, + }, + }); + + await act(async () => { + render( + + + , + ); + }); + + expect(initPushNotifications).toHaveBeenCalledTimes(1); + }); + + it("does not call initPushNotifications when not authenticated", async () => { + const initPushNotifications = vi.fn().mockResolvedValue(undefined); + vi.mocked(usePushNotifications).mockReturnValue({ + initPushNotifications, + retryRegistration: vi.fn().mockResolvedValue(undefined), + }); + + const store = createTestStore(); + + await act(async () => { + render( + + + , + ); + }); + + expect(initPushNotifications).not.toHaveBeenCalled(); + }); + it("displays AppHeader and BottomNavigation in portrait on medium or larger screens", async () => { const { ScreenOrientation } = await import("@capacitor/screen-orientation"); diff --git a/mobile/asa-go/src/components/LoadingSwitch.test.tsx b/mobile/asa-go/src/components/LoadingSwitch.test.tsx new file mode 100644 index 0000000000..6fc41b5362 --- /dev/null +++ b/mobile/asa-go/src/components/LoadingSwitch.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import LoadingSwitch from "./LoadingSwitch"; + +describe("LoadingSwitch", () => { + it("renders as unchecked", () => { + render( + , + ); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + }); + + it("renders as checked", () => { + render( + , + ); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("calls onChange when clicked", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("checkbox")); + expect(onChange).toHaveBeenCalled(); + }); + + it("disables the switch while loading", () => { + render( + , + ); + expect(screen.getByRole("checkbox")).toBeDisabled(); + }); + + it("disables the switch when disabled", () => { + render( + , + ); + expect(screen.getByRole("checkbox")).toBeDisabled(); + }); + + it("does not call onChange while loading", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("checkbox")); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/mobile/asa-go/src/components/LoadingSwitch.tsx b/mobile/asa-go/src/components/LoadingSwitch.tsx new file mode 100644 index 0000000000..19d4b55cf4 --- /dev/null +++ b/mobile/asa-go/src/components/LoadingSwitch.tsx @@ -0,0 +1,43 @@ +import { Switch, SwitchProps } from "@mui/material"; + +interface LoadingSwitchProps { + checked: boolean; + onChange: (e: React.ChangeEvent) => void; + "aria-label": string; + loading?: boolean; + disabled?: boolean; + edge?: SwitchProps["edge"]; +} + +const LoadingSwitch = ({ + checked, + onChange, + "aria-label": ariaLabel, + loading = false, + disabled, + edge, +}: LoadingSwitchProps) => ( + +); + +export default LoadingSwitch; diff --git a/mobile/asa-go/src/components/NotificationErrorSnackbar.tsx b/mobile/asa-go/src/components/NotificationErrorSnackbar.tsx new file mode 100644 index 0000000000..6cae20b265 --- /dev/null +++ b/mobile/asa-go/src/components/NotificationErrorSnackbar.tsx @@ -0,0 +1,48 @@ +import { Alert, AlertColor, Snackbar } from "@mui/material"; +import { SnackbarOrigin } from "@mui/material/Snackbar"; + +interface NotificationErrorSnackbarProps { + open: boolean; + onClose: () => void; + message: string; + anchorOrigin?: SnackbarOrigin; + severity?: AlertColor; + autoHideDuration?: number | null; +} + +const NotificationErrorSnackbar = ({ + open, + onClose, + message, + anchorOrigin = { vertical: "top", horizontal: "center" }, + severity = "error", + autoHideDuration = 6000, +}: NotificationErrorSnackbarProps) => ( + + + {message} + + +); + +export default NotificationErrorSnackbar; diff --git a/mobile/asa-go/src/components/map/FireShapeActionsDrawer.tsx b/mobile/asa-go/src/components/map/FireShapeActionsDrawer.tsx index 73b68f1615..1cbab20a5d 100644 --- a/mobile/asa-go/src/components/map/FireShapeActionsDrawer.tsx +++ b/mobile/asa-go/src/components/map/FireShapeActionsDrawer.tsx @@ -2,27 +2,38 @@ import { FireShape } from "@/api/fbaAPI"; import { SwipeableBottomDrawer } from "@/components/SwipeableBottomDrawer"; import { useIsPortrait } from "@/hooks/useIsPortrait"; import { useIsTablet } from "@/hooks/useIsTablet"; +import { checkPushNotificationPermission } from "@/slices/pushNotificationSlice"; +import { useNotificationSettings } from "@/hooks/useNotificationSettings"; +import { usePushNotifications } from "@/hooks/usePushNotifications"; import { - checkPushNotificationPermission, - toggleSubscription, -} from "@/slices/settingsSlice"; -import { AppDispatch, selectNetworkStatus, selectSettings } from "@/store"; + AppDispatch, + selectNetworkStatus, + selectNotificationSetupState, + selectNotificationSettingsDisabled, + selectPushNotification, + selectRegistrationFailed, + selectSettings, +} from "@/store"; import { fireZoneUnitNameFormatter } from "@/utils/stringUtils"; import AnalyticsIcon from "@mui/icons-material/Analytics"; import CloseIcon from "@mui/icons-material/Close"; import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive"; import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; +import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; import TextSnippetIcon from "@mui/icons-material/TextSnippet"; import { Box, Button, + CircularProgress, IconButton, Typography, useMediaQuery, useTheme, } from "@mui/material"; -import { useEffect } from "react"; +import NotificationErrorSnackbar from "@/components/NotificationErrorSnackbar"; +import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; +import { subscriptionUpdateErrorMessage } from "@/utils/constants"; interface FireShapeActionsDrawerProps { open: boolean; @@ -40,6 +51,9 @@ const FireShapeActionsDrawer = ({ onSelectAdvisory, }: FireShapeActionsDrawerProps) => { const dispatch: AppDispatch = useDispatch(); + const { toggleSubscription, updateError, clearUpdateError } = + useNotificationSettings(); + const { retryRegistration } = usePushNotifications(); const theme = useTheme(); const isPortrait = useIsPortrait(); @@ -47,20 +61,29 @@ const FireShapeActionsDrawer = ({ const isSmallScreen = useMediaQuery(theme.breakpoints.down("lg")); const useSideSheet = !isPortrait && isSmallScreen; + const { subscriptions } = useSelector(selectSettings); + const { pushNotificationPermission, deviceIdError } = useSelector( + selectPushNotification, + ); + const [registrationErrorDismissed, setRegistrationErrorDismissed] = + useState(false); const { networkStatus } = useSelector(selectNetworkStatus); - const { pushNotificationPermission, subscriptions } = - useSelector(selectSettings); + const setupState = useSelector(selectNotificationSetupState); + const notificationSettingsDisabled = useSelector( + selectNotificationSettingsDisabled, + ); + const isRegistrationFailed = useSelector(selectRegistrationFailed); const selectedFireShapeId = selectedFireShape?.fire_shape_id; const isSubscribed = selectedFireShapeId !== undefined && subscriptions.includes(selectedFireShapeId); - const notificationSettingsDisabled = - pushNotificationPermission !== "granted" || !networkStatus.connected; - const actionIconSx = { - fontSize: isTablet ? 40 : 32, - }; + const isAwaitingToken = + setupState === "unregistered" && networkStatus.connected && !deviceIdError; + + const actionIconSize = isTablet ? 40 : 32; + const actionIconSx = { fontSize: actionIconSize }; const actionButtonSx = { borderRadius: 2, @@ -78,102 +101,143 @@ const FireShapeActionsDrawer = ({ } }, [dispatch, open, pushNotificationPermission]); + useEffect(() => { + if (open) { + void retryRegistration(); + } + }, [open, retryRegistration]); + const handleSubscriptionUpdate = () => { if (selectedFireShapeId === undefined || notificationSettingsDisabled) { return; } - dispatch(toggleSubscription(selectedFireShapeId)); + toggleSubscription(selectedFireShapeId); }; return ( - - + <> + + setRegistrationErrorDismissed(true)} + message="Unable to register this device for notifications. Retrying automatically." + severity="warning" + autoHideDuration={null} + /> + - - {fireZoneUnitNameFormatter(selectedFireShape?.mof_fire_zone_name)} - - - - - + + {fireZoneUnitNameFormatter(selectedFireShape?.mof_fire_zone_name)} + + + + + - - - - + + + + + + - - + + ); }; diff --git a/mobile/asa-go/src/components/map/fireShapeActionsDrawer.test.tsx b/mobile/asa-go/src/components/map/fireShapeActionsDrawer.test.tsx index 953ae3c957..cb23a4517f 100644 --- a/mobile/asa-go/src/components/map/fireShapeActionsDrawer.test.tsx +++ b/mobile/asa-go/src/components/map/fireShapeActionsDrawer.test.tsx @@ -3,12 +3,19 @@ import { useIsPortrait } from "@/hooks/useIsPortrait"; import { useIsTablet } from "@/hooks/useIsTablet"; import FireShapeActionsDrawer from "@/components/map/FireShapeActionsDrawer"; import { createTestStore } from "@/testUtils"; -import { Preferences } from "@capacitor/preferences"; import { useMediaQuery } from "@mui/material"; import { ThemeProvider, createTheme } from "@mui/material/styles"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { Provider } from "react-redux"; -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, Mock } from "vitest"; +import { + getNotificationSettings, + updateNotificationSettings, +} from "api/pushNotificationsAPI"; +import { useDeviceId } from "@/hooks/useDeviceId"; +vi.mock("@/hooks/useDeviceId", () => ({ + useDeviceId: vi.fn().mockReturnValue("test-device-id"), +})); vi.mock("@mui/material", async () => { const actual = await vi.importActual( @@ -21,6 +28,15 @@ vi.mock("@mui/material", async () => { }; }); +vi.mock("api/pushNotificationsAPI", () => ({ + getNotificationSettings: vi.fn().mockResolvedValue([]), + updateNotificationSettings: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/utils/retryWithBackoff", () => ({ + retryWithBackoff: vi.fn((op: () => Promise) => op()), +})); + vi.mock("@capacitor/preferences", () => ({ Preferences: { get: vi.fn().mockResolvedValue({ value: null }), @@ -29,8 +45,12 @@ vi.mock("@capacitor/preferences", () => ({ })); vi.mock("@capacitor-firebase/messaging", () => ({ + Importance: { High: 4 }, FirebaseMessaging: { checkPermissions: vi.fn().mockResolvedValue({ receive: "granted" }), + getToken: vi.fn().mockResolvedValue({ token: "test-token" }), + addListener: vi.fn().mockResolvedValue({ remove: vi.fn() }), + removeAllListeners: vi.fn(), }, })); @@ -54,10 +74,14 @@ const renderWithProviders = ({ subscriptions = [], pushNotificationPermission = "granted", connected = true, + registeredFcmToken = "test-token", + deviceIdError = false, }: { subscriptions?: number[]; pushNotificationPermission?: "granted" | "denied" | "prompt" | "unknown"; connected?: boolean; + registeredFcmToken?: string | null; + deviceIdError?: boolean; } = {}) => { const store = createTestStore({ networkStatus: { @@ -71,32 +95,41 @@ const renderWithProviders = ({ error: null, fireCentreInfos: [], pinnedFireCentre: null, - pushNotificationPermission, subscriptions, + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission, + registeredFcmToken, + deviceIdError, + registrationError: false, }, }); - return { - ...render( - - - - - , - ), - store, - }; + render( + + + + + , + ); + return { store }; }; describe("FireShapeActionsDrawer", () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(useDeviceId).mockReturnValue("test-device-id"); + vi.mocked(getNotificationSettings).mockResolvedValue([]); + vi.mocked(updateNotificationSettings).mockImplementation((_, subs) => + Promise.resolve(subs), + ); vi.mocked(useIsPortrait).mockReturnValue(true); vi.mocked(useIsTablet).mockReturnValue(false); vi.mocked(useMediaQuery).mockReturnValue(false); @@ -132,8 +165,14 @@ describe("FireShapeActionsDrawer", () => { error: null, fireCentreInfos: [], pinnedFireCentre: null, - pushNotificationPermission: "granted", subscriptions: [], + subscriptionsInitialized: false, + }, + pushNotification: { + pushNotificationPermission: "granted", + registeredFcmToken: null, + deviceIdError: false, + registrationError: false, }, }); const theme = createTheme(); @@ -172,8 +211,14 @@ describe("FireShapeActionsDrawer", () => { error: null, fireCentreInfos: [], pinnedFireCentre: null, - pushNotificationPermission: "granted", subscriptions: [], + subscriptionsInitialized: false, + }, + pushNotification: { + pushNotificationPermission: "granted", + registeredFcmToken: null, + deviceIdError: false, + registrationError: false, }, }); const theme = createTheme(); @@ -201,7 +246,6 @@ describe("FireShapeActionsDrawer", () => { it("toggles the subscription for the selected fire shape", async () => { const { store } = renderWithProviders(); - fireEvent.click( screen.getByRole("button", { name: /Toggle subscription for Test Fire Zone/i, @@ -211,10 +255,6 @@ describe("FireShapeActionsDrawer", () => { await waitFor(() => { expect(store.getState().settings.subscriptions).toEqual([1]); }); - expect(Preferences.set).toHaveBeenCalledWith({ - key: "asaGoSubscriptions", - value: JSON.stringify([1]), - }); expect( screen.getByRole("button", { name: /Toggle subscription for Test Fire Zone/i, @@ -243,6 +283,16 @@ describe("FireShapeActionsDrawer", () => { ).toBeDisabled(); }); + it("disables subscription when awaiting FCM token", () => { + renderWithProviders({ registeredFcmToken: null }); + + expect( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ).toBeDisabled(); + }); + it("uses the side-sheet layout on landscape small screens", async () => { vi.mocked(useIsPortrait).mockReturnValue(false); vi.mocked(useMediaQuery).mockReturnValue(true); @@ -251,7 +301,7 @@ describe("FireShapeActionsDrawer", () => { const actionGrid = screen.getByRole("button", { name: /Toggle subscription for Test Fire Zone/i, - }).parentElement; + }).parentElement?.parentElement; expect(actionGrid).toHaveStyle({ gridTemplateColumns: "repeat(2, minmax(0, 1fr))", @@ -272,7 +322,7 @@ describe("FireShapeActionsDrawer", () => { const actionGrid = screen.getByRole("button", { name: /Toggle subscription for Test Fire Zone/i, - }).parentElement; + }).parentElement?.parentElement; expect(actionGrid).toHaveStyle({ gridTemplateColumns: "repeat(3, minmax(0, 1fr))", @@ -296,4 +346,119 @@ describe("FireShapeActionsDrawer", () => { fontSize: "40px", }); }); + + it("calls updateNotificationSettings when toggling subscription", async () => { + renderWithProviders(); + fireEvent.click( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ); + + await waitFor(() => { + expect(updateNotificationSettings).toHaveBeenCalledWith( + "test-device-id", + ["1"], + ); + }); + }); + + it("updates store from server response after toggling", async () => { + // Server returns a corrected list (e.g. deduped or reordered) + (updateNotificationSettings as Mock).mockResolvedValue(["1", "99"]); + + const { store } = renderWithProviders(); + fireEvent.click( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ); + + await waitFor(() => { + expect(store.getState().settings.subscriptions).toEqual([1, 99]); + }); + }); + + it("reverts local state when the server call fails", async () => { + (updateNotificationSettings as Mock).mockRejectedValue( + new Error("server error"), + ); + (getNotificationSettings as Mock).mockResolvedValue(["42"]); + + const { store } = renderWithProviders({ subscriptions: [42] }); + fireEvent.click( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ); + + await waitFor(() => { + expect(store.getState().settings.subscriptions).toEqual([42]); + }); + }); + + it("shows error snackbar when subscription toggle fails", async () => { + vi.mocked(updateNotificationSettings).mockRejectedValue( + new Error("server error"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + renderWithProviders(); + fireEvent.click( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ); + + await waitFor(() => { + expect(screen.getByText(/Failed to update/i)).toBeInTheDocument(); + }); + }); + + it("shows a loading spinner on the subscribe button when awaiting FCM token", () => { + renderWithProviders({ registeredFcmToken: null }); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("disables the subscribe button when permission is denied", () => { + renderWithProviders({ pushNotificationPermission: "denied" }); + expect( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ).toBeDisabled(); + }); + + it("disables the subscribe button when offline", () => { + renderWithProviders({ connected: false }); + expect( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ).toBeDisabled(); + }); + + it("disables the subscribe button when device ID error", () => { + renderWithProviders({ deviceIdError: true, registeredFcmToken: null }); + expect( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ).toBeDisabled(); + }); + + it("does not call updateNotificationSettings when offline", async () => { + renderWithProviders({ + connected: false, + pushNotificationPermission: "granted", + }); + + // Button is disabled when offline, so no click possible — just verify the API is never called + expect( + screen.getByRole("button", { + name: /Toggle subscription for Test Fire Zone/i, + }), + ).toBeDisabled(); + expect(updateNotificationSettings).not.toHaveBeenCalled(); + }); }); diff --git a/mobile/asa-go/src/components/notificationErrorSnackbar.test.tsx b/mobile/asa-go/src/components/notificationErrorSnackbar.test.tsx new file mode 100644 index 0000000000..941fd4e42c --- /dev/null +++ b/mobile/asa-go/src/components/notificationErrorSnackbar.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import NotificationErrorSnackbar from "./NotificationErrorSnackbar"; + +describe("NotificationErrorSnackbar", () => { + it("renders the message when open", () => { + render( + , + ); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + render( + , + ); + expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument(); + }); + + it("renders with error severity", () => { + render( + , + ); + expect(screen.getByRole("alert")).toHaveClass("MuiAlert-colorError"); + }); + + it("calls onClose when the close button is clicked", () => { + const onClose = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/mobile/asa-go/src/components/settings/Settings.tsx b/mobile/asa-go/src/components/settings/Settings.tsx index 40f8cb5c53..03ebe37ab5 100644 --- a/mobile/asa-go/src/components/settings/Settings.tsx +++ b/mobile/asa-go/src/components/settings/Settings.tsx @@ -1,12 +1,19 @@ import { FireCentreInfo } from "@/api/fbaAPI"; import SubscriptionAccordion from "@/components/settings/SubscriptionAccordion"; +import { checkPushNotificationPermission } from "@/slices/pushNotificationSlice"; import { - checkPushNotificationPermission, fetchFireCentreInfo, initPinnedFireCentre, - initSubscriptions, } from "@/slices/settingsSlice"; -import { AppDispatch, selectNetworkStatus, selectSettings } from "@/store"; +import { + AppDispatch, + selectNetworkStatus, + selectNotificationSettingsDisabled, + selectNotificationSetupState, + selectPushNotification, + selectRegistrationFailed, + selectSettings, +} from "@/store"; import { theme } from "@/theme"; import { Alert, @@ -15,11 +22,13 @@ import { LinearProgress, Typography, } from "@mui/material"; +import NotificationErrorSnackbar from "@/components/NotificationErrorSnackbar"; import { isNil } from "lodash"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useAppIsActive } from "@/hooks/useAppIsActive"; +import { usePushNotifications } from "@/hooks/usePushNotifications"; import { NavPanel } from "@/utils/constants"; interface SettingsProps { @@ -30,32 +39,32 @@ const Settings = ({ activeTab }: SettingsProps) => { const dispatch: AppDispatch = useDispatch(); const isActive = useAppIsActive(); const isVisible = activeTab === NavPanel.SETTINGS; - + const { retryRegistration } = usePushNotifications(); const { networkStatus } = useSelector(selectNetworkStatus); - const { - fireCentreInfos, - loading, - error, - pinnedFireCentre, - pushNotificationPermission, - } = useSelector(selectSettings); - - const notificationSettingsDisabled = - pushNotificationPermission !== "granted" || !networkStatus.connected; - - // Load subscriptions and pinned fire centre from locally cached user preferences + const { fireCentreInfos, loading, error, pinnedFireCentre } = + useSelector(selectSettings); + const { deviceIdError } = useSelector(selectPushNotification); + const [registrationErrorDismissed, setRegistrationErrorDismissed] = useState(false); + const setupState = useSelector(selectNotificationSetupState); + const isRegistrationFailed = useSelector(selectRegistrationFailed); + const notificationSettingsDisabled = useSelector( + selectNotificationSettingsDisabled, + ); + + // Load pinned fire centre from locally cached user preferences useEffect(() => { dispatch(initPinnedFireCentre()); - dispatch(initSubscriptions()); }, [dispatch]); - // Check push notification settings and fetch fire centre info on mount and when app is foregrounded + // Check push notification settings and fetch fire centre info on mount and when app is foregrounded. + // Also retry device registration in case the initial attempt failed (e.g. offline at startup). useEffect(() => { if (isVisible) { dispatch(fetchFireCentreInfo()); dispatch(checkPushNotificationPermission()); + void retryRegistration(); } - }, [isActive, isVisible, dispatch]); + }, [isActive, isVisible, dispatch, retryRegistration]); // Derived ordered list of centres for display (memoized) const orderedFireCentres = useMemo(() => { @@ -90,11 +99,7 @@ const Settings = ({ activeTab }: SettingsProps) => { }, [fireCentreInfos, pinnedFireCentre]); const renderNotificationMessage = () => { - if ( - !networkStatus.connected || - pushNotificationPermission !== "granted" || - error - ) { + if (notificationSettingsDisabled || error) { return; } return ( @@ -127,10 +132,25 @@ const Settings = ({ activeTab }: SettingsProps) => { ); }; + const renderDeviceIdErrorBanner = () => { + if (!deviceIdError) return; + return ( + + Device identification error + Unable to identify this device. Notification settings are unavailable. + + ); + }; + + const renderPermissionBanner = () => { // Show a banner if permission is not explicitly granted in system settings and we're online. const shouldShow = - pushNotificationPermission !== "granted" && networkStatus.connected; + setupState === "permissionDenied" && networkStatus.connected; if (shouldShow) { return ( { + setRegistrationErrorDismissed(true)} + message="Unable to register this device for notifications. Retrying automatically." + severity="warning" + autoHideDuration={null} + /> + {renderDeviceIdErrorBanner()} {renderPermissionBanner()} {renderOfflineMessage()} {renderNotificationMessage()} diff --git a/mobile/asa-go/src/components/settings/SubscriptionAccordion.tsx b/mobile/asa-go/src/components/settings/SubscriptionAccordion.tsx index ff95f7e4a0..b0231d043b 100644 --- a/mobile/asa-go/src/components/settings/SubscriptionAccordion.tsx +++ b/mobile/asa-go/src/components/settings/SubscriptionAccordion.tsx @@ -1,11 +1,14 @@ import { FireCentreInfo } from "@/api/fbaAPI"; import SubscriptionOption from "@/components/settings/SubscriptionOption"; +import { useNotificationSettings } from "@/hooks/useNotificationSettings"; +import { savePinnedFireCentre } from "@/slices/settingsSlice"; import { - savePinnedFireCentre, - saveSubscriptions, -} from "@/slices/settingsSlice"; -import { AppDispatch, selectSettings } from "@/store"; + AppDispatch, + selectNotificationSettingsDisabled, + selectSettings, +} from "@/store"; import { theme } from "@/theme"; +import { subscriptionUpdateErrorMessage } from "@/utils/constants"; import { nameFormatter } from "@/utils/stringUtils"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import PushPinIcon from "@mui/icons-material/PushPin"; @@ -22,6 +25,7 @@ import { List, Typography, } from "@mui/material"; +import NotificationErrorSnackbar from "@/components/NotificationErrorSnackbar"; import { useCallback, useMemo, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; @@ -37,8 +41,19 @@ const SubscriptionAccordion = ({ fireCentreInfo, }: SubscriptionAccordionProps) => { const dispatch: AppDispatch = useDispatch(); + const { + updateSubscriptions, + toggleSubscription, + updateError, + clearUpdateError, + } = useNotificationSettings(); const { pinnedFireCentre, subscriptions } = useSelector(selectSettings); + const notificationSettingsDisabled = useSelector( + selectNotificationSettingsDisabled, + ); + const [expanded, setExpanded] = useState(defaultExpanded); + const [pendingZoneId, setPendingZoneId] = useState(null); // All fire zone unit ids in this fire centre. const allFireZoneUnitIds = useMemo(() => { @@ -56,7 +71,9 @@ const SubscriptionAccordion = ({ // Handle expanding/collapsing the accordion. const handleChange = useCallback( (_: React.SyntheticEvent, newExpanded: boolean) => { - if (disabled) return; // block expansion when disabled + if (disabled) { + return; // block expansion when disabled + } setExpanded(newExpanded); }, [disabled], @@ -83,6 +100,12 @@ const SubscriptionAccordion = ({ }; // Add/remove subscription to all fire zone units in this fire centre. + const handleToggle = async (id: number) => { + setPendingZoneId(id); + await toggleSubscription(id); + setPendingZoneId(null); + }; + const toggleAll = (e: React.ChangeEvent) => { e.stopPropagation(); // Remove all of this fire centre's fire zone unit ids to avoid adding duplicates in the following if block. @@ -94,7 +117,7 @@ const SubscriptionAccordion = ({ newSubs.push(...allFireZoneUnitIds); } - dispatch(saveSubscriptions(newSubs)); + updateSubscriptions(newSubs); }; const disabledStyles = disabled @@ -113,9 +136,15 @@ const SubscriptionAccordion = ({ }} aria-disabled={disabled ? true : undefined} > + - + {fireCentreInfo.fire_zone_units.map((fireZoneUnit) => { return ( ); })} diff --git a/mobile/asa-go/src/components/settings/SubscriptionOption.tsx b/mobile/asa-go/src/components/settings/SubscriptionOption.tsx index 219cc19953..02923052c7 100644 --- a/mobile/asa-go/src/components/settings/SubscriptionOption.tsx +++ b/mobile/asa-go/src/components/settings/SubscriptionOption.tsx @@ -1,31 +1,36 @@ import { FireZoneUnit } from "@/api/fbaAPI"; -import { toggleSubscription } from "@/slices/settingsSlice"; -import { AppDispatch, selectSettings } from "@/store"; +import LoadingSwitch from "@/components/LoadingSwitch"; +import { selectSettings } from "@/store"; import { fireZoneUnitNameFormatter } from "@/utils/stringUtils"; import { ListItem, ListItemButton, ListItemText, - Switch, Typography, } from "@mui/material"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; interface SubscriptionOptionProps { fireZoneUnit: FireZoneUnit; + onToggle: (fireZoneUnitId: number) => void; + disabled: boolean; + loading?: boolean; } -const SubscriptionOption = ({ fireZoneUnit }: SubscriptionOptionProps) => { - const dispatch: AppDispatch = useDispatch(); +const SubscriptionOption = ({ + fireZoneUnit, + onToggle, + disabled, + loading, +}: SubscriptionOptionProps) => { const { subscriptions } = useSelector(selectSettings); - const handleSwitchChange = (e: React.ChangeEvent) => { e.stopPropagation(); - handleSubscriptionUpdate(); + onToggle(fireZoneUnit.id); }; const handleSubscriptionUpdate = () => { - dispatch(toggleSubscription(fireZoneUnit.id)); + onToggle(fireZoneUnit.id); }; return ( @@ -40,16 +45,16 @@ const SubscriptionOption = ({ fireZoneUnit }: SubscriptionOptionProps) => { {fireZoneUnitNameFormatter(fireZoneUnit.name)} - + e.stopPropagation()}> + + ); diff --git a/mobile/asa-go/src/components/settings/settings.test.tsx b/mobile/asa-go/src/components/settings/settings.test.tsx index 09c4cfbfad..1730a18cea 100644 --- a/mobile/asa-go/src/components/settings/settings.test.tsx +++ b/mobile/asa-go/src/components/settings/settings.test.tsx @@ -5,6 +5,8 @@ import { configureStore } from "@reduxjs/toolkit"; import Settings from "./Settings"; import settingsReducer from "@/slices/settingsSlice"; import networkStatusReducer from "@/slices/networkStatusSlice"; +import authenticationReducer from "@/slices/authenticationSlice"; +import pushNotificationReducer from "@/slices/pushNotificationSlice"; import { FireCentreInfo, getFireCentreInfo } from "@/api/fbaAPI"; import * as Storage from "@/utils/storage"; import { NavPanel } from "@/utils/constants"; @@ -22,8 +24,12 @@ vi.mock("@/api/fbaAPI", async () => { vi.mock("@capacitor-firebase/messaging", () => { const mockCheckPermissions = vi.fn().mockResolvedValue({ receive: "denied" }); return { + Importance: { High: 4 }, FirebaseMessaging: { checkPermissions: mockCheckPermissions, + getToken: vi.fn().mockResolvedValue({ token: "test-token" }), + addListener: vi.fn().mockResolvedValue({ remove: vi.fn() }), + removeAllListeners: vi.fn(), }, }; }); @@ -63,6 +69,8 @@ const createTestStore = (initialState = {}) => { reducer: { settings: settingsReducer, networkStatus: networkStatusReducer, + authentication: authenticationReducer, + pushNotification: pushNotificationReducer, }, preloadedState: initialState, }); @@ -183,6 +191,13 @@ describe("Settings", () => { settings: { ...settingsReducer(undefined, { type: "unknown" }), fireCentreInfos: mockFireCentreInfos, + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission: "granted", + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, }, networkStatus: { networkStatus: { connected: true, connectionType: "wifi" }, @@ -312,6 +327,91 @@ describe("Settings", () => { expect(fireCentreElements[1]).toHaveTextContent(/PRINCE GEORGE/i); }); }); + it("disables accordions when awaiting FCM token", async () => { + const { FirebaseMessaging } = await import("@capacitor-firebase/messaging"); + (FirebaseMessaging.checkPermissions as Mock).mockResolvedValue({ receive: "granted" }); + + const store = createTestStore({ + settings: { + ...settingsReducer(undefined, { type: "unknown" }), + fireCentreInfos: mockFireCentreInfos, + }, + pushNotification: { + pushNotificationPermission: "granted", + registeredFcmToken: null, + deviceIdError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + + render( + + + , + ); + + await waitFor(() => { + const accordion = screen.getAllByRole("heading")[0].closest(".MuiAccordion-root"); + expect(accordion).toHaveStyle({ opacity: "0.5" }); + }); + }); + + it("shows device ID error banner when deviceIdError is true", async () => { + const store = createTestStore({ + settings: { + ...settingsReducer(undefined, { type: "unknown" }), + }, + pushNotification: { + pushNotificationPermission: "unknown", + registeredFcmToken: null, + deviceIdError: true, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("device-id-error-banner")).toBeInTheDocument(); + }); + }); + + it("does not show device ID error banner when deviceIdError is false", async () => { + const store = createTestStore({ + settings: { + ...settingsReducer(undefined, { type: "unknown" }), + }, + pushNotification: { + pushNotificationPermission: "unknown", + registeredFcmToken: null, + deviceIdError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + + render( + + + , + ); + + await waitFor(() => { + expect( + screen.queryByTestId("device-id-error-banner"), + ).not.toBeInTheDocument(); + }); + }); + it("sorts fire zone units alphabetically", async () => { // Mock permission check to return granted immediately const { FirebaseMessaging } = await import("@capacitor-firebase/messaging"); diff --git a/mobile/asa-go/src/components/settings/subscriptionAccordion.test.tsx b/mobile/asa-go/src/components/settings/subscriptionAccordion.test.tsx index dd1010df24..5206cf9578 100644 --- a/mobile/asa-go/src/components/settings/subscriptionAccordion.test.tsx +++ b/mobile/asa-go/src/components/settings/subscriptionAccordion.test.tsx @@ -9,9 +9,37 @@ import { waitFor, within, } from "@testing-library/react"; + import { Provider } from "react-redux"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import SubscriptionAccordion from "@/components/settings/SubscriptionAccordion"; +vi.mock("@/hooks/useDeviceId", () => ({ + useDeviceId: vi.fn().mockReturnValue("test-device-id"), +})); + +vi.mock("api/pushNotificationsAPI", () => ({ + getNotificationSettings: vi.fn(), + updateNotificationSettings: vi.fn(), + registerToken: vi.fn(), +})); + +vi.mock("@/utils/retryWithBackoff", () => ({ + retryWithBackoff: vi.fn((op: () => Promise) => op()), +})); + +import { + getNotificationSettings, + updateNotificationSettings, +} from "api/pushNotificationsAPI"; +import { subscriptionUpdateErrorMessage } from "@/utils/constants"; + +vi.mock("@capacitor/preferences", () => ({ + Preferences: { + get: vi.fn().mockResolvedValue({ value: null }), + set: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }, +})); const FIRE_CENTRE_LABEL = "KAMLOOPS"; const VERNON_ZONE_LABEL = "K4-Vernon"; @@ -78,6 +106,13 @@ const mockFireCentreInfos: FireCentreInfo[] = [ ]; describe("SubscriptionAccordion", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getNotificationSettings).mockResolvedValue([]); + vi.mocked(updateNotificationSettings).mockImplementation((_, subs) => + Promise.resolve(subs), + ); + }); it("renders correctly with fire centre name", () => { const store = createTestStore(); @@ -150,7 +185,25 @@ describe("SubscriptionAccordion", () => { }); it("expands and collapses when clicked", async () => { - const store = createTestStore(); + const store = createTestStore({ + pushNotification: { + pushNotificationPermission: "granted", + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + settings: { + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptions: [], + subscriptionsInitialized: true, + }, + }); render( @@ -198,10 +251,73 @@ describe("SubscriptionAccordion", () => { const accordion = getAccordionButton()?.closest(".MuiAccordion-root"); expect(accordion).toHaveStyle({ opacity: "0.5", filter: "grayscale(1)" }); + // Check that checkbox is disabled + const checkbox = within(accordion as HTMLElement).getByRole("checkbox"); + expect(checkbox).toBeDisabled(); + // Check if the accordion is not interactive fireEvent.click(getAccordionButton() as HTMLElement); expect(getZoneLabel(LILLOOET_ZONE_LABEL)).not.toBeVisible(); expect(getZoneLabel(VERNON_ZONE_LABEL)).not.toBeVisible(); + + // Check all toggle switches are disabled + const toggleSwitches = within(accordion as HTMLElement).getAllByTestId( + "loading-switch", + ); + + toggleSwitches.forEach((toggleSwitch) => { + // MUI switches are spans, so we gotta check for the class + expect(toggleSwitch).toHaveClass("Mui-disabled"); + }); + }); + + it("shows a snackbar error when a subscription toggle fails", async () => { + vi.mocked(updateNotificationSettings).mockRejectedValue( + new Error("server error"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const store = createTestStore({ + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + pushNotification: { + pushNotificationPermission: "granted", + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, + }, + settings: { + subscriptionsInitialized: true, + subscriptions: [], + pinnedFireCentre: null, + loading: false, + error: null, + fireCentreInfos: [mockFireCentreInfo], + }, + }); + + render( + + + , + ); + + const firstZone = mockFireCentreInfo.fire_zone_units[0]; + + await act(async () => { + fireEvent.click( + screen.getByLabelText(`Toggle subscription for ${firstZone.name}`), + ); + }); + + expect(updateNotificationSettings).toHaveBeenCalled(); + expect(screen.getByText(subscriptionUpdateErrorMessage)).toBeInTheDocument(); + expect(screen.queryAllByTestId("loading-switch-error")).toHaveLength(0); }); it("displays filled push pin icon when fire centre is pinned", () => { @@ -294,23 +410,41 @@ describe("SubscriptionAccordion", () => { }); it("selects all fire zone units when 'All' checkbox is checked", async () => { - const store = createTestStore(); + const store = createTestStore({ + settings: { + ...settingsReducer(undefined, { type: "unknown" }), + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptions: [], + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); - render( - - - , + await act(async () => + render( + + + , + ), ); - // Click the "All" checkbox to select all - const allCheckbox = getAllCheckbox("Kamloops Fire Centre"); - fireEvent.click(allCheckbox); + fireEvent.click(getAllCheckbox("Kamloops Fire Centre")); - // Check that all fire zone units are now subscribed await waitFor(() => { expect(store.getState().settings.subscriptions).toEqual( KAMLOOPS_FIRE_ZONE_IDS, @@ -323,24 +457,37 @@ describe("SubscriptionAccordion", () => { settings: { ...settingsReducer(undefined, { type: "unknown" }), subscriptions: KAMLOOPS_FIRE_ZONE_IDS, + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, }, }); - render( - - - , + await act(async () => + render( + + + , + ), ); - // Click the "All" checkbox to deselect all - const allCheckbox = getAllCheckbox("Kamloops Fire Centre"); - fireEvent.click(allCheckbox); + fireEvent.click(getAllCheckbox("Kamloops Fire Centre")); - // Check that all fire zone units are now unsubscribed await waitFor(() => { expect(store.getState().settings.subscriptions).toEqual([]); }); @@ -351,6 +498,11 @@ describe("SubscriptionAccordion", () => { settings: { ...settingsReducer(undefined, { type: "unknown" }), subscriptions: [mockFireCentreInfo.fire_zone_units[0].id], + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptionsInitialized: true, }, }); @@ -424,14 +576,24 @@ describe("SubscriptionAccordion", () => { it("checkbox does not impact fire zone unit ids that are not related to the current fire centre", async () => { const initialSubscriptions = [100, 200]; + const store = createTestStore({ settings: { loading: false, error: null, fireCentreInfos: [], pinnedFireCentre: null, - pushNotificationPermission: "unknown", subscriptions: initialSubscriptions, + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, }, }); @@ -475,6 +637,54 @@ describe("SubscriptionAccordion", () => { expect(subs4).not.toContain(mockFireCentreInfo.fire_zone_units[1].id); }); + it("shows error snackbar when subscription update fails", async () => { + vi.mocked(updateNotificationSettings).mockRejectedValue( + new Error("server error"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const store = createTestStore({ + settings: { + ...settingsReducer(undefined, { type: "unknown" }), + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptions: [], + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + + await act(async () => + render( + + + , + ), + ); + + fireEvent.click( + screen.getByLabelText("Toggle subscription for K4-Vernon Zone (Vernon)"), + ); + + await waitFor(() => { + expect(screen.getByText(/Failed to update/i)).toBeInTheDocument(); + }); + }); + it("All checkbox on accordion works independently", async () => { const store = createTestStore({ settings: { @@ -482,6 +692,15 @@ describe("SubscriptionAccordion", () => { fireCentreInfos: mockFireCentreInfos, pinnedFireCentre: null, subscriptions: [3, 4], + loading: false, + error: null, + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, }, networkStatus: { networkStatus: { connected: true, connectionType: "wifi" }, diff --git a/mobile/asa-go/src/components/settings/subscriptionOption.test.tsx b/mobile/asa-go/src/components/settings/subscriptionOption.test.tsx index 2fad446dfd..7d84cb92f6 100644 --- a/mobile/asa-go/src/components/settings/subscriptionOption.test.tsx +++ b/mobile/asa-go/src/components/settings/subscriptionOption.test.tsx @@ -2,20 +2,15 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { Provider } from "react-redux"; import { vi } from "vitest"; import { createTestStore } from "@/testUtils"; -import { Preferences } from "@capacitor/preferences"; import SubscriptionOption from "./SubscriptionOption"; import { FireZoneUnit } from "@/api/fbaAPI"; +import { setSubscriptions } from "@/slices/settingsSlice"; +import { getUpdatedSubscriptions } from "@/utils/subscriptionUtils"; -const KAMLOOPS_SWITCH_LABEL = "Toggle subscription for K2-Kamloops Zone (Kamloops)"; +const KAMLOOPS_SWITCH_LABEL = + "Toggle subscription for K2-Kamloops Zone (Kamloops)"; const VANJAM_LABEL = "G4-VanJam\n(Vanderhoof)"; -vi.mock("@capacitor/preferences", () => ({ - Preferences: { - get: vi.fn().mockResolvedValue({ value: null }), - set: vi.fn().mockResolvedValue(undefined), - }, -})); - const getZoneLabel = (label: string) => screen.getByText((_, element) => { return element?.tagName === "P" && element.textContent === label; @@ -37,15 +32,24 @@ describe("SubscriptionOption", () => { error: null, fireCentreInfos: [], pinnedFireCentre: null, - pushNotificationPermission: "unknown", subscriptions: initialSubscriptions, + subscriptionsInitialized: false, }, }); + const onToggle = (id: number) => { + const current = store.getState().settings.subscriptions; + store.dispatch(setSubscriptions(getUpdatedSubscriptions(current, id))); + }; + return { ...render( - + , ), store, @@ -84,18 +88,22 @@ describe("SubscriptionOption", () => { it("renders with switch unchecked when not subscribed", () => { renderWithProvider(mockFireZoneUnit, []); - const switchEl = screen.getByRole("checkbox", { name: KAMLOOPS_SWITCH_LABEL }); + const switchEl = screen.getByRole("checkbox", { + name: KAMLOOPS_SWITCH_LABEL, + }); expect(switchEl).not.toBeChecked(); }); it("renders with switch checked when subscribed", () => { renderWithProvider(mockFireZoneUnit, [123]); - const switchEl = screen.getByRole("checkbox", { name: KAMLOOPS_SWITCH_LABEL }); + const switchEl = screen.getByRole("checkbox", { + name: KAMLOOPS_SWITCH_LABEL, + }); expect(switchEl).toBeChecked(); }); - it("dispatches saveSubscriptions when clicking the list item (not subscribed)", async () => { + it("toggles subscription on when clicking the list item (not subscribed)", async () => { const { store } = renderWithProvider(mockFireZoneUnit, []); const listItemButton = screen.getByRole("button"); @@ -104,13 +112,9 @@ describe("SubscriptionOption", () => { await waitFor(() => { expect(store.getState().settings.subscriptions).toContain(123); }); - expect(Preferences.set).toHaveBeenCalledWith({ - key: "asaGoSubscriptions", - value: JSON.stringify([123]), - }); }); - it("dispatches saveSubscriptions when clicking the list item (already subscribed)", async () => { + it("toggles subscription off when clicking the list item (already subscribed)", async () => { const { store } = renderWithProvider(mockFireZoneUnit, [123]); const listItemButton = screen.getByRole("button"); @@ -119,16 +123,14 @@ describe("SubscriptionOption", () => { await waitFor(() => { expect(store.getState().settings.subscriptions).not.toContain(123); }); - expect(Preferences.set).toHaveBeenCalledWith({ - key: "asaGoSubscriptions", - value: JSON.stringify([]), - }); }); - it("dispatches saveSubscriptions when toggling the switch (turn on)", async () => { + it("toggles subscription on when toggling the switch (turn on)", async () => { const { store } = renderWithProvider(mockFireZoneUnit, []); - const switchEl = screen.getByRole("checkbox", { name: KAMLOOPS_SWITCH_LABEL }); + const switchEl = screen.getByRole("checkbox", { + name: KAMLOOPS_SWITCH_LABEL, + }); fireEvent.click(switchEl); await waitFor(() => { @@ -136,10 +138,12 @@ describe("SubscriptionOption", () => { }); }); - it("dispatches saveSubscriptions when toggling the switch (turn off)", async () => { + it("toggles subscription off when toggling the switch (turn off)", async () => { const { store } = renderWithProvider(mockFireZoneUnit, [123]); - const switchEl = screen.getByRole("checkbox", { name: KAMLOOPS_SWITCH_LABEL }); + const switchEl = screen.getByRole("checkbox", { + name: KAMLOOPS_SWITCH_LABEL, + }); fireEvent.click(switchEl); await waitFor(() => { @@ -154,7 +158,9 @@ describe("SubscriptionOption", () => { expect(store.getState().settings.subscriptions).toEqual([100, 200]); }); - const switchEl = screen.getByRole("checkbox", { name: KAMLOOPS_SWITCH_LABEL }); + const switchEl = screen.getByRole("checkbox", { + name: KAMLOOPS_SWITCH_LABEL, + }); fireEvent.click(switchEl); // All fire zone units should be subscribed to diff --git a/mobile/asa-go/src/hooks/useDeviceId.test.ts b/mobile/asa-go/src/hooks/useDeviceId.test.ts new file mode 100644 index 0000000000..a08a507592 --- /dev/null +++ b/mobile/asa-go/src/hooks/useDeviceId.test.ts @@ -0,0 +1,64 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDeviceId } from "./useDeviceId"; +import { createTestStore } from "@/testUtils"; +import { Provider } from "react-redux"; +import React from "react"; + +vi.mock("@capacitor/device", () => ({ + Device: { getId: vi.fn() }, +})); + +vi.mock("@capacitor/preferences", () => ({ + Preferences: { + get: vi.fn().mockResolvedValue({ value: null }), + }, +})); + +import { Device } from "@capacitor/device"; + +function renderWithStore() { + const store = createTestStore(); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { store, children }); + return { store, ...renderHook(() => useDeviceId(), { wrapper }) }; +} + +describe("useDeviceId", () => { + beforeEach(() => { + vi.mocked(Device.getId).mockResolvedValue({ identifier: "test-device-id" }); + }); + + it("returns null before Device.getId resolves", () => { + vi.mocked(Device.getId).mockReturnValue(new Promise(() => {})); + const { result } = renderWithStore(); + expect(result.current).toBeNull(); + }); + + it("returns the device identifier after resolving", async () => { + const { result } = renderWithStore(); + await act(async () => {}); + expect(result.current).toBe("test-device-id"); + }); + + it("sets deviceIdError in the store when Device.getId fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(Device.getId).mockRejectedValue(new Error("hardware error")); + const { result, store } = renderWithStore(); + await act(async () => {}); + expect(result.current).toBeNull(); + expect(store.getState().pushNotification.deviceIdError).toBe(true); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to get device ID")); + consoleSpy.mockRestore(); + }); + + it("clears deviceIdError in the store when Device.getId succeeds", async () => { + const store = createTestStore({ pushNotification: { pushNotificationPermission: "unknown", registeredFcmToken: null, deviceIdError: true, registrationError: false } }); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { store, children }); + const { result } = renderHook(() => useDeviceId(), { wrapper }); + await act(async () => {}); + expect(result.current).toBe("test-device-id"); + expect(store.getState().pushNotification.deviceIdError).toBe(false); + }); +}); diff --git a/mobile/asa-go/src/hooks/useDeviceId.ts b/mobile/asa-go/src/hooks/useDeviceId.ts new file mode 100644 index 0000000000..b63c67935e --- /dev/null +++ b/mobile/asa-go/src/hooks/useDeviceId.ts @@ -0,0 +1,24 @@ +import { setDeviceIdError } from "@/slices/pushNotificationSlice"; +import { AppDispatch } from "@/store"; +import { Device } from "@capacitor/device"; +import { useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; + +export function useDeviceId(): string | null { + const dispatch = useDispatch(); + const [deviceId, setDeviceId] = useState(null); + + useEffect(() => { + Device.getId() + .then(({ identifier }) => { + dispatch(setDeviceIdError(false)); + setDeviceId(identifier); + }) + .catch((e) => { + console.error(`Failed to get device ID: ${e}`); + dispatch(setDeviceIdError(true)); + }); + }, [dispatch]); + + return deviceId; +} diff --git a/mobile/asa-go/src/hooks/useNotificationSettings.test.ts b/mobile/asa-go/src/hooks/useNotificationSettings.test.ts new file mode 100644 index 0000000000..7a28ecb658 --- /dev/null +++ b/mobile/asa-go/src/hooks/useNotificationSettings.test.ts @@ -0,0 +1,288 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { useNotificationSettings } from "./useNotificationSettings"; +import { createTestStore } from "@/testUtils"; +import { Provider } from "react-redux"; +import React from "react"; + +vi.mock("api/pushNotificationsAPI", () => ({ + updateNotificationSettings: vi.fn(), +})); + +vi.mock("@/utils/retryWithBackoff", () => ({ + retryWithBackoff: vi.fn((op: () => Promise) => op()), +})); + +vi.mock("@capacitor/device", () => ({ + Device: { + getId: vi.fn().mockResolvedValue({ identifier: "test-device-id" }), + }, +})); + +vi.mock("@capacitor/preferences", () => ({ + Preferences: { + get: vi.fn().mockResolvedValue({ value: null }), + set: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { updateNotificationSettings } from "api/pushNotificationsAPI"; +import { retryWithBackoff } from "@/utils/retryWithBackoff"; + +const onlineState = { + networkStatus: { + networkStatus: { + connected: true, + connectionType: "wifi" as "wifi" | "cellular" | "none" | "unknown", + }, + }, + settings: { + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptions: [] as number[], + subscriptionsInitialized: true, + }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "test-token", + deviceIdError: false, + registrationError: false, + }, +}; + +const offlineState = { + networkStatus: { + networkStatus: { + connected: false, + connectionType: "none" as "wifi" | "cellular" | "none" | "unknown", + }, + }, +}; + +function renderWithStore(storeState = onlineState) { + const store = createTestStore(storeState); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { store, children }); + return { store, ...renderHook(() => useNotificationSettings(), { wrapper }) }; +} + +describe("useNotificationSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses retryWithBackoff when updating subscriptions", async () => { + (updateNotificationSettings as Mock).mockResolvedValue(["1"]); + + const { result } = await act(async () => renderWithStore()); + vi.clearAllMocks(); + + await act(async () => { + await result.current.updateSubscriptions([1]); + }); + + expect(retryWithBackoff).toHaveBeenCalledTimes(1); + }); + + it("updateSubscriptions saves locally and syncs to server when online", async () => { + (updateNotificationSettings as Mock).mockResolvedValue(["10", "20"]); + + const { result, store } = await act(async () => renderWithStore()); + + await act(async () => { + await result.current.updateSubscriptions([10, 20]); + }); + + expect(updateNotificationSettings).toHaveBeenCalledWith("test-device-id", [ + "10", + "20", + ]); + expect(store.getState().settings.subscriptions).toEqual([10, 20]); + }); + + it("updateSubscriptions skips API call when offline", async () => { + const { result } = await act(async () => + renderWithStore({ ...onlineState, ...offlineState }), + ); + + await act(async () => { + await result.current.updateSubscriptions([10]); + }); + + expect(updateNotificationSettings).not.toHaveBeenCalled(); + }); + + it("rolls back subscription change when offline", async () => { + const store = createTestStore({ + ...offlineState, + settings: { + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptions: [5], + subscriptionsInitialized: false, + }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "tok", + deviceIdError: false, + registrationError: false, + }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { store, children }); + + const { result } = await act(async () => + renderHook(() => useNotificationSettings(), { wrapper }), + ); + + await act(async () => { + await result.current.updateSubscriptions([5, 10]); + }); + + expect(store.getState().settings.subscriptions).toEqual([5]); + }); + + it("updateSubscriptions updates state from server response", async () => { + // server returns different subs than what was sent (e.g. server corrects the list) + (updateNotificationSettings as Mock).mockResolvedValue(["5", "6"]); + + const { result, store } = await act(async () => renderWithStore()); + + await act(async () => { + await result.current.updateSubscriptions([10, 20]); + }); + + expect(store.getState().settings.subscriptions).toEqual([5, 6]); + }); + + it("toggleSubscription adds a subscription", async () => { + (updateNotificationSettings as Mock).mockResolvedValue(["1", "2"]); + + const { result, store } = await act(async () => + renderWithStore({ + ...onlineState, + settings: { ...onlineState.settings, subscriptions: [1] }, + }), + ); + + await act(async () => { + await result.current.toggleSubscription(2); + }); + + expect(updateNotificationSettings).toHaveBeenCalledWith("test-device-id", [ + "1", + "2", + ]); + expect(store.getState().settings.subscriptions).toEqual([1, 2]); + }); + + it("toggleSubscription removes an existing subscription", async () => { + (updateNotificationSettings as Mock).mockResolvedValue(["2"]); + + const { result, store } = await act(async () => + renderWithStore({ + ...onlineState, + settings: { ...onlineState.settings, subscriptions: [1, 2] }, + }), + ); + + await act(async () => { + await result.current.toggleSubscription(1); + }); + + expect(updateNotificationSettings).toHaveBeenCalledWith("test-device-id", [ + "2", + ]); + expect(store.getState().settings.subscriptions).toEqual([2]); + }); + + it("rolls back subscription change when not registered", async () => { + const store = createTestStore({ + ...onlineState, + settings: { ...onlineState.settings, subscriptions: [5] }, + pushNotification: { + pushNotificationPermission: "granted" as const, + registeredFcmToken: null, + deviceIdError: false, + registrationError: false, + }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { store, children }); + const { result } = await act(async () => + renderHook(() => useNotificationSettings(), { wrapper }), + ); + + await act(async () => { + await result.current.updateSubscriptions([5, 10]); + }); + + expect(updateNotificationSettings).not.toHaveBeenCalled(); + expect(store.getState().settings.subscriptions).toEqual([5]); + }); + + it("reverts local state when update fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + (updateNotificationSettings as Mock).mockRejectedValue( + new Error("server error"), + ); + + const { result, store } = await act(async () => + renderWithStore({ + ...onlineState, + settings: { ...onlineState.settings, subscriptions: [5, 6] }, + }), + ); + + await act(async () => { + await result.current.updateSubscriptions([1]).catch(() => {}); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to update"), + ); + expect(store.getState().settings.subscriptions).toEqual([5, 6]); + consoleSpy.mockRestore(); + }); + + it("sets updateError to true when update fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + (updateNotificationSettings as Mock).mockRejectedValue( + new Error("server error"), + ); + + const { result } = await act(async () => renderWithStore()); + + await act(async () => { + await result.current.updateSubscriptions([1]).catch(() => {}); + }); + + expect(result.current.updateError).toBe(true); + consoleSpy.mockRestore(); + }); + + it("clears updateError after a successful update", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + (updateNotificationSettings as Mock) + .mockRejectedValueOnce(new Error("server error")) + .mockResolvedValueOnce(["1"]); + + const { result } = await act(async () => renderWithStore()); + + await act(async () => { + await result.current.updateSubscriptions([1]).catch(() => {}); + }); + expect(result.current.updateError).toBe(true); + + await act(async () => { + await result.current.updateSubscriptions([1]); + }); + expect(result.current.updateError).toBe(false); + consoleSpy.mockRestore(); + }); +}); diff --git a/mobile/asa-go/src/hooks/useNotificationSettings.ts b/mobile/asa-go/src/hooks/useNotificationSettings.ts new file mode 100644 index 0000000000..f9182f91a1 --- /dev/null +++ b/mobile/asa-go/src/hooks/useNotificationSettings.ts @@ -0,0 +1,62 @@ +import { updateNotificationSettings } from "api/pushNotificationsAPI"; +import { useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { setSubscriptions } from "@/slices/settingsSlice"; +import { getUpdatedSubscriptions } from "@/utils/subscriptionUtils"; +import { + AppDispatch, + selectNetworkStatus, + selectPushNotification, + selectSettings, +} from "@/store"; +import { useDeviceId } from "@/hooks/useDeviceId"; +import { retryWithBackoff } from "@/utils/retryWithBackoff"; + +export function useNotificationSettings() { + const dispatch = useDispatch(); + const { networkStatus } = useSelector(selectNetworkStatus); + const { subscriptions } = useSelector(selectSettings); + const { registeredFcmToken } = useSelector(selectPushNotification); + const { subscriptionsInitialized } = useSelector(selectSettings); + const deviceId = useDeviceId(); + const [updateError, setUpdateError] = useState(false); + + const updateSubscriptions = async (subs: number[]): Promise => { + // Guard matches selectNotificationSettingsDisabled — button should be disabled + // before this is reachable, but guard prevents any state change if not. + if ( + !deviceId || + !networkStatus.connected || + !registeredFcmToken || + !subscriptionsInitialized + ) + return false; + const previousSubs = subscriptions; + dispatch(setSubscriptions(subs)); + try { + const ids = await retryWithBackoff(() => + updateNotificationSettings(deviceId, subs.map(String)), + ); + dispatch(setSubscriptions(ids.map(Number))); + setUpdateError(false); + return true; + } catch (e) { + console.error(`Failed to update notification settings: ${e}`); + dispatch(setSubscriptions(previousSubs)); + setUpdateError(true); + return false; + } + }; + + const toggleSubscription = (fireZoneUnitId: number) => + updateSubscriptions(getUpdatedSubscriptions(subscriptions, fireZoneUnitId)); + + const clearUpdateError = () => setUpdateError(false); + + return { + updateSubscriptions, + toggleSubscription, + updateError, + clearUpdateError, + }; +} diff --git a/mobile/asa-go/src/hooks/usePushNotifications.test.ts b/mobile/asa-go/src/hooks/usePushNotifications.test.ts index 2d56d7ec66..d981329049 100644 --- a/mobile/asa-go/src/hooks/usePushNotifications.test.ts +++ b/mobile/asa-go/src/hooks/usePushNotifications.test.ts @@ -1,133 +1,555 @@ -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { usePushNotifications } from "./usePushNotifications"; -import { PushNotificationService } from "@/services/pushNotificationService"; -import type { PushInitOptions } from "@/services/pushNotificationService"; +import { + FirebaseMessaging, + PermissionStatus, +} from "@capacitor-firebase/messaging"; +import { Capacitor } from "@capacitor/core"; -// Define an interface for the PushNotificationService methods we use -interface IPushNotificationService { - initPushNotificationService: () => Promise; - unregister: () => Promise; +vi.mock("@capacitor-firebase/messaging", () => ({ + FirebaseMessaging: { + checkPermissions: vi.fn(), + requestPermissions: vi.fn(), + createChannel: vi.fn(), + getToken: vi.fn(), + addListener: vi.fn(), + removeAllListeners: vi.fn(), + }, + Importance: { High: 4 }, +})); + +vi.mock(import("@capacitor/core"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Capacitor: { + ...actual.Capacitor, + getPlatform: vi.fn().mockReturnValue("android"), + }, + }; +}); + +vi.mock("@/hooks/useAppIsActive", () => ({ + useAppIsActive: vi.fn().mockReturnValue(true), +})); + +const mockDispatch = vi.fn(); +vi.mock("react-redux", () => ({ + useDispatch: () => mockDispatch, + useSelector: vi.fn((selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: false, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }), + ), +})); + +vi.mock("@/slices/pushNotificationSlice", async (importOriginal) => { + const actual = await importOriginal< + typeof import("@/slices/pushNotificationSlice") + >(); + return { + ...actual, + registerDevice: vi.fn((token: string, registered: string | null) => ({ + type: "registerDevice", + token, + registered, + })), + setRegistrationError: vi.fn((value: boolean) => ({ + type: "setRegistrationError", + value, + })), + }; +}); + +function setupFirebaseMocks({ + token = "test-fcm-token", + permissionStatus = "granted", +} = {}) { + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: permissionStatus, + } as PermissionStatus); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ token }); + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), + }); } -// Mock the PushNotificationService -vi.mock("@/services/pushNotificationService"); +const defaultSelectorState = { + pushNotification: { + registrationError: false, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + registrationAttempts: 0, + }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, +}; describe("usePushNotifications", () => { - // Reset all mocks before each test - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + vi.mocked(Capacitor.getPlatform).mockReturnValue("android"); + const { useSelector } = await import("react-redux"); + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => selector(defaultSelectorState), + ); }); - it("should initialize with token null", () => { + it("initializes and exposes initPushNotifications and retryRegistration", () => { const { result } = renderHook(() => usePushNotifications()); - expect(result.current.token).toBeNull(); expect(result.current.initPushNotifications).toBeInstanceOf(Function); + expect(result.current.retryRegistration).toBeInstanceOf(Function); + }); + + it("sets token after successful init", async () => { + setupFirebaseMocks({ token: "test-fcm-token" }); + const { result } = renderHook(() => usePushNotifications()); + + await act(async () => { + await result.current.initPushNotifications(); + }); + + expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); }); - it("should create a PushNotificationService instance and call initPushNotificationService when initPushNotifications is called", async () => { - // Mock the service methods - const mockInit = vi.fn().mockResolvedValue(undefined); - (PushNotificationService as Mock).mockImplementation(function ( - this: IPushNotificationService, - ) { - this.initPushNotificationService = mockInit; - this.unregister = vi.fn(); + it("updates token when tokenReceived fires", async () => { + let tokenListener: ((e: { token: string }) => void) | undefined; + setupFirebaseMocks({ token: "initial-token" }); + vi.mocked(FirebaseMessaging.addListener).mockImplementation( + async (event, handler) => { + if ((event as string) === "tokenReceived") + tokenListener = handler as unknown as typeof tokenListener; + return { remove: vi.fn() }; + }, + ); + const { registerDevice } = await import("@/slices/pushNotificationSlice"); + const { useSelector } = await import("react-redux"); + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: false, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + registrationAttempts: 0, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }), + ); + + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.initPushNotifications(); + }); + + await act(async () => { + tokenListener?.({ token: "refreshed-token" }); }); + expect(mockDispatch).toHaveBeenCalledWith( + registerDevice("refreshed-token", null), + ); + }); + + it("prevents multiple initializations", async () => { + setupFirebaseMocks(); const { result } = renderHook(() => usePushNotifications()); await act(async () => { await result.current.initPushNotifications(); + await result.current.initPushNotifications(); }); - expect(PushNotificationService).toHaveBeenCalledTimes(1); - expect(mockInit).toHaveBeenCalledTimes(1); + expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); }); - it("should set token when onRegister callback is triggered", async () => { - const testToken = "test-fcm-token"; - (PushNotificationService as Mock).mockImplementation(function ( - this: IPushNotificationService, - opts: PushInitOptions, - ) { - // Trigger onRegister callback immediately - opts.onRegister?.(testToken); - this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); - this.unregister = vi.fn(); + it("requests permissions when not initially granted", async () => { + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: "test-token", + }); + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), }); const { result } = renderHook(() => usePushNotifications()); - await act(async () => { await result.current.initPushNotifications(); }); - expect(result.current.token).toEqual(testToken); + expect(FirebaseMessaging.requestPermissions).toHaveBeenCalledTimes(1); }); - it("should prevent multiple initializations of PushNotificationService", async () => { - (PushNotificationService as Mock).mockImplementation(function ( - this: IPushNotificationService, - ) { - this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); - this.unregister = vi.fn(); + it("does not throw when permissions are denied", async () => { + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ + remove: vi.fn(), }); const { result } = renderHook(() => usePushNotifications()); + await expect( + act(async () => { + await result.current.initPushNotifications(); + }), + ).resolves.not.toThrow(); + }); + + it("dispatches setRegistrationError when getToken fails during init", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { setRegistrationError } = await import( + "@/slices/pushNotificationSlice" + ); + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.getToken).mockRejectedValue( + new Error("token error"), + ); + const { result } = renderHook(() => usePushNotifications()); await act(async () => { await result.current.initPushNotifications(); - await result.current.initPushNotifications(); // Call again }); - expect(PushNotificationService).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(setRegistrationError(true)); + consoleSpy.mockRestore(); }); - it("should call unregister on service when component unmounts", async () => { - const mockUnregister = vi.fn(); - (PushNotificationService as Mock).mockImplementation(function ( - this: IPushNotificationService, - ) { - this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); - this.unregister = mockUnregister; + it("does not dispatch setRegistrationError when permissions are denied", async () => { + const { setRegistrationError } = await import( + "@/slices/pushNotificationSlice" + ); + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ + receive: "denied", + } as PermissionStatus); + + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.initPushNotifications(); }); - const { result, unmount } = renderHook(() => usePushNotifications()); + expect(mockDispatch).not.toHaveBeenCalledWith(setRegistrationError(true)); + }); + it("retries registration after getToken fails during init", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { useSelector } = await import("react-redux"); + const { setRegistrationError, registerDevice } = await import( + "@/slices/pushNotificationSlice" + ); + + vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ + receive: "granted", + } as PermissionStatus); + vi.mocked(FirebaseMessaging.getToken).mockRejectedValue( + new Error("token error"), + ); + + const { result, rerender } = renderHook(() => usePushNotifications()); await act(async () => { await result.current.initPushNotifications(); }); + expect(mockDispatch).toHaveBeenCalledWith(setRegistrationError(true)); - unmount(); + // Simulate opening Settings: selector now reflects registrationError: true + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: true, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + registrationAttempts: 0, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }), + ); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: "retry-token", + }); + rerender(); - expect(mockUnregister).toHaveBeenCalledTimes(1); + await act(async () => { + await result.current.retryRegistration(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + registerDevice("retry-token", null), + ); + consoleSpy.mockRestore(); }); - it("should pass correct android channel configuration to PushNotificationService", async () => { - (PushNotificationService as Mock).mockImplementation(function ( - this: IPushNotificationService, - ) { - this.initPushNotificationService = vi.fn().mockResolvedValue(undefined); - this.unregister = vi.fn(); - }); + it("does not create Android channel on iOS", async () => { + vi.mocked(Capacitor.getPlatform).mockReturnValue("ios"); + setupFirebaseMocks(); const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.initPushNotifications(); + }); + + expect(FirebaseMessaging.createChannel).not.toHaveBeenCalled(); + }); + + it("removes all listeners on unmount only when initialized", async () => { + const remove1 = vi.fn(); + const remove2 = vi.fn(); + const remove3 = vi.fn(); + vi.mocked(FirebaseMessaging.addListener) + .mockResolvedValueOnce({ remove: remove1 }) + .mockResolvedValueOnce({ remove: remove2 }) + .mockResolvedValueOnce({ remove: remove3 }); + setupFirebaseMocks(); + const { result, unmount } = renderHook(() => usePushNotifications()); await act(async () => { await result.current.initPushNotifications(); }); - expect(PushNotificationService).toHaveBeenCalledWith( - expect.objectContaining({ - androidChannel: expect.objectContaining({ - id: "general", - name: "General", - description: "General notifications", - importance: 4, // High importance - sound: "default", - }), - }), - ); + unmount(); + + expect(FirebaseMessaging.removeAllListeners).toHaveBeenCalledTimes(1); + expect(remove1).toHaveBeenCalledTimes(1); + expect(remove2).toHaveBeenCalledTimes(1); + expect(remove3).toHaveBeenCalledTimes(1); + }); + + it("does not call removeAllListeners on unmount when not initialized", () => { + const { unmount } = renderHook(() => usePushNotifications()); + unmount(); + expect(FirebaseMessaging.removeAllListeners).not.toHaveBeenCalled(); + }); + + describe("registerDevice effect", () => { + it("dispatches registerDevice when connected and token is available", async () => { + const { registerDevice } = await import("@/slices/pushNotificationSlice"); + const { useSelector } = await import("react-redux"); + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: false, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }), + ); + setupFirebaseMocks({ token: "test-fcm-token" }); + + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.initPushNotifications(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + registerDevice("test-fcm-token", null), + ); + }); + + it("does not dispatch registerDevice when offline", async () => { + const { registerDevice } = await import("@/slices/pushNotificationSlice"); + setupFirebaseMocks({ token: "test-fcm-token" }); + + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.initPushNotifications(); + }); + + expect(mockDispatch).not.toHaveBeenCalledWith( + registerDevice("test-fcm-token", null), + ); + }); + }); + + describe("retryRegistration", () => { + it("is a no-op when registrationError is false", async () => { + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.retryRegistration(); + }); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("fetches token and dispatches registerDevice", async () => { + const { useSelector } = await import("react-redux"); + const { setRegistrationError, registerDevice } = await import( + "@/slices/pushNotificationSlice" + ); + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: true, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + registrationAttempts: 0, + }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }), + ); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: "retry-token", + }); + + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.retryRegistration(); + }); + + expect(mockDispatch).toHaveBeenCalledWith(setRegistrationError(false)); + expect(mockDispatch).toHaveBeenCalledWith( + registerDevice("retry-token", null), + ); + }); + + it("restores registrationError and does not dispatch registerDevice when getToken fails", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const { useSelector } = await import("react-redux"); + const { setRegistrationError, registerDevice } = await import( + "@/slices/pushNotificationSlice" + ); + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: true, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + registrationAttempts: 0, + }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }), + ); + vi.mocked(FirebaseMessaging.getToken).mockRejectedValue( + new Error("token error"), + ); + + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.retryRegistration(); + }); + + expect(mockDispatch).toHaveBeenNthCalledWith( + 1, + setRegistrationError(false), + ); + expect(mockDispatch).toHaveBeenNthCalledWith( + 2, + setRegistrationError(true), + ); + expect(mockDispatch).not.toHaveBeenCalledWith( + registerDevice(expect.anything(), null), + ); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("skips registration when registrationAttempts has reached MAX_REGISTRATION_ATTEMPTS", async () => { + const { useSelector } = await import("react-redux"); + const { + MAX_REGISTRATION_ATTEMPTS, + resetRegistrationAttempts, + registerDevice, + } = await import("@/slices/pushNotificationSlice"); + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: true, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + registrationAttempts: MAX_REGISTRATION_ATTEMPTS, + }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }), + ); + + renderHook(() => usePushNotifications()); + + expect(mockDispatch).not.toHaveBeenCalledWith( + resetRegistrationAttempts(), + ); + expect(mockDispatch).not.toHaveBeenCalledWith( + registerDevice(expect.anything(), expect.anything()), + ); + }); + + it("retries registration on next open after counter has been reset", async () => { + const { useSelector } = await import("react-redux"); + const { registerDevice } = await import("@/slices/pushNotificationSlice"); + vi.mocked(useSelector).mockImplementation( + (selector: (s: unknown) => unknown) => + selector({ + pushNotification: { + registrationError: true, + registeredFcmToken: null, + pushNotificationPermission: "unknown", + deviceIdError: false, + registrationAttempts: 0, + }, + networkStatus: { + networkStatus: { connected: false, connectionType: "none" }, + }, + }), + ); + vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ + token: "retry-token", + }); + + const { result } = renderHook(() => usePushNotifications()); + await act(async () => { + await result.current.retryRegistration(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + registerDevice("retry-token", null), + ); + }); }); }); diff --git a/mobile/asa-go/src/hooks/usePushNotifications.ts b/mobile/asa-go/src/hooks/usePushNotifications.ts index 1826baf146..22e9881d4e 100644 --- a/mobile/asa-go/src/hooks/usePushNotifications.ts +++ b/mobile/asa-go/src/hooks/usePushNotifications.ts @@ -1,47 +1,132 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { PushNotificationService } from "@/services/pushNotificationService"; +import { + FirebaseMessaging, + Importance, + NotificationActionPerformedEvent, + NotificationReceivedEvent, + PermissionStatus, + TokenReceivedEvent, +} from "@capacitor-firebase/messaging"; +import { Capacitor, PluginListenerHandle } from "@capacitor/core"; +import { useDispatch, useSelector } from "react-redux"; +import { + AppDispatch, + selectNetworkStatus, + selectPushNotification, +} from "@/store"; +import { + MAX_REGISTRATION_ATTEMPTS, + registerDevice, + resetRegistrationAttempts, + setRegistrationError, +} from "@/slices/pushNotificationSlice"; +import { useAppIsActive } from "@/hooks/useAppIsActive"; + +const ANDROID_CHANNEL = { + id: "general", + name: "General", + description: "General notifications", + importance: Importance.High, + sound: "default", +}; export function usePushNotifications() { - const [token, setToken] = useState(null); - const serviceRef = useRef(null); + const [currentFcmToken, setCurrentFcmToken] = useState(null); + const handles = useRef([]); + const initialized = useRef(false); + const dispatch = useDispatch(); + const { registrationError, registeredFcmToken, registrationAttempts } = + useSelector(selectPushNotification); + const { networkStatus } = useSelector(selectNetworkStatus); + const isActive = useAppIsActive(); const initPushNotifications = useCallback(async () => { - // Prevent multiple initializations of the same service - if (serviceRef.current) { - return; + if (initialized.current) return; + try { + const check: PermissionStatus = + await FirebaseMessaging.checkPermissions(); + if (check.receive !== "granted") { + const req = await FirebaseMessaging.requestPermissions(); + if (req.receive !== "granted") return; + } + + if (Capacitor.getPlatform() === "android") { + await FirebaseMessaging.createChannel(ANDROID_CHANNEL); + } + + try { + const { token } = await FirebaseMessaging.getToken(); + setCurrentFcmToken(token); + } catch (e) { + console.error("Failed to get FCM token during init:", e); + dispatch(setRegistrationError(true)); + return; + } + + const tokenHandle = await FirebaseMessaging.addListener( + "tokenReceived", + (e: TokenReceivedEvent) => setCurrentFcmToken(e.token), + ); + + const receivedHandle = await FirebaseMessaging.addListener( + "notificationReceived", + (evt: NotificationReceivedEvent) => { + if (evt) console.log(evt.notification.body); + }, + ); + + const actionHandle = await FirebaseMessaging.addListener( + "notificationActionPerformed", + (evt: NotificationActionPerformedEvent) => { + if (evt) console.log(evt.notification.body); + }, + ); + + handles.current.push(tokenHandle, receivedHandle, actionHandle); + initialized.current = true; + } catch (e) { + console.error("Push notification error:", e); } + }, [dispatch]); - const service = new PushNotificationService({ - onRegister: (t) => { - setToken(t); - }, - onNotificationReceived: (_evt) => { - if (_evt) console.log(_evt.notification.body); - }, - onNotificationAction: (_evt) => { - if (_evt) console.log(_evt.notification.body); - }, - onError: (err) => { - console.error("Push notification error:", err); - }, - androidChannel: { - id: "general", - name: "General", - description: "General notifications", - importance: 4, // Importance.High - sound: "default", - }, - }); - - serviceRef.current = service; - await service.initPushNotificationService(); - }, []); + useEffect(() => { + if (networkStatus.connected && currentFcmToken) { + dispatch(registerDevice(currentFcmToken, registeredFcmToken)); + } + }, [ + currentFcmToken, + registeredFcmToken, + networkStatus.connected, + isActive, + dispatch, + ]); + + const retryRegistration = useCallback(async () => { + if (!registrationError) return; + if (registrationAttempts >= MAX_REGISTRATION_ATTEMPTS) { + // Caller is deliberately retrying, e.g. in settings and context drawer menu + dispatch(resetRegistrationAttempts()); + } + dispatch(setRegistrationError(false)); + try { + const { token } = await FirebaseMessaging.getToken(); + if (token) dispatch(registerDevice(token, registeredFcmToken)); + } catch (e) { + console.error("Failed to get token for retry:", e); + dispatch(setRegistrationError(true)); + } + }, [registrationError, registrationAttempts, registeredFcmToken, dispatch]); useEffect(() => { return () => { - serviceRef.current?.unregister(); + if (initialized.current) { + void FirebaseMessaging.removeAllListeners(); + } + handles.current.forEach((h) => void h.remove()); + handles.current = []; + initialized.current = false; }; }, []); - return { initPushNotifications, token }; + return { initPushNotifications, retryRegistration }; } diff --git a/mobile/asa-go/src/rootReducer.ts b/mobile/asa-go/src/rootReducer.ts index 1ebd13b6f3..187dee98df 100644 --- a/mobile/asa-go/src/rootReducer.ts +++ b/mobile/asa-go/src/rootReducer.ts @@ -3,6 +3,7 @@ import dataSlice from "@/slices/dataSlice"; import fireCentresSlice from "@/slices/fireCentresSlice"; import geolocationSlice from "@/slices/geolocationSlice"; import networkStatusSlice from "@/slices/networkStatusSlice"; +import pushNotificationSlice from "@/slices/pushNotificationSlice"; import runParametersSlice from "@/slices/runParametersSlice"; import { combineReducers } from "@reduxjs/toolkit"; import settingsSlice from "@/slices/settingsSlice"; @@ -15,4 +16,5 @@ export const rootReducer = combineReducers({ authentication: authenticateSlice, data: dataSlice, settings: settingsSlice, + pushNotification: pushNotificationSlice, }); diff --git a/mobile/asa-go/src/services/pushNotificationService.test.ts b/mobile/asa-go/src/services/pushNotificationService.test.ts deleted file mode 100644 index af83163af7..0000000000 --- a/mobile/asa-go/src/services/pushNotificationService.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { PushNotificationService } from "./pushNotificationService"; -import { - FirebaseMessaging, - PermissionStatus, - Importance, -} from "@capacitor-firebase/messaging"; -import { Capacitor } from "@capacitor/core"; - -// Mock the FirebaseMessaging plugin -vi.mock("@capacitor-firebase/messaging", () => ({ - FirebaseMessaging: { - checkPermissions: vi.fn(), - requestPermissions: vi.fn(), - createChannel: vi.fn(), - getToken: vi.fn(), - addListener: vi.fn(), - removeAllListeners: vi.fn(), - }, - Importance: { - High: 4, - }, -})); - -// Mock Capacitor.getPlatform() -vi.mock(import("@capacitor/core"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Capacitor: { - ...actual.Capacitor, - getPlatform: vi.fn().mockReturnValue("android"), - }, - }; -}); - -describe("PushNotificationService", () => { - // Reset all mocks before each test - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("initPushNotificationService", () => { - it("should not call createChannel when platform is iOS", async () => { - const mockToken = "test-fcm-token"; - - // Override Capacitor.getPlatform to return 'ios' - vi.mocked(Capacitor.getPlatform).mockReturnValue("ios"); - - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "granted", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ - token: mockToken, - }); - - vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ - remove: vi.fn(), - }); - - const service = new PushNotificationService(); - await service.initPushNotificationService(); - - expect(FirebaseMessaging.createChannel).not.toHaveBeenCalled(); - }); - - it("should initialize push notifications successfully when permissions are granted", async () => { - const mockToken = "test-fcm-token"; - const mockOnRegister = vi.fn(); - - // Ensure platform is back to Android for this test - vi.mocked(Capacitor.getPlatform).mockReturnValue("android"); - - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "granted", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ - token: mockToken, - }); - - vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ - remove: vi.fn(), - }); - - const service = new PushNotificationService({ - onRegister: mockOnRegister, - }); - - // Act - await service.initPushNotificationService(); - - // Assert - expect(FirebaseMessaging.checkPermissions).toHaveBeenCalledTimes(1); - expect(FirebaseMessaging.requestPermissions).not.toHaveBeenCalled(); - expect(FirebaseMessaging.createChannel).toHaveBeenCalledTimes(1); - expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); - expect(FirebaseMessaging.addListener).toHaveBeenCalledTimes(3); - expect(mockOnRegister).toHaveBeenCalledWith(mockToken); - }); - - it("should request permissions when not granted initially", async () => { - const mockToken = "test-fcm-token"; - - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "denied", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ - receive: "granted", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ - token: mockToken, - }); - - vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ - remove: vi.fn(), - }); - - const service = new PushNotificationService(); - - await service.initPushNotificationService(); - - expect(FirebaseMessaging.checkPermissions).toHaveBeenCalledTimes(1); - expect(FirebaseMessaging.requestPermissions).toHaveBeenCalledTimes(1); - expect(FirebaseMessaging.createChannel).toHaveBeenCalledTimes(1); - expect(FirebaseMessaging.getToken).toHaveBeenCalledTimes(1); - }); - - it("should call onError handler when permission request is denied", async () => { - const mockOnError = vi.fn(); - - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "denied", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.requestPermissions).mockResolvedValue({ - receive: "denied", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ - remove: vi.fn(), - }); - - const service = new PushNotificationService({ - onError: mockOnError, - }); - - await service.initPushNotificationService(); - - expect(mockOnError).toHaveBeenCalledOnce(); - }); - - it("should use custom Android channel when provided", async () => { - const mockToken = "test-fcm-token"; - const customChannel = { - id: "custom-channel", - name: "Custom Channel", - description: "Custom notifications", - importance: Importance.High, - sound: "custom-sound", - }; - - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "granted", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ - token: mockToken, - }); - - vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ - remove: vi.fn(), - }); - - const service = new PushNotificationService({ - androidChannel: customChannel, - }); - - await service.initPushNotificationService(); - - expect(FirebaseMessaging.createChannel).toHaveBeenCalledWith( - customChannel, - ); - }); - }); - - describe("unregister", () => { - it("should unregister all listeners", async () => { - const mockRemoveAllListeners = vi.fn(); - const mockRemoveListener1 = vi.fn(); - const mockRemoveListener2 = vi.fn(); - const mockRemoveListener3 = vi.fn(); - - vi.mocked(FirebaseMessaging.removeAllListeners).mockImplementation( - mockRemoveAllListeners, - ); - - vi.mocked(FirebaseMessaging.addListener) - .mockResolvedValueOnce({ remove: mockRemoveListener1 }) - .mockResolvedValueOnce({ remove: mockRemoveListener2 }) - .mockResolvedValueOnce({ remove: mockRemoveListener3 }); - - const service = new PushNotificationService(); - - // First, initialize to add listeners - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "granted", - } as PermissionStatus); - - vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ - token: "test-token", - }); - - await service.initPushNotificationService(); - - await service.unregister(); - - expect(FirebaseMessaging.removeAllListeners).toHaveBeenCalledTimes(1); - expect(mockRemoveListener1).toHaveBeenCalledTimes(1); - expect(mockRemoveListener2).toHaveBeenCalledTimes(1); - expect(mockRemoveListener3).toHaveBeenCalledTimes(1); - }); - - it("should handle errors when removing listeners", async () => { - const mockRemoveListener = vi - .fn() - .mockRejectedValue(new Error("Remove failed")); - - vi.mocked(FirebaseMessaging.removeAllListeners).mockRejectedValue( - new Error("Remove all failed"), - ); - - vi.mocked(FirebaseMessaging.addListener).mockResolvedValue({ - remove: mockRemoveListener, - }); - - const service = new PushNotificationService(); - - vi.mocked(FirebaseMessaging.checkPermissions).mockResolvedValue({ - receive: "granted", - } as PermissionStatus); - vi.mocked(FirebaseMessaging.getToken).mockResolvedValue({ - token: "test-token", - }); - await service.initPushNotificationService(); - - await expect(service.unregister()).resolves.not.toThrow(); - }); - }); -}); diff --git a/mobile/asa-go/src/services/pushNotificationService.ts b/mobile/asa-go/src/services/pushNotificationService.ts deleted file mode 100644 index e589dc9a91..0000000000 --- a/mobile/asa-go/src/services/pushNotificationService.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - Channel, - FirebaseMessaging, - Importance, - NotificationActionPerformedEvent, - NotificationReceivedEvent, - PermissionStatus, - TokenReceivedEvent, -} from "@capacitor-firebase/messaging"; -import { Capacitor, PluginListenerHandle } from "@capacitor/core"; - -export type PushInitOptions = { - onRegister?: (token: string) => void; - onNotificationReceived?: (evt: NotificationReceivedEvent) => void; - onNotificationAction?: (evt: NotificationActionPerformedEvent) => void; - onError?: (err: unknown) => void; - androidChannel?: Channel; -}; - -export class PushNotificationService { - private handles: PluginListenerHandle[] = []; - private isInitialized = false; - - constructor(private readonly opts: PushInitOptions = {}) {} - - async initPushNotificationService(): Promise { - if (this.isInitialized) { - return; - } - try { - // Permissions (Android 13+ & iOS) - const check: PermissionStatus = - await FirebaseMessaging.checkPermissions(); - if (check.receive !== "granted") { - const req = await FirebaseMessaging.requestPermissions(); - if (req.receive !== "granted") - throw new Error("Push permission not granted"); - } - - // Android channel (recommended on 8+) - if (Capacitor.getPlatform() === "android") { - await FirebaseMessaging.createChannel( - this.opts.androidChannel ?? { - id: "general", - name: "General", - description: "General notifications", - importance: Importance.High, - sound: "default", - }, - ); - } - - // FCM token (works on iOS & Android) - const { token } = await FirebaseMessaging.getToken(); - this.opts.onRegister?.(token); - - // Strongly-typed listeners - const tokenReceivedHandler = await FirebaseMessaging.addListener( - "tokenReceived", - (e: TokenReceivedEvent) => { - this.opts.onRegister?.(e.token); - }, - ); - - const notificationReceivedHandler = await FirebaseMessaging.addListener( - "notificationReceived", - (evt: NotificationReceivedEvent) => { - this.opts.onNotificationReceived?.(evt); - }, - ); - - const onNotificationAction = await FirebaseMessaging.addListener( - "notificationActionPerformed", - (evt: NotificationActionPerformedEvent) => { - this.opts.onNotificationAction?.(evt); - }, - ); - - this.handles.push( - tokenReceivedHandler, - notificationReceivedHandler, - onNotificationAction, - ); - this.isInitialized = true; - } catch (e) { - console.error(e); - this.opts.onError?.(e); - } - } - - async unregister(): Promise { - try { - await FirebaseMessaging.removeAllListeners(); - } catch (e) { - console.error(e); - /* noop */ - } finally { - await Promise.all( - this.handles.map(async (h) => { - try { - await h.remove(); - } catch (e) { - console.error(e); - /* noop */ - } - }), - ); - this.handles = []; - this.isInitialized = false; - } - } -} diff --git a/mobile/asa-go/src/slices/networkStatusSlice.test.ts b/mobile/asa-go/src/slices/networkStatusSlice.test.ts new file mode 100644 index 0000000000..ee27b2bcba --- /dev/null +++ b/mobile/asa-go/src/slices/networkStatusSlice.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import reducer, { updateNetworkStatus } from "./networkStatusSlice"; + +describe("networkStatusSlice", () => { + describe("updateNetworkStatus", () => { + it.each([ + { connectionType: "wifi", connected: true }, + { connectionType: "cellular", connected: true }, + { connectionType: "none", connected: false }, + { connectionType: "unknown", connected: false }, + ] as const)( + "sets connected=$connected when connectionType=$connectionType", + ({ connectionType, connected }) => { + const state = reducer( + undefined, + updateNetworkStatus({ connected: true, connectionType }), + ); + expect(state.networkStatus.connected).toBe(connected); + expect(state.networkStatus.connectionType).toBe(connectionType); + }, + ); + }); +}); diff --git a/mobile/asa-go/src/slices/networkStatusSlice.ts b/mobile/asa-go/src/slices/networkStatusSlice.ts index a3fe41dd90..89c7b94b03 100644 --- a/mobile/asa-go/src/slices/networkStatusSlice.ts +++ b/mobile/asa-go/src/slices/networkStatusSlice.ts @@ -1,26 +1,32 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { ConnectionStatus } from "@capacitor/network" - +import { ConnectionStatus } from "@capacitor/network"; export interface NetworkStatusState { - networkStatus: ConnectionStatus + networkStatus: ConnectionStatus; } const initialState: NetworkStatusState = { - networkStatus: { connected: false, connectionType: 'none'}, + networkStatus: { connected: false, connectionType: "none" }, }; const networkStatusSlice = createSlice({ name: "networkStatus", initialState, reducers: { - updateNetworkStatus(state: NetworkStatusState, action: PayloadAction) { - state.networkStatus = action.payload + updateNetworkStatus( + state: NetworkStatusState, + action: PayloadAction, + ) { + state.networkStatus = { + connected: + action.payload.connectionType !== "none" && + action.payload.connectionType !== "unknown", + connectionType: action.payload.connectionType, + }; }, }, }); -export const { updateNetworkStatus } = -networkStatusSlice.actions; +export const { updateNetworkStatus } = networkStatusSlice.actions; export default networkStatusSlice.reducer; diff --git a/mobile/asa-go/src/slices/pushNotificationSlice.test.ts b/mobile/asa-go/src/slices/pushNotificationSlice.test.ts new file mode 100644 index 0000000000..802fd5352c --- /dev/null +++ b/mobile/asa-go/src/slices/pushNotificationSlice.test.ts @@ -0,0 +1,359 @@ +import { createTestStore } from "@/testUtils"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import pushNotificationReducer, { + checkPushNotificationPermission, + incrementRegistrationAttempts, + initialState, + MAX_REGISTRATION_ATTEMPTS, + PushNotificationState, + registerDevice, + resetRegistrationAttempts, + setDeviceIdError, + setPushNotificationPermission, + setRegisteredFcmToken, +} from "./pushNotificationSlice"; + +vi.mock("@capacitor-firebase/messaging", () => ({ + FirebaseMessaging: { + checkPermissions: vi.fn().mockResolvedValue({ receive: "granted" }), + getToken: vi.fn().mockResolvedValue({ token: "fcm-token" }), + }, +})); + +vi.mock("@capacitor/device", () => ({ + Device: { getId: vi.fn().mockResolvedValue({ identifier: "device-id" }) }, +})); + +vi.mock("@/utils/retryWithBackoff", () => ({ + retryWithBackoff: vi.fn((op: () => Promise) => op()), +})); + +vi.mock("@capacitor/core", () => ({ + Capacitor: { getPlatform: vi.fn().mockReturnValue("ios") }, +})); + +vi.mock("api/pushNotificationsAPI", () => ({ + registerToken: vi.fn(), +})); + +describe("pushNotificationSlice", () => { + const makeState = ( + overrides: Partial = {}, + ): PushNotificationState => ({ + ...initialState, + ...overrides, + }); + + describe("reducers", () => { + it("returns the initial state", () => { + expect(pushNotificationReducer(undefined, { type: "unknown" })).toEqual( + initialState, + ); + }); + + it("initial state has pushNotificationPermission unknown", () => { + expect(initialState.pushNotificationPermission).toBe("unknown"); + }); + + it("initial state has registeredFcmToken null", () => { + expect(initialState.registeredFcmToken).toBeNull(); + }); + + it("initial state has deviceIdError false", () => { + expect(initialState.deviceIdError).toBe(false); + }); + + it("handles setPushNotificationPermission", () => { + const next = pushNotificationReducer( + makeState(), + setPushNotificationPermission("granted"), + ); + expect(next.pushNotificationPermission).toBe("granted"); + }); + + it("handles setRegisteredFcmToken to a value", () => { + const next = pushNotificationReducer( + makeState(), + setRegisteredFcmToken("my-token"), + ); + expect(next.registeredFcmToken).toBe("my-token"); + }); + + it("handles setRegisteredFcmToken to null", () => { + const next = pushNotificationReducer( + makeState({ registeredFcmToken: "my-token" }), + setRegisteredFcmToken(null), + ); + expect(next.registeredFcmToken).toBeNull(); + }); + + it("handles setDeviceIdError to true", () => { + const next = pushNotificationReducer(makeState(), setDeviceIdError(true)); + expect(next.deviceIdError).toBe(true); + }); + + it("handles setDeviceIdError to false", () => { + const next = pushNotificationReducer( + makeState({ deviceIdError: true }), + setDeviceIdError(false), + ); + expect(next.deviceIdError).toBe(false); + }); + + it("handles incrementRegistrationAttempts", () => { + const next = pushNotificationReducer( + makeState(), + incrementRegistrationAttempts(), + ); + expect(next.registrationAttempts).toBe(1); + }); + + it("handles resetRegistrationAttempts", () => { + const next = pushNotificationReducer( + makeState({ registrationAttempts: 3 }), + resetRegistrationAttempts(), + ); + expect(next.registrationAttempts).toBe(0); + }); + }); + + describe("thunks", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("checkPushNotificationPermission", () => { + it("dispatches granted when Firebase returns granted", async () => { + const { FirebaseMessaging } = await import( + "@capacitor-firebase/messaging" + ); + (FirebaseMessaging.checkPermissions as Mock).mockResolvedValue({ + receive: "granted", + }); + + const store = createTestStore(); + await store.dispatch(checkPushNotificationPermission()); + + expect( + store.getState().pushNotification.pushNotificationPermission, + ).toBe("granted"); + }); + + it("dispatches unknown when Firebase throws", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const { FirebaseMessaging } = await import( + "@capacitor-firebase/messaging" + ); + (FirebaseMessaging.checkPermissions as Mock).mockRejectedValue( + new Error("permission error"), + ); + + const store = createTestStore(); + await store.dispatch(checkPushNotificationPermission()); + + expect( + store.getState().pushNotification.pushNotificationPermission, + ).toBe("unknown"); + consoleSpy.mockRestore(); + }); + }); + + describe("registerDevice", () => { + it("registers and sets registeredFcmToken when not yet registered", async () => { + const { Device } = await import("@capacitor/device"); + const { Capacitor } = await import("@capacitor/core"); + const { registerToken } = await import("api/pushNotificationsAPI"); + (Device.getId as Mock).mockResolvedValue({ identifier: "device-id" }); + (Capacitor.getPlatform as Mock).mockReturnValue("ios"); + (registerToken as Mock).mockResolvedValue(undefined); + + const store = createTestStore({ + authentication: { + error: null, + isAuthenticated: true, + idir: "test-user", + authenticating: false, + tokenRefreshed: false, + token: undefined, + idToken: undefined, + }, + }); + + await store.dispatch(registerDevice("fcm-token", null)); + + expect(registerToken).toHaveBeenCalledWith( + "ios", + "fcm-token", + "device-id", + "test-user", + ); + expect(store.getState().pushNotification.registeredFcmToken).toBe( + "fcm-token", + ); + }); + + it("is a no-op when already registered with the same token", async () => { + const { registerToken } = await import("api/pushNotificationsAPI"); + + const store = createTestStore(); + + await store.dispatch( + registerDevice("existing-token", "existing-token"), + ); + + expect(registerToken).not.toHaveBeenCalled(); + }); + + it("re-registers when token has rotated", async () => { + const { Device } = await import("@capacitor/device"); + const { Capacitor } = await import("@capacitor/core"); + const { registerToken } = await import("api/pushNotificationsAPI"); + (Device.getId as Mock).mockResolvedValue({ identifier: "device-id" }); + (Capacitor.getPlatform as Mock).mockReturnValue("ios"); + (registerToken as Mock).mockResolvedValue(undefined); + + const store = createTestStore(); + + await store.dispatch(registerDevice("new-token", "old-token")); + + expect(registerToken).toHaveBeenCalledWith( + "ios", + "new-token", + "device-id", + null, + ); + expect(store.getState().pushNotification.registeredFcmToken).toBe( + "new-token", + ); + }); + + it("does not set registeredFcmToken when registration fails", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const { Device } = await import("@capacitor/device"); + const { Capacitor } = await import("@capacitor/core"); + const { registerToken } = await import("api/pushNotificationsAPI"); + (Device.getId as Mock).mockResolvedValue({ identifier: "device-id" }); + (Capacitor.getPlatform as Mock).mockReturnValue("ios"); + (registerToken as Mock).mockRejectedValue(new Error("backend error")); + + const store = createTestStore(); + + await store.dispatch(registerDevice("fcm-token", null)); + + expect(store.getState().pushNotification.registeredFcmToken).toBeNull(); + consoleSpy.mockRestore(); + }); + + it("uses retryWithBackoff to register and sets token on success", async () => { + const { Device } = await import("@capacitor/device"); + const { Capacitor } = await import("@capacitor/core"); + const { registerToken } = await import("api/pushNotificationsAPI"); + const { retryWithBackoff } = await import("@/utils/retryWithBackoff"); + (Device.getId as Mock).mockResolvedValue({ identifier: "device-id" }); + (Capacitor.getPlatform as Mock).mockReturnValue("ios"); + (registerToken as Mock).mockResolvedValue(undefined); + + const store = createTestStore({ + authentication: { + error: null, + isAuthenticated: true, + idir: "test-user", + authenticating: false, + tokenRefreshed: false, + token: undefined, + idToken: undefined, + }, + }); + + await store.dispatch(registerDevice("fcm-token", null)); + + expect(retryWithBackoff).toHaveBeenCalledTimes(1); + expect(store.getState().pushNotification.registeredFcmToken).toBe( + "fcm-token", + ); + }); + + it("does not set token when retryWithBackoff exhausts retries", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const { Device } = await import("@capacitor/device"); + const { Capacitor } = await import("@capacitor/core"); + const { registerToken } = await import("api/pushNotificationsAPI"); + const { retryWithBackoff } = await import("@/utils/retryWithBackoff"); + (Device.getId as Mock).mockResolvedValue({ identifier: "device-id" }); + (Capacitor.getPlatform as Mock).mockReturnValue("ios"); + (registerToken as Mock).mockRejectedValue( + new Error("persistent error"), + ); + (retryWithBackoff as Mock).mockRejectedValue( + new Error("persistent error"), + ); + + const store = createTestStore(); + await store.dispatch(registerDevice("fcm-token", null)); + + expect(store.getState().pushNotification.registeredFcmToken).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("increments registrationAttempts on each failure to MAX_REGISTRATION_ATTEMPTS", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const { Device } = await import("@capacitor/device"); + const { Capacitor } = await import("@capacitor/core"); + const { retryWithBackoff } = await import("@/utils/retryWithBackoff"); + (Device.getId as Mock).mockResolvedValue({ identifier: "device-id" }); + (Capacitor.getPlatform as Mock).mockReturnValue("ios"); + (retryWithBackoff as Mock).mockRejectedValue( + new Error("persistent error"), + ); + + const store = createTestStore(); + for (let i = 0; i < MAX_REGISTRATION_ATTEMPTS; i++) { + await store.dispatch(registerDevice("fcm-token", null)); + } + + expect(store.getState().pushNotification.registrationAttempts).toBe( + MAX_REGISTRATION_ATTEMPTS, + ); + expect(store.getState().pushNotification.registrationError).toBe(true); + consoleSpy.mockRestore(); + }); + + it("resets registrationAttempts on successful registration", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const { Device } = await import("@capacitor/device"); + const { Capacitor } = await import("@capacitor/core"); + const { registerToken } = await import("api/pushNotificationsAPI"); + const { retryWithBackoff } = await import("@/utils/retryWithBackoff"); + (Device.getId as Mock).mockResolvedValue({ identifier: "device-id" }); + (Capacitor.getPlatform as Mock).mockReturnValue("ios"); + + // Fail up to max, then succeed + (retryWithBackoff as Mock) + .mockRejectedValueOnce(new Error("error")) + .mockRejectedValueOnce(new Error("error")) + .mockResolvedValueOnce(undefined); + (registerToken as Mock).mockResolvedValue(undefined); + + const store = createTestStore(); + await store.dispatch(registerDevice("fcm-token", null)); + await store.dispatch(registerDevice("fcm-token", null)); + expect(store.getState().pushNotification.registrationAttempts).toBe(2); + + await store.dispatch(registerDevice("fcm-token", "different-token")); + expect(store.getState().pushNotification.registrationAttempts).toBe(0); + consoleSpy.mockRestore(); + }); + }); + }); +}); diff --git a/mobile/asa-go/src/slices/pushNotificationSlice.ts b/mobile/asa-go/src/slices/pushNotificationSlice.ts new file mode 100644 index 0000000000..90dee2a65e --- /dev/null +++ b/mobile/asa-go/src/slices/pushNotificationSlice.ts @@ -0,0 +1,109 @@ +import { AppThunk } from "@/store"; +import { retryWithBackoff } from "@/utils/retryWithBackoff"; +import { FirebaseMessaging } from "@capacitor-firebase/messaging"; +import { Capacitor, PermissionState } from "@capacitor/core"; +import { Device } from "@capacitor/device"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { Platform, registerToken } from "api/pushNotificationsAPI"; + +export const MAX_REGISTRATION_ATTEMPTS = 5; + +export interface PushNotificationState { + pushNotificationPermission: PermissionState | "unknown"; + registeredFcmToken: string | null; + deviceIdError: boolean; + registrationError: boolean; + registrationAttempts: number; +} + +export const initialState: PushNotificationState = { + pushNotificationPermission: "unknown", + registeredFcmToken: null, + deviceIdError: false, + registrationError: false, + registrationAttempts: 0, +}; + +const pushNotificationSlice = createSlice({ + name: "pushNotification", + initialState, + reducers: { + setPushNotificationPermission( + state: PushNotificationState, + action: PayloadAction, + ) { + state.pushNotificationPermission = action.payload; + }, + setRegisteredFcmToken( + state: PushNotificationState, + action: PayloadAction, + ) { + state.registeredFcmToken = action.payload; + }, + setDeviceIdError( + state: PushNotificationState, + action: PayloadAction, + ) { + state.deviceIdError = action.payload; + }, + setRegistrationError( + state: PushNotificationState, + action: PayloadAction, + ) { + state.registrationError = action.payload; + }, + incrementRegistrationAttempts(state: PushNotificationState) { + state.registrationAttempts += 1; + }, + resetRegistrationAttempts(state: PushNotificationState) { + state.registrationAttempts = 0; + }, + }, +}); + +export const { + setDeviceIdError, + setRegistrationError, + setPushNotificationPermission, + setRegisteredFcmToken, + incrementRegistrationAttempts, + resetRegistrationAttempts, +} = pushNotificationSlice.actions; + +export default pushNotificationSlice.reducer; + +export const checkPushNotificationPermission = + (): AppThunk => async (dispatch) => { + try { + const permissions = await FirebaseMessaging.checkPermissions(); + dispatch(setPushNotificationPermission(permissions.receive ?? "unknown")); + } catch (e) { + console.error(e); + dispatch(setPushNotificationPermission("unknown")); + } + }; + +export const registerDevice = + (token: string, registeredFcmToken: string | null): AppThunk => + async (dispatch, getState) => { + if (token === registeredFcmToken) return; + try { + const { idir } = getState().authentication; + const { identifier } = await Device.getId(); + await retryWithBackoff(() => + registerToken( + Capacitor.getPlatform() as Platform, + token, + identifier, + idir || null, + ), + ); + dispatch(setRegistrationError(false)); + dispatch(resetRegistrationAttempts()); + dispatch(setRegisteredFcmToken(token)); + } catch (e) { + console.error("Failed to register device:", e); + dispatch(incrementRegistrationAttempts()); + dispatch(setRegistrationError(true)); + } + }; diff --git a/mobile/asa-go/src/slices/settingsSlice.test.ts b/mobile/asa-go/src/slices/settingsSlice.test.ts index 38ac2b8ad2..1420dfd25c 100644 --- a/mobile/asa-go/src/slices/settingsSlice.test.ts +++ b/mobile/asa-go/src/slices/settingsSlice.test.ts @@ -5,6 +5,7 @@ import { Preferences } from "@capacitor/preferences"; import { act } from "@testing-library/react"; import { DateTime } from "luxon"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { getNotificationSettings } from "api/pushNotificationsAPI"; import settingsSlice, { fetchFireCentreInfo, getFireCentreInfoFailed, @@ -14,11 +15,11 @@ import settingsSlice, { initPinnedFireCentre, initSubscriptions, savePinnedFireCentre, - saveSubscriptions, setPinnedFireCentre, setSubscriptions, SettingsState, } from "./settingsSlice"; +import { getUpdatedSubscriptions } from "@/utils/subscriptionUtils"; // Mock the @capacitor/preferences module vi.mock("@capacitor/preferences", () => ({ @@ -42,6 +43,14 @@ vi.mock("@/api/fbaAPI", () => ({ getFireCentreInfo: vi.fn(), })); +vi.mock("api/pushNotificationsAPI", () => ({ + getNotificationSettings: vi.fn(), +})); + +vi.mock("@/utils/retryWithBackoff", () => ({ + retryWithBackoff: vi.fn((op: () => Promise) => op()), +})); + describe("settingsSlice", () => { // Test data factories const createSettingsState = ( @@ -191,6 +200,34 @@ describe("settingsSlice", () => { subscriptions: [], }); }); + + it("setSubscriptions marks subscriptionsInitialized as true", () => { + const previousState = createSettingsState({ + subscriptionsInitialized: false, + }); + + const nextState = settingsSlice(previousState, setSubscriptions([1, 2])); + + expect(nextState.subscriptionsInitialized).toBe(true); + }); + }); + + describe("getUpdatedSubscriptions", () => { + it("adds a zone ID that is not yet subscribed", () => { + expect(getUpdatedSubscriptions([1, 2], 3)).toEqual([1, 2, 3]); + }); + + it("removes a zone ID that is already subscribed", () => { + expect(getUpdatedSubscriptions([1, 2, 3], 2)).toEqual([1, 3]); + }); + + it("adds to an empty subscription list", () => { + expect(getUpdatedSubscriptions([], 5)).toEqual([5]); + }); + + it("removes the only subscription", () => { + expect(getUpdatedSubscriptions([5], 5)).toEqual([]); + }); }); describe("thunks", () => { @@ -226,82 +263,6 @@ describe("settingsSlice", () => { }); }); - describe("initSubscriptions", () => { - it("should dispatch setSubscriptions when stored value exists", async () => { - const store = createTestStore(); - (Preferences.get as Mock).mockResolvedValue({ - value: JSON.stringify([1, 2, 3]), - }); - - await store.dispatch(initSubscriptions()); - - expectSettingsState(store.getState().settings, { - subscriptions: [1, 2, 3], - }); - }); - - it("should not dispatch setSubscriptions when no stored value", async () => { - const store = createTestStore(); - (Preferences.get as Mock).mockResolvedValue({ value: null }); - - await store.dispatch(initSubscriptions()); - - expectSettingsState(store.getState().settings, { - subscriptions: [], - }); - }); - - it("should handle invalid JSON gracefully", async () => { - const store = createTestStore(); - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - (Preferences.get as Mock).mockResolvedValue({ - value: "invalid-json", - }); - - await store.dispatch(initSubscriptions()); - - expectSettingsState(store.getState().settings, { - subscriptions: [], - }); - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); - }); - }); - - describe("saveSubscriptions", () => { - it("should save subscriptions to preferences and update state", async () => { - const store = createTestStore(); - (Preferences.set as Mock).mockResolvedValue(undefined); - - await store.dispatch(saveSubscriptions([1, 2, 3])); - - expect(Preferences.set).toHaveBeenCalledWith({ - key: "asaGoSubscriptions", - value: "[1,2,3]", - }); - expectSettingsState(store.getState().settings, { - subscriptions: [1, 2, 3], - }); - }); - - it("should save empty subscriptions array", async () => { - const store = createTestStore(); - (Preferences.set as Mock).mockResolvedValue(undefined); - - await store.dispatch(saveSubscriptions([])); - - expect(Preferences.set).toHaveBeenCalledWith({ - key: "asaGoSubscriptions", - value: "[]", - }); - expectSettingsState(store.getState().settings, { - subscriptions: [], - }); - }); - }); - describe("savePinnedFireCentre", () => { it("should save fire centre to preferences and update state", async () => { const store = createTestStore(); @@ -332,6 +293,31 @@ describe("settingsSlice", () => { }); }); }); + describe("initSubscriptions", () => { + it("dispatches setSubscriptions with parsed numbers on success", async () => { + (getNotificationSettings as Mock).mockResolvedValue(["1", "2", "3"]); + const store = createTestStore(); + + await store.dispatch(initSubscriptions("test-device-id")); + + expect(store.getState().settings.subscriptions).toEqual([1, 2, 3]); + expect(store.getState().settings.subscriptionsInitialized).toBe(true); + }); + + it("does not update state when fetch fails", async () => { + (getNotificationSettings as Mock).mockRejectedValue( + new Error("network error"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + const store = createTestStore(); + + await store.dispatch(initSubscriptions("test-device-id")); + + expect(store.getState().settings.subscriptions).toEqual([]); + expect(store.getState().settings.subscriptionsInitialized).toBe(false); + }); + }); + describe("fetchFireCentreInfo", () => { beforeEach(() => { // Reset all mocks before each test @@ -456,6 +442,25 @@ describe("settingsSlice", () => { expect(state.loading).toBe(false); expect(state.fireCentreInfos).toEqual([mockFireCentreInfoA]); }); + + it("should dispatch error when API call fails", async () => { + mockCacheWithNoData(); + (getFireCentreInfo as Mock).mockRejectedValue( + new Error("server error"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + const store = createTestStore({ + settings: { ...initialState }, + networkStatus: { + networkStatus: { connected: true, connectionType: "wifi" }, + }, + }); + await store.dispatch(fetchFireCentreInfo()); + const state = store.getState().settings; + expect(state.loading).toBe(false); + expect(state.error).toMatch(/Error: server error/); + expect(state.fireCentreInfos).toEqual([]); + }); }); }); }); diff --git a/mobile/asa-go/src/slices/settingsSlice.ts b/mobile/asa-go/src/slices/settingsSlice.ts index 5e630a452a..48b68458f6 100644 --- a/mobile/asa-go/src/slices/settingsSlice.ts +++ b/mobile/asa-go/src/slices/settingsSlice.ts @@ -6,12 +6,12 @@ import { readFromFilesystem, writeToFileSystem, } from "@/utils/storage"; -import { FirebaseMessaging } from "@capacitor-firebase/messaging"; -import { PermissionState } from "@capacitor/core"; +import { retryWithBackoff } from "@/utils/retryWithBackoff"; import { Filesystem } from "@capacitor/filesystem"; import { Preferences } from "@capacitor/preferences"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { FireCentreInfo, getFireCentreInfo } from "api/fbaAPI"; +import { getNotificationSettings } from "api/pushNotificationsAPI"; import { isNil, isNull } from "lodash"; import { DateTime } from "luxon"; @@ -20,8 +20,8 @@ export interface SettingsState { error: string | null; fireCentreInfos: FireCentreInfo[]; pinnedFireCentre: string | null; - pushNotificationPermission: PermissionState | "unknown"; subscriptions: number[]; + subscriptionsInitialized: boolean; } export const initialState: SettingsState = { @@ -29,12 +29,11 @@ export const initialState: SettingsState = { error: null, fireCentreInfos: [], pinnedFireCentre: null, - pushNotificationPermission: "unknown", subscriptions: [], + subscriptionsInitialized: false, }; const PINNED_FIRE_CENTRE_KEY = "asaGoPinnedFireCentre"; -const SUBSCRIPTIONS_KEY = "asaGoSubscriptions"; const settingsSlice = createSlice({ name: "settings", @@ -66,14 +65,9 @@ const settingsSlice = createSlice({ ) { state.pinnedFireCentre = action.payload; }, - setPushNotificationPermission( - state: SettingsState, - action: PayloadAction, - ) { - state.pushNotificationPermission = action.payload; - }, setSubscriptions(state: SettingsState, action: PayloadAction) { state.subscriptions = action.payload; + state.subscriptionsInitialized = true; }, }, }); @@ -83,7 +77,6 @@ export const { getFireCentreInfoFailed, getFireCentreInfoSuccess, setPinnedFireCentre, - setPushNotificationPermission, setSubscriptions, } = settingsSlice.actions; @@ -99,52 +92,17 @@ export const initPinnedFireCentre = (): AppThunk => async (dispatch) => { } }; -export const initSubscriptions = (): AppThunk => async (dispatch) => { - const result = await Preferences.get({ - key: SUBSCRIPTIONS_KEY, - }); - try { - if (result.value) { - const subs = JSON.parse(result.value); - if (subs && Array.isArray(subs)) { - dispatch(setSubscriptions(subs)); - } - } - } catch (e) { - console.error( - `An error occurred when populating notification subscriptions: ${e}`, - ); - } -}; - -export const saveSubscriptions = - (subs: number[]): AppThunk => +export const initSubscriptions = + (deviceId: string): AppThunk => async (dispatch) => { - await Preferences.set({ - key: SUBSCRIPTIONS_KEY, - value: JSON.stringify(subs), - }); - dispatch(setSubscriptions(subs)); - }; - -export const getUpdatedSubscriptions = ( - subscriptions: number[], - fireZoneUnitId: number, -) => { - if (subscriptions.includes(fireZoneUnitId)) { - return subscriptions.filter((sub) => sub !== fireZoneUnitId); - } - - return [...subscriptions, fireZoneUnitId]; -}; - -export const toggleSubscription = - (fireZoneUnitId: number): AppThunk => - async (dispatch, getState) => { - const { subscriptions } = getState().settings; - dispatch( - saveSubscriptions(getUpdatedSubscriptions(subscriptions, fireZoneUnitId)), - ); + try { + const ids = await retryWithBackoff(() => + getNotificationSettings(deviceId), + ); + dispatch(setSubscriptions(ids.map(Number))); + } catch (e) { + console.error(`Failed to fetch notification settings: ${e}`); + } }; // Update @capacitor/preferences and redux state with pinned fire centre @@ -211,14 +169,3 @@ export const fetchFireCentreInfo = ); } }; - -export const checkPushNotificationPermission = - (): AppThunk => async (dispatch) => { - try { - const permissions = await FirebaseMessaging.checkPermissions(); - dispatch(setPushNotificationPermission(permissions.receive ?? "unknown")); - } catch (e) { - console.error(e); - dispatch(setPushNotificationPermission("unknown")); - } - }; diff --git a/mobile/asa-go/src/store.test.ts b/mobile/asa-go/src/store.test.ts new file mode 100644 index 0000000000..7fec1da3af --- /dev/null +++ b/mobile/asa-go/src/store.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + selectNotificationSetupState, + selectNotificationSettingsDisabled, +} from "@/store"; +import type { RootState } from "@/store"; + +const base: { + pushNotificationPermission: "granted" | "denied"; + registeredFcmToken: string | null; + deviceIdError: boolean; + registrationError: boolean; +} = { + pushNotificationPermission: "granted" as const, + registeredFcmToken: "some-token", + deviceIdError: false, + registrationError: false, +}; + +const makeState = ( + overrides: Partial, + connected = true, +): RootState => + ({ + settings: { + loading: false, + error: null, + fireCentreInfos: [], + pinnedFireCentre: null, + subscriptions: [], + subscriptionsInitialized: true, + }, + pushNotification: { + ...base, + ...overrides, + }, + networkStatus: { + networkStatus: { connected, connectionType: connected ? "wifi" : "none" }, + }, + } as unknown as RootState); + +describe("selectNotificationSetupState", () => { + it("returns permissionDenied when permission is denied", () => { + expect( + selectNotificationSetupState( + makeState({ pushNotificationPermission: "denied" }), + ), + ).toBe("permissionDenied"); + }); + + it("returns permissionDenied when permission is unknown", () => { + expect( + selectNotificationSetupState( + makeState({ pushNotificationPermission: "unknown" as never }), + ), + ).toBe("permissionDenied"); + }); + + it("returns unregistered when registeredFcmToken is null", () => { + expect( + selectNotificationSetupState(makeState({ registeredFcmToken: null })), + ).toBe("unregistered"); + }); + + it("returns registrationFailed when token is null and registrationError is true", () => { + expect( + selectNotificationSetupState( + makeState({ registeredFcmToken: null, registrationError: true }), + ), + ).toBe("registrationFailed"); + }); + + it("returns ready when permission granted and registeredFcmToken is set", () => { + expect(selectNotificationSetupState(makeState({}))).toBe("ready"); + }); +}); + +describe("selectNotificationSettingsDisabled", () => { + it("returns false when ready and online", () => { + expect(selectNotificationSettingsDisabled(makeState({}))).toBe(false); + }); + + it("returns true when permission denied", () => { + expect( + selectNotificationSettingsDisabled( + makeState({ pushNotificationPermission: "denied" }), + ), + ).toBe(true); + }); + + it("returns true when unregistered", () => { + expect( + selectNotificationSettingsDisabled( + makeState({ registeredFcmToken: undefined }), + ), + ).toBe(true); + }); + + it("returns true when offline", () => { + expect(selectNotificationSettingsDisabled(makeState({}, false))).toBe(true); + }); + + it("returns true when subscriptions are not yet initialized", () => { + const state = { ...makeState({}), settings: { ...makeState({}).settings, subscriptionsInitialized: false } }; + expect(selectNotificationSettingsDisabled(state as RootState)).toBe(true); + }); +}); + diff --git a/mobile/asa-go/src/store.ts b/mobile/asa-go/src/store.ts index eaccba7a0b..0f65572f3a 100644 --- a/mobile/asa-go/src/store.ts +++ b/mobile/asa-go/src/store.ts @@ -1,5 +1,10 @@ import { rootReducer } from "@/rootReducer"; -import { Action, configureStore, ThunkAction } from "@reduxjs/toolkit"; +import { + Action, + configureStore, + createSelector, + ThunkAction, +} from "@reduxjs/toolkit"; export const store = configureStore({ reducer: rootReducer, @@ -24,3 +29,41 @@ export const selectProvincialSummaries = (state: RootState) => export const selectTPIStats = (state: RootState) => state.data.tpiStats; export const selectHFIStats = (state: RootState) => state.data.hfiStats; export const selectSettings = (state: RootState) => state.settings; +export const selectPushNotification = (state: RootState) => state.pushNotification; + +export type NotificationSetupState = + | "permissionDenied" + | "unregistered" + | "registrationFailed" + | "ready"; + +export const selectNotificationSetupState = createSelector( + selectPushNotification, + ({ + pushNotificationPermission, + registeredFcmToken, + registrationError, + }): NotificationSetupState => { + if (pushNotificationPermission !== "granted") { + return "permissionDenied"; + } + if (!registeredFcmToken) { + return registrationError ? "registrationFailed" : "unregistered"; + } + return "ready"; + }, +); + +export const selectRegistrationFailed = createSelector( + selectNotificationSetupState, + (setupState) => setupState === "registrationFailed", +); + +export const selectNotificationSettingsDisabled = createSelector( + selectNotificationSetupState, + selectNetworkStatus, + selectSettings, + (setupState, { networkStatus }, { subscriptionsInitialized }) => + setupState !== "ready" || !networkStatus.connected || !subscriptionsInitialized, +); + diff --git a/mobile/asa-go/src/utils/constants.ts b/mobile/asa-go/src/utils/constants.ts index a8b6bd7d07..674dd5564c 100644 --- a/mobile/asa-go/src/utils/constants.ts +++ b/mobile/asa-go/src/utils/constants.ts @@ -49,3 +49,6 @@ export enum NavPanel { PROFILE = "Profile", SETTINGS = "Settings", } + +export const subscriptionUpdateErrorMessage = + "Failed to update notification settings. Please try again later."; diff --git a/mobile/asa-go/src/utils/retryWithBackoff.test.ts b/mobile/asa-go/src/utils/retryWithBackoff.test.ts new file mode 100644 index 0000000000..68168b97c3 --- /dev/null +++ b/mobile/asa-go/src/utils/retryWithBackoff.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { retryWithBackoff } from "./retryWithBackoff"; + +describe("retryWithBackoff", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves immediately when the operation succeeds on the first attempt", async () => { + const op = vi.fn().mockResolvedValue("result"); + const result = await retryWithBackoff(op); + expect(result).toBe("result"); + expect(op).toHaveBeenCalledTimes(1); + }); + + it("retries and resolves when the operation succeeds on a later attempt", async () => { + const op = vi + .fn() + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValueOnce("result"); + const promise = retryWithBackoff(op); + await vi.advanceTimersByTimeAsync(1000); + expect(await promise).toBe("result"); + expect(op).toHaveBeenCalledTimes(2); + }); + + it("throws after exhausting all retries", async () => { + const op = vi.fn().mockRejectedValue(new Error("persistent")); + const caught = retryWithBackoff(op, { maxRetries: 3 }).catch( + (e) => e as Error, + ); + await vi.advanceTimersByTimeAsync(7000); // 1000 + 2000 + 4000 + // @ts-expect-error - promise rejection type is intentionally coerced for test + expect((await caught).message).toBe("persistent"); + expect(op).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + }); + + it("does not retry before the delay elapses", async () => { + const op = vi.fn().mockRejectedValue(new Error("fail")); + const promise = retryWithBackoff(op, { + maxRetries: 3, + baseDelayMs: 1000, + }).catch(() => {}); + await vi.advanceTimersByTimeAsync(999); + expect(op).toHaveBeenCalledTimes(1); // still on first attempt, waiting for delay + await vi.advanceTimersByTimeAsync(7000); + await promise; + }); + + it("uses exponential backoff delays between retries", async () => { + const op = vi.fn().mockRejectedValue(new Error("fail")); + const promise = retryWithBackoff(op, { + maxRetries: 3, + baseDelayMs: 1000, + }).catch(() => {}); + + // After 1st failure, waits 1000ms before retry 1 + await vi.advanceTimersByTimeAsync(999); + expect(op).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); // 1000ms elapsed → retry 1 + expect(op).toHaveBeenCalledTimes(2); + + // After 2nd failure, waits 2000ms before retry 2 + await vi.advanceTimersByTimeAsync(1999); + expect(op).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); // 2000ms elapsed → retry 2 + expect(op).toHaveBeenCalledTimes(3); + + // After 3rd failure, waits 4000ms before retry 3 + await vi.advanceTimersByTimeAsync(3999); + expect(op).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1); // 4000ms elapsed → retry 3 + expect(op).toHaveBeenCalledTimes(4); + + await promise; + }); + + it("uses custom baseDelayMs", async () => { + const op = vi + .fn() + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValueOnce("ok"); + const promise = retryWithBackoff(op, { baseDelayMs: 500 }); + await vi.advanceTimersByTimeAsync(499); + expect(op).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await promise).toBe("ok"); + expect(op).toHaveBeenCalledTimes(2); + }); +}); diff --git a/mobile/asa-go/src/utils/retryWithBackoff.ts b/mobile/asa-go/src/utils/retryWithBackoff.ts new file mode 100644 index 0000000000..9773fcc712 --- /dev/null +++ b/mobile/asa-go/src/utils/retryWithBackoff.ts @@ -0,0 +1,25 @@ +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +interface RetryOptions { + maxRetries?: number; + baseDelayMs?: number; +} + +export async function retryWithBackoff( + op: () => Promise, + { maxRetries = 3, baseDelayMs = 250 }: RetryOptions = {}, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + await sleep(baseDelayMs * Math.pow(2, attempt - 1)); + } + try { + return await op(); + } catch (e) { + lastError = e; + } + } + throw lastError; +} diff --git a/mobile/asa-go/src/utils/subscriptionUtils.ts b/mobile/asa-go/src/utils/subscriptionUtils.ts new file mode 100644 index 0000000000..9ad13e43c8 --- /dev/null +++ b/mobile/asa-go/src/utils/subscriptionUtils.ts @@ -0,0 +1,10 @@ +export const getUpdatedSubscriptions = ( + subscriptions: number[], + fireZoneUnitId: number, +): number[] => { + if (subscriptions.includes(fireZoneUnitId)) { + return subscriptions.filter((sub) => sub !== fireZoneUnitId); + } + + return [...subscriptions, fireZoneUnitId]; +}; diff --git a/openshift/templates/deploy.yaml b/openshift/templates/deploy.yaml index 90a0b452b7..caafa37bd5 100644 --- a/openshift/templates/deploy.yaml +++ b/openshift/templates/deploy.yaml @@ -419,6 +419,11 @@ objects: configMapKeyRef: name: ${GLOBAL_NAME} key: env.fuel_raster_name + - name: FCM_CREDS + valueFrom: + secretKeyRef: + name: ${GLOBAL_NAME} + key: fcm-creds - name: WX_OBJECT_STORE_USER_ID valueFrom: secretKeyRef: diff --git a/openshift/templates/nats.yaml b/openshift/templates/nats.yaml index d35f45ca8c..a2a2c78327 100644 --- a/openshift/templates/nats.yaml +++ b/openshift/templates/nats.yaml @@ -433,6 +433,11 @@ objects: configMapKeyRef: name: ${GLOBAL_NAME} key: env.fuel_raster_name + - name: FCM_CREDS + valueFrom: + secretKeyRef: + name: ${GLOBAL_NAME} + key: fcm-creds ports: - containerPort: 4222 name: client