-
Notifications
You must be signed in to change notification settings - Fork 10
Push notification token management #5178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 29 commits
9ac98ba
d9471c5
7edd3ef
189df46
14990e6
8fecac0
5fb287e
6f91c6f
833ec10
31da761
bcae514
8997d37
24709f4
3a3033b
949f1ac
1c67610
b0222b3
2459551
086862e
669b1c9
bcae983
9508c8e
16d0f99
f484ac6
f668fe0
e51eb3e
de523dd
709be15
2664f47
2fc8548
aec4720
fcb8a26
9b30978
d9ff808
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| """Push notification tokens | ||
|
|
||
| Revision ID: 7d2194c5051e | ||
| Revises: 0b46effaf3a1 | ||
| Create Date: 2026-03-02 10:48:11.523814 | ||
|
|
||
| """ | ||
| import sqlalchemy as sa | ||
| from alembic import op | ||
| from sqlalchemy.dialects import postgresql | ||
| from wps_shared.db.models.common import TZTimeStamp | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = '7d2194c5051e' | ||
| down_revision = '0b46effaf3a1' | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade(): | ||
| op.create_table( | ||
| "device_token", | ||
| sa.Column("id", sa.Integer(), nullable=False), | ||
| sa.Column("device_id", sa.String(), nullable=False), | ||
| sa.Column("user_id", sa.String(), nullable=True), | ||
| sa.Column( | ||
| "platform", postgresql.ENUM("android", "ios", name="platformenum"), nullable=False | ||
| ), | ||
| sa.Column("token", sa.String(), nullable=False), | ||
| sa.Column("is_active", sa.Boolean(), nullable=False), | ||
| sa.Column("created_at", TZTimeStamp(), nullable=False), | ||
| sa.Column("updated_at", TZTimeStamp(), nullable=False), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| comment="Device token management.", | ||
| ) | ||
| op.create_index(op.f('ix_device_token_id'), 'device_token', ['id'], unique=False) | ||
| op.create_index(op.f("ix_device_token_device_id"), "device_token", ["device_id"], unique=False) | ||
| op.create_index(op.f('ix_device_token_platform'), 'device_token', ['platform'], unique=False) | ||
| op.create_index(op.f('ix_device_token_token'), 'device_token', ['token'], unique=True) | ||
|
|
||
|
|
||
| def downgrade(): | ||
| op.drop_index(op.f('ix_device_token_token'), table_name='device_token') | ||
| op.drop_index(op.f('ix_device_token_platform'), table_name='device_token') | ||
| op.drop_index(op.f('ix_device_token_id'), table_name='device_token') | ||
| op.drop_table('device_token') | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| from typing import Optional | ||
|
|
||
| from pydantic import BaseModel, Field | ||
|
|
||
|
|
||
| class RegisterDeviceRequest(BaseModel): | ||
| user_id: Optional[str] = None | ||
| device_id: str | ||
| token: str = Field(..., min_length=10) | ||
| platform: str = Field(..., pattern="^(ios|android)$") | ||
|
|
||
| class UnregisterDeviceRequest(BaseModel): | ||
| token: str = Field(..., min_length=10) | ||
|
|
||
| class DeviceRequestResponse(BaseModel): | ||
| success: bool | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import logging | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException | ||
| from wps_shared.auth import asa_authentication_required, audit_asa | ||
| from wps_shared.db.crud.fcm import ( | ||
| get_device_by_device_id, | ||
| save_device_token, | ||
| update_device_token_is_active, | ||
| ) | ||
| from wps_shared.db.database import get_async_write_session_scope | ||
| from wps_shared.db.models.fcm import DeviceToken | ||
| from wps_shared.utils.time import get_utc_now | ||
|
|
||
| from app.fcm.schema import DeviceRequestResponse, RegisterDeviceRequest, UnregisterDeviceRequest | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| router = APIRouter( | ||
| prefix="/device", | ||
| dependencies=[Depends(asa_authentication_required), Depends(audit_asa)], | ||
| ) | ||
|
|
||
|
|
||
| @router.post("/register") | ||
conbrad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| async def register_device(request: RegisterDeviceRequest): | ||
conbrad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
| Upsert a device token for a device_id. | ||
| """ | ||
| logger.info("/device/register") | ||
| async with get_async_write_session_scope() as session: | ||
| existing = await get_device_by_device_id(session, request.device_id) | ||
| if existing: | ||
| existing.is_active = True | ||
| existing.token = request.token | ||
| existing.updated_at = get_utc_now() | ||
conbrad marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+32
to
+35
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just thinking about work phones, and on the assumption that the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After doing a bit of reading I don't think we need to do this. If I understand Apple's page properly there would be a new device id if the phone was wiped and given to someone else |
||
| logger.info(f"Updated existing DeviceInfo record for token: {request.token}") | ||
| else: | ||
| device_token = DeviceToken( | ||
| user_id=request.user_id, | ||
| device_id=request.device_id, | ||
| token=request.token, | ||
| platform=request.platform, | ||
| is_active=True, | ||
| ) | ||
| save_device_token(session, device_token) | ||
| logger.info("Successfully created new DeviceToken record.") | ||
| return DeviceRequestResponse(success=True) | ||
|
|
||
|
|
||
| @router.post("/unregister") | ||
| async def unregister_device(request: UnregisterDeviceRequest): | ||
| """ | ||
| Mark a token inactive (e.g., user logged out or uninstalled). | ||
| """ | ||
| logger.info("/device/unregister") | ||
| async with get_async_write_session_scope() as session: | ||
| success = await update_device_token_is_active(session, request.token, False) | ||
| if not success: | ||
| logger.error(f"Could not find a record matching the provided token: {request.token}") | ||
| raise HTTPException(status_code=404, detail=f"Token not found: {request.token}") | ||
|
Check failure on line 60 in backend/packages/wps-api/src/app/routers/fcm.py
|
||
| return DeviceRequestResponse(success=True) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| """Unit tests for FCM endpoints.""" | ||
|
|
||
| from datetime import datetime | ||
| from unittest.mock import patch | ||
|
|
||
| import app.main | ||
| import pytest | ||
| from app.fcm.schema import RegisterDeviceRequest | ||
| from fastapi.testclient import TestClient | ||
| from wps_shared.db.models.fcm import PlatformEnum | ||
|
|
||
| DB_SESSION = "app.routers.fcm.get_async_write_session_scope" | ||
| GET_DEVICE_TOKEN = "app.routers.fcm.get_device_by_device_id" | ||
| SAVE_DEVICE_TOKEN = "app.routers.fcm.save_device_token" | ||
| API_DEVICE_REGISTER = "/api/device/register" | ||
| API_DEVICE_UNREGISTER = "/api/device/unregister" | ||
| MOCK_DEVICE_TOKEN = "abcdefghijklmonp" | ||
| TEST_REGISTER_DEVICE_REQUEST = { | ||
| "user_id": "test_idir", | ||
| "device_id": "test_device_id", | ||
| "token": MOCK_DEVICE_TOKEN, | ||
| "platform": PlatformEnum.android.value, | ||
| } | ||
| TEST_UNREGISTER_DEVICE_REQUEST = {"token": MOCK_DEVICE_TOKEN} | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def client(): | ||
| from app.main import app as test_app | ||
|
|
||
| with TestClient(test_app) as test_client: | ||
| yield test_client | ||
|
|
||
|
|
||
| @pytest.mark.usefixtures("mock_client_session") | ||
| @pytest.mark.parametrize( | ||
| "endpoint, payload", | ||
| [ | ||
| (API_DEVICE_REGISTER, TEST_REGISTER_DEVICE_REQUEST), | ||
| (API_DEVICE_UNREGISTER, TEST_UNREGISTER_DEVICE_REQUEST), | ||
| ], | ||
| ) | ||
| def test_get_endpoints_unauthorized(endpoint: str, payload, client: TestClient): | ||
| """Forbidden to get fire zone areas when unauthorized""" | ||
|
|
||
| response = client.post(endpoint, json=payload) | ||
| assert response.status_code == 401 | ||
|
|
||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_register_device_success(): | ||
| """Test that device registration returns 200/OK.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| # Test data | ||
| request_data = { | ||
| "user_id": "test-user-123", | ||
| "device_id": "test_device_id", | ||
| "token": "test-fcm-token-456", | ||
|
||
| "platform": "android", | ||
| } | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
| with ( | ||
| patch(GET_DEVICE_TOKEN, return_value=None), | ||
| patch(SAVE_DEVICE_TOKEN), | ||
| ): | ||
| response = client.post(API_DEVICE_REGISTER, json=request_data) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json()["success"] == True | ||
| assert response.headers["content-type"] == "application/json" | ||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_register_device_already_exists(): | ||
| """Test that existing device registration updates successfully.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| request_data = { | ||
| "user_id": "test-user-123", | ||
| "device_id": "test_device_id", | ||
| "token": "existing-fcm-token", | ||
|
Check failure on line 83 in backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py
|
||
|
||
| "platform": "ios", | ||
| } | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
conbrad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| mock_session_scope.return_value.__aenter__.return_value | ||
|
|
||
| existing_device = type( | ||
| "", | ||
| (object,), | ||
| { | ||
| "is_active": False, | ||
| "device_id": "test_device_id", | ||
| "token": "existing-fcm-token", | ||
|
Check failure on line 96 in backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py
|
||
|
||
| "updated_at": datetime(2026, 1, 1), | ||
| }, | ||
| )() | ||
|
|
||
| with ( | ||
| patch(GET_DEVICE_TOKEN, return_value=existing_device), | ||
| patch(SAVE_DEVICE_TOKEN) as mock_save, | ||
| ): | ||
| response = client.post(API_DEVICE_REGISTER, json=request_data) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json()["success"] == True | ||
| assert existing_device.is_active == True # Should be updated | ||
conbrad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| mock_save.assert_not_called() # Should not call save for existing device | ||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_register_device_missing_fields(): | ||
| """Test that missing fields in registration request returns 422.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| # Missing 'token' field which is required | ||
| request_data = {"user_id": "test-user-123", "platform": "android"} | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
|
|
||
| response = client.post(API_DEVICE_REGISTER, json=request_data) | ||
|
|
||
| assert response.status_code == 422 | ||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_register_device_invalid_platform(): | ||
| """Test that invalid platform returns 422.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| request_data = { | ||
| "user_id": "test-user-123", | ||
| "token": "test-fcm-token", | ||
| "platform": "invalid-platform", | ||
| } | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
|
|
||
| response = client.post(API_DEVICE_REGISTER, json=request_data) | ||
|
|
||
| assert response.status_code == 422 | ||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_register_device_short_token(): | ||
| """Test that short token returns 422.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| request_data = { | ||
| "user_id": "test-user-123", | ||
| "token": "short", # Less than 10 characters | ||
| "platform": "android", | ||
| "device_id": "test_device_id", | ||
| } | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
|
|
||
| response = client.post(API_DEVICE_REGISTER, json=request_data) | ||
|
|
||
| assert response.status_code == 422 | ||
|
|
||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_missing_device_id_returns_422(): | ||
| """Test that a missing device_id returns 422.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| request_data = { | ||
| "user_id": "test-user-123", | ||
| "token": "short", # Less than 10 characters | ||
| "platform": "android", | ||
| } | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
|
|
||
| response = client.post(API_DEVICE_REGISTER, json=request_data) | ||
|
|
||
| assert response.status_code == 422 | ||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_unregister_device_success(): | ||
| """Test that device unregistration returns 200/OK.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| request_data = {"token": "test-fcm-token-456"} | ||
|
|
||
| with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
| with patch("app.routers.fcm.update_device_token_is_active"): | ||
| response = client.post("/api/device/unregister", json=request_data) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json()["success"] == True | ||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_unregister_device_missing_token(): | ||
| """Test that missing token field returns 422.""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| request_data = {} | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
|
|
||
| response = client.post("/api/device/unregister", json=request_data) | ||
|
|
||
| assert response.status_code == 422 | ||
|
|
||
| @pytest.mark.usefixtures("mock_jwt_decode") | ||
| def test_register_device_without_user_id(): | ||
| """Test that device registration without user_id is allowed (null user).""" | ||
| client = TestClient(app.main.app) | ||
|
|
||
| request_data = { | ||
| "token": "test-fcm-token-789", | ||
|
||
| "platform": "android", | ||
| "device_id": "test_device_id", | ||
| } | ||
|
|
||
| with patch(DB_SESSION) as mock_session_scope: | ||
| mock_session_scope.return_value.__aenter__.return_value | ||
| with ( | ||
| patch(GET_DEVICE_TOKEN, return_value=None), | ||
| patch(SAVE_DEVICE_TOKEN), | ||
| ): | ||
| response = client.post(API_DEVICE_REGISTER, json=request_data) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json()["success"] == True | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: downgrade also should drop the
platformenumI think