"""Tests for notification preferences endpoints."""
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from preloop.models.crud import crud_registration_token
from preloop.models.models.api_key import ApiKey
from preloop.models.models.user import User
def test_get_qr_code_success(client: TestClient, test_user: User, db_session: Session):
"""Test generating QR code for mobile device registration."""
response = client.get("/api/v1/notification-preferences/me/qr-code")
assert response.status_code == 200
data = response.json()
# Verify response structure
assert "token" in data
assert "qr_data" in data
assert "expires_at" in data
assert "expires_in_seconds" in data
# Verify QR data contains the registration URL
assert "/register-device?token=" in data["qr_data"]
assert data["token"] in data["qr_data"]
# Verify token was stored in database
token_obj = crud_registration_token.get_by_token(db_session, token=data["token"])
assert token_obj is not None
assert token_obj.user_id == test_user.id
assert not token_obj.is_consumed
def test_register_via_token_success(
client: TestClient, test_user: User, db_session: Session
):
"""Test successful mobile device registration via QR token (happy path).
This test verifies:
1. Token is validated from database
2. Device is registered for push notifications
3. API key is created for mobile app authentication
4. Token is consumed and cannot be reused
"""
# First, generate a QR code token
qr_response = client.get("/api/v1/notification-preferences/me/qr-code")
assert qr_response.status_code == 200
qr_data = qr_response.json()
token = qr_data["token"]
# Mock WebSocket broadcast to avoid connection issues in tests
with patch(
"preloop.services.websocket_manager.manager.broadcast_json",
new_callable=AsyncMock,
):
# Register device via token
response = client.post(
f"/api/v1/notification-preferences/register-via-token?token={token}",
json={"platform": "ios", "token": "test-device-token-123"},
)
assert response.status_code == 200
data = response.json()
# Verify response structure
assert "preferences" in data
assert "api_key" in data
assert "api_key_id" in data
assert "api_key_expires_at" in data
# Verify device token was added to preferences
prefs = data["preferences"]
assert prefs["mobile_device_tokens"] is not None
assert len(prefs["mobile_device_tokens"]) > 0
device_token_entry = prefs["mobile_device_tokens"][0]
assert device_token_entry["platform"] == "ios"
assert device_token_entry["token"] == "test-device-token-123"
# Verify API key was created
assert data["api_key"] is not None
assert len(data["api_key"]) == 40 # API keys are 40 characters
created_api_key = db_session.get(ApiKey, data["api_key_id"])
assert created_api_key is not None
assert created_api_key.account_id == test_user.account_id
# Verify token was consumed in database
token_obj = crud_registration_token.get_by_token(db_session, token=token)
assert token_obj.is_consumed
assert token_obj.used_at is not None
def test_register_via_token_persistence_across_requests(
client: TestClient, test_user: User, db_session: Session
):
"""Regression test: Verify tokens persist in DB and work across separate requests.
This test specifically addresses the bug where tokens were stored in an
in-memory dict, causing failures when requests were served by different
workers/pods or after restarts.
The test simulates two separate operations:
1. Generate token (like QR code generation request to pod A)
2. Register device (like registration request to pod B)
If tokens were still in-memory, this would fail. By persisting in the
database, the token is available to any pod/worker.
"""
# Step 1: Generate token (simulating request to pod A)
qr_response = client.get("/api/v1/notification-preferences/me/qr-code")
assert qr_response.status_code == 200
token = qr_response.json()["token"]
# Verify token exists in database (not just in-memory)
token_obj = crud_registration_token.get_by_token(db_session, token=token)
assert token_obj is not None, "Token should be persisted in database"
assert token_obj.user_id == test_user.id
assert not token_obj.is_consumed
# Step 2: Register device (simulating request to pod B)
# In a real scenario, this could be served by a different pod/worker
# that doesn't have the in-memory dict from step 1
with patch(
"preloop.services.websocket_manager.manager.broadcast_json",
new_callable=AsyncMock,
):
response = client.post(
f"/api/v1/notification-preferences/register-via-token?token={token}",
json={"platform": "android", "token": "android-token-456"},
)
# Should succeed because token is in database
assert response.status_code == 200, "Token should be validated from database"
# Verify token was consumed
db_session.expire_all() # Clear session cache to force DB read
token_obj = crud_registration_token.get_by_token(db_session, token=token)
assert token_obj.is_consumed, "Token should be marked as consumed in database"
def test_register_via_token_invalid_token(
client: TestClient, test_user: User, db_session: Session
):
"""Test registration with invalid token."""
response = client.post(
"/api/v1/notification-preferences/register-via-token?token=invalid-token",
json={"platform": "ios", "token": "test-device-token"},
)
assert response.status_code == 400
assert "Invalid or expired" in response.json()["detail"]
def test_register_via_token_expired_token(
client: TestClient, test_user: User, db_session: Session
):
"""Test registration with expired token."""
# Create expired token directly in database
from datetime import datetime, timezone, timedelta
token_obj = crud_registration_token.create_token(
db_session,
user_id=test_user.id,
expiry_minutes=-5, # Already expired
)
# Manually set expires_at to past
token_obj.expires_at = datetime.now(timezone.utc) - timedelta(minutes=5)
db_session.add(token_obj)
db_session.commit()
response = client.post(
f"/api/v1/notification-preferences/register-via-token?token={token_obj.token}",
json={"platform": "ios", "token": "test-device-token"},
)
assert response.status_code == 400
assert "Invalid or expired" in response.json()["detail"]
def test_register_via_token_already_consumed(
client: TestClient, test_user: User, db_session: Session
):
"""Test registration with already consumed token (should fail)."""
# Generate token
qr_response = client.get("/api/v1/notification-preferences/me/qr-code")
token = qr_response.json()["token"]
# Register device first time (consumes token)
with patch(
"preloop.services.websocket_manager.manager.broadcast_json",
new_callable=AsyncMock,
):
response1 = client.post(
f"/api/v1/notification-preferences/register-via-token?token={token}",
json={"platform": "ios", "token": "test-device-token-1"},
)
assert response1.status_code == 200
# Try to register again with same token (should fail)
with patch(
"preloop.services.websocket_manager.manager.broadcast_json",
new_callable=AsyncMock,
):
response2 = client.post(
f"/api/v1/notification-preferences/register-via-token?token={token}",
json={"platform": "android", "token": "test-device-token-2"},
)
assert response2.status_code == 400
assert "Invalid or expired" in response2.json()["detail"]
def test_register_via_token_invalid_platform(
client: TestClient, test_user: User, db_session: Session
):
"""Test registration with invalid platform.
Note: Platform validation currently happens at the add_device_token level,
which accepts any platform string. The validation only exists in the
direct register-device endpoint, not register-via-token.
This test documents the current behavior - platforms other than ios/android
are accepted but may not receive notifications.
"""
qr_response = client.get("/api/v1/notification-preferences/me/qr-code")
token = qr_response.json()["token"]
with patch(
"preloop.services.websocket_manager.manager.broadcast_json",
new_callable=AsyncMock,
):
response = client.post(
f"/api/v1/notification-preferences/register-via-token?token={token}",
json={"platform": "windows", "token": "test-device-token"},
)
# Currently accepts any platform (validation needed in future)
assert response.status_code == 200
def test_check_token_validity_valid(
client: TestClient, test_user: User, db_session: Session
):
"""Test token validity check with valid token."""
# Generate token
qr_response = client.get("/api/v1/notification-preferences/me/qr-code")
token = qr_response.json()["token"]
# Check landing page (uses check_token_validity internally)
response = client.get(
f"/api/v1/notification-preferences/register-device?token={token}"
)
# Should show the deep link page (not the expired page)
assert response.status_code == 200
assert "Opening Preloop.AI" in response.text
assert "Registration Link Expired" not in response.text
def test_check_token_validity_expired(
client: TestClient, test_user: User, db_session: Session
):
"""Test token validity check with expired token."""
# Create expired token
token_obj = crud_registration_token.create_token(
db_session, user_id=test_user.id, expiry_minutes=1
)
token_obj.expires_at = datetime.now(timezone.utc) - timedelta(minutes=5)
db_session.add(token_obj)
db_session.commit()
# Check landing page
response = client.get(
f"/api/v1/notification-preferences/register-device?token={token_obj.token}"
)
# Should show expired page
assert response.status_code == 200
assert "Registration Link Expired" in response.text
assert "Opening Preloop.AI" not in response.text
def test_get_my_notification_preferences(
client: TestClient, test_user: User, db_session: Session
):
"""Test getting current user's notification preferences."""
response = client.get("/api/v1/notification-preferences/me")
assert response.status_code == 200
data = response.json()
# Verify default values
assert data["user_id"] == str(test_user.id)
assert "preferred_channel" in data
assert "enable_email" in data
assert "enable_mobile_push" in data
def test_update_my_notification_preferences(
client: TestClient, test_user: User, db_session: Session
):
"""Test updating current user's notification preferences."""
response = client.put(
"/api/v1/notification-preferences/me",
json={
"preferred_channel": "mobile_push",
"enable_email": True,
"enable_mobile_push": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["preferred_channel"] == "mobile_push"
assert data["enable_email"] is True
assert data["enable_mobile_push"] is True
def test_register_mobile_device_direct(
client: TestClient, test_user: User, db_session: Session
):
"""Test direct device registration (without QR token)."""
response = client.post(
"/api/v1/notification-preferences/me/register-device",
json={"platform": "ios", "token": "direct-device-token-789"},
)
assert response.status_code == 200
data = response.json()
# Verify device was added
assert len(data["mobile_device_tokens"]) > 0
device = data["mobile_device_tokens"][0]
assert device["platform"] == "ios"
assert device["token"] == "direct-device-token-789"
def test_unregister_mobile_device(
client: TestClient, test_user: User, db_session: Session
):
"""Test unregistering a mobile device."""
# First register a device
register_response = client.post(
"/api/v1/notification-preferences/me/register-device",
json={"platform": "ios", "token": "device-to-remove"},
)
assert register_response.status_code == 200
# Unregister the device
response = client.delete(
"/api/v1/notification-preferences/me/device/device-to-remove"
)
assert response.status_code == 200
data = response.json()
# Verify device was removed
assert len(data["mobile_device_tokens"]) == 0
def test_unregister_nonexistent_device(
client: TestClient, test_user: User, db_session: Session
):
"""Test unregistering a device that doesn't exist."""
response = client.delete(
"/api/v1/notification-preferences/me/device/nonexistent-token"
)
# Should return 404 when trying to remove device that doesn't exist
assert response.status_code == 404
assert "not found" in response.json()["detail"]