Merge pull request #501 from christianlouis/copilot/add-push-notifications-for-signup
feat(notifications): admin push notifications and webhooks for user signup, plan changes, and payment issues
This commit is contained in:
@@ -69,6 +69,12 @@ class UserProfileUpsert(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class PaymentIssueBody(BaseModel):
|
||||
"""Body for reporting a payment issue for a user."""
|
||||
|
||||
issue: str = Field(..., min_length=1, max_length=2048, description="Description of the payment issue")
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
"""Response schema for a user profile record."""
|
||||
|
||||
@@ -399,6 +405,7 @@ def upsert_user_profile(
|
||||
profile = UserProfile(user_id=user_id)
|
||||
db.add(profile)
|
||||
|
||||
old_tier = (profile.subscription_tier or "free") if profile.id else None # None means brand-new profile
|
||||
profile.display_name = body.display_name
|
||||
profile.daily_upload_limit = body.daily_upload_limit
|
||||
profile.notes = body.notes
|
||||
@@ -407,6 +414,8 @@ def upsert_user_profile(
|
||||
profile.subscription_period_start = body.subscription_period_start
|
||||
profile.allow_overage = body.allow_overage
|
||||
profile.is_complimentary = body.is_complimentary
|
||||
tier_changed = False
|
||||
new_tier: str | None = None
|
||||
if body.subscription_tier is not None:
|
||||
from app.utils.subscription import TIERS
|
||||
|
||||
@@ -415,6 +424,10 @@ def upsert_user_profile(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
|
||||
)
|
||||
# Detect a real change only for existing profiles (old_tier is not None)
|
||||
if old_tier is not None and old_tier != body.subscription_tier:
|
||||
tier_changed = True
|
||||
new_tier = body.subscription_tier
|
||||
profile.subscription_tier = body.subscription_tier
|
||||
|
||||
try:
|
||||
@@ -425,9 +438,66 @@ def upsert_user_profile(
|
||||
raise
|
||||
|
||||
logger.info("Admin upserted profile for user %s", user_id)
|
||||
|
||||
# Notify admins and fire webhook when plan is changed by an admin
|
||||
if tier_changed and new_tier is not None:
|
||||
try:
|
||||
from app.utils.notification import notify_plan_changed
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
notify_plan_changed(user_id, old_tier=old_tier, new_tier=new_tier, changed_by="admin") # type: ignore[arg-type]
|
||||
dispatch_webhook_event(
|
||||
"user.plan_changed",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"old_tier": old_tier,
|
||||
"new_tier": new_tier,
|
||||
"billing_cycle": body.subscription_billing_cycle,
|
||||
"changed_by": "admin",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send plan-change notification/webhook for user %s", user_id)
|
||||
|
||||
return _profile_to_dict(profile)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id:path}/payment-issue", status_code=status.HTTP_200_OK, summary="Report a payment issue for a user"
|
||||
)
|
||||
def report_payment_issue(user_id: str, body: PaymentIssueBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Notify admins and fire a webhook for a payment issue reported against *user_id*.
|
||||
|
||||
The user profile must exist. Use this endpoint when a payment processor
|
||||
webhook or manual review identifies a billing problem (e.g. failed charge,
|
||||
expired card, disputed transaction).
|
||||
|
||||
Returns the user profile dict alongside an acknowledgement flag.
|
||||
"""
|
||||
profile = _get_or_none(db, user_id)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User profile not found")
|
||||
|
||||
logger.warning("Payment issue reported for user %s: %s", user_id, body.issue)
|
||||
|
||||
try:
|
||||
from app.utils.notification import notify_payment_issue
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
notify_payment_issue(user_id, issue=body.issue)
|
||||
dispatch_webhook_event(
|
||||
"user.payment_issue",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"issue": body.issue,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send payment-issue notification/webhook for user %s", user_id)
|
||||
|
||||
return {"acknowledged": True, "user_id": user_id, "profile": _profile_to_dict(profile)}
|
||||
|
||||
|
||||
@router.delete("/{user_id:path}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a user profile")
|
||||
def delete_user_profile(user_id: str, db: DbSession, _admin: AdminUser) -> None:
|
||||
"""Delete the admin-managed profile for *user_id*.
|
||||
|
||||
@@ -175,6 +175,7 @@ def save_plan(request: Request, body: PlanBody, db: DbSession) -> dict[str, Any]
|
||||
)
|
||||
|
||||
profile = _get_or_create_profile(db, user_id)
|
||||
old_tier = profile.subscription_tier or "free"
|
||||
profile.subscription_tier = body.subscription_tier
|
||||
profile.subscription_billing_cycle = body.billing_cycle
|
||||
|
||||
@@ -186,6 +187,27 @@ def save_plan(request: Request, body: PlanBody, db: DbSession) -> dict[str, Any]
|
||||
raise
|
||||
|
||||
logger.info("Onboarding: saved plan %s/%s", body.subscription_tier, body.billing_cycle)
|
||||
|
||||
# Notify admins and fire webhook when the plan actually changes
|
||||
if old_tier != body.subscription_tier:
|
||||
try:
|
||||
from app.utils.notification import notify_plan_changed
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
notify_plan_changed(user_id, old_tier=old_tier, new_tier=body.subscription_tier, changed_by="user")
|
||||
dispatch_webhook_event(
|
||||
"user.plan_changed",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"old_tier": old_tier,
|
||||
"new_tier": body.subscription_tier,
|
||||
"billing_cycle": body.billing_cycle,
|
||||
"changed_by": "user",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send plan-change notification/webhook for user %s", user_id)
|
||||
|
||||
return _profile_to_dict(profile)
|
||||
|
||||
|
||||
|
||||
+18
@@ -168,6 +168,7 @@ def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -
|
||||
existing = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
||||
if existing is None:
|
||||
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
|
||||
email = user_data.get("email")
|
||||
profile = UserProfile(
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
@@ -183,6 +184,23 @@ def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -
|
||||
is_admin,
|
||||
highest_tier if is_admin else "free",
|
||||
)
|
||||
# Notify admins and fire webhook for new (non-admin) user signup
|
||||
if not is_admin:
|
||||
try:
|
||||
from app.utils.notification import notify_user_signup
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
notify_user_signup(user_id, display_name=display_name, email=email)
|
||||
dispatch_webhook_event(
|
||||
"user.signup",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
|
||||
elif is_admin:
|
||||
# Ensure existing admin profiles always have complimentary flag set.
|
||||
# Also upgrade from free tier to highest if still on default.
|
||||
|
||||
@@ -404,6 +404,18 @@ class Settings(BaseSettings):
|
||||
default=True,
|
||||
description="Send notifications when files are successfully processed",
|
||||
)
|
||||
notify_on_user_signup: bool = Field(
|
||||
default=True,
|
||||
description="Send admin notifications when a new user signs up",
|
||||
)
|
||||
notify_on_plan_change: bool = Field(
|
||||
default=True,
|
||||
description="Send admin notifications when a user changes their subscription plan",
|
||||
)
|
||||
notify_on_payment_issue: bool = Field(
|
||||
default=True,
|
||||
description="Send admin notifications when a payment issue is reported for a user",
|
||||
)
|
||||
|
||||
# Webhook settings
|
||||
webhook_enabled: bool = Field(
|
||||
|
||||
@@ -204,3 +204,104 @@ The file has been successfully processed and is being uploaded to all configured
|
||||
return send_notification(
|
||||
title=title, message=message.strip(), notification_type="success", tags=["document", "processed", "success"]
|
||||
)
|
||||
|
||||
|
||||
def notify_user_signup(user_id: str, display_name: str | None = None, email: str | None = None) -> bool:
|
||||
"""Send a notification to admins when a new user signs up.
|
||||
|
||||
Args:
|
||||
user_id: The stable user identifier (preferred_username / email / sub).
|
||||
display_name: Optional human-readable name for the user.
|
||||
email: Optional email address for the user.
|
||||
|
||||
Returns:
|
||||
bool: True if the notification was sent successfully.
|
||||
"""
|
||||
if not settings.notify_on_user_signup:
|
||||
return False
|
||||
|
||||
name_str = display_name or user_id
|
||||
email_str = email or "N/A"
|
||||
|
||||
title = f"New User Signup: {name_str}"
|
||||
message = f"""A new user has signed up for DocuElevate.
|
||||
|
||||
User ID: {user_id}
|
||||
Display Name: {name_str}
|
||||
Email: {email_str}
|
||||
|
||||
Review the new account in the admin panel."""
|
||||
|
||||
return send_notification(
|
||||
title=title,
|
||||
message=message.strip(),
|
||||
notification_type="info",
|
||||
tags=["user", "signup"],
|
||||
)
|
||||
|
||||
|
||||
def notify_plan_changed(
|
||||
user_id: str,
|
||||
old_tier: str,
|
||||
new_tier: str,
|
||||
changed_by: str = "user",
|
||||
) -> bool:
|
||||
"""Send a notification to admins when a user changes their subscription plan.
|
||||
|
||||
Args:
|
||||
user_id: The stable user identifier.
|
||||
old_tier: The previous subscription tier.
|
||||
new_tier: The new subscription tier.
|
||||
changed_by: Who initiated the change (``"user"`` or ``"admin"``).
|
||||
|
||||
Returns:
|
||||
bool: True if the notification was sent successfully.
|
||||
"""
|
||||
if not settings.notify_on_plan_change:
|
||||
return False
|
||||
|
||||
title = f"Plan Changed: {user_id}"
|
||||
message = f"""A user's subscription plan has changed.
|
||||
|
||||
User ID: {user_id}
|
||||
Previous Plan: {old_tier}
|
||||
New Plan: {new_tier}
|
||||
Changed By: {changed_by}
|
||||
|
||||
Review the account in the admin panel."""
|
||||
|
||||
return send_notification(
|
||||
title=title,
|
||||
message=message.strip(),
|
||||
notification_type="info",
|
||||
tags=["user", "plan", "subscription"],
|
||||
)
|
||||
|
||||
|
||||
def notify_payment_issue(user_id: str, issue: str) -> bool:
|
||||
"""Send a notification to admins when a payment issue is reported for a user.
|
||||
|
||||
Args:
|
||||
user_id: The stable user identifier.
|
||||
issue: A human-readable description of the payment issue.
|
||||
|
||||
Returns:
|
||||
bool: True if the notification was sent successfully.
|
||||
"""
|
||||
if not settings.notify_on_payment_issue:
|
||||
return False
|
||||
|
||||
title = f"Payment Issue: {user_id}"
|
||||
message = f"""A payment issue has been reported for a user.
|
||||
|
||||
User ID: {user_id}
|
||||
Issue: {issue}
|
||||
|
||||
Please review the account in the admin panel and follow up with the user."""
|
||||
|
||||
return send_notification(
|
||||
title=title,
|
||||
message=message.strip(),
|
||||
notification_type="warning",
|
||||
tags=["user", "payment", "billing"],
|
||||
)
|
||||
|
||||
@@ -1251,6 +1251,30 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"notify_on_user_signup": {
|
||||
"category": "Notifications",
|
||||
"description": "Send admin notifications when a new user signs up",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"notify_on_plan_change": {
|
||||
"category": "Notifications",
|
||||
"description": "Send admin notifications when a user changes their subscription plan",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"notify_on_payment_issue": {
|
||||
"category": "Notifications",
|
||||
"description": "Send admin notifications when a payment issue is reported for a user",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Feature Flags
|
||||
"allow_file_delete": {
|
||||
"category": "Feature Flags",
|
||||
|
||||
@@ -7,6 +7,9 @@ Supported events:
|
||||
- ``document.uploaded`` – a new document has been ingested
|
||||
- ``document.processed`` – a document finished processing successfully
|
||||
- ``document.failed`` – document processing failed
|
||||
- ``user.signup`` – a new user account was created
|
||||
- ``user.plan_changed`` – a user's subscription plan changed
|
||||
- ``user.payment_issue`` – a payment issue was reported for a user
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -29,6 +32,9 @@ VALID_EVENTS: frozenset[str] = frozenset(
|
||||
"document.uploaded",
|
||||
"document.processed",
|
||||
"document.failed",
|
||||
"user.signup",
|
||||
"user.plan_changed",
|
||||
"user.payment_issue",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+36
-1
@@ -812,6 +812,38 @@ Returns `204 No Content` on success, `404` if no profile exists.
|
||||
|
||||
---
|
||||
|
||||
**POST** `/api/admin/users/{user_id}/payment-issue`
|
||||
|
||||
Report a payment issue for a user. Sends an admin notification (via configured Apprise channels) and
|
||||
fires a `user.payment_issue` webhook event. Use this endpoint when a payment processor (e.g.
|
||||
Stripe, PayPal) sends a failed-charge notification or when a manual billing review identifies a
|
||||
problem.
|
||||
|
||||
**Request body**:
|
||||
```json
|
||||
{
|
||||
"issue": "Card declined: insufficient funds"
|
||||
}
|
||||
```
|
||||
|
||||
- `issue` (required): Human-readable description of the payment problem (1–2048 characters)
|
||||
|
||||
**Response (200)**:
|
||||
```json
|
||||
{
|
||||
"acknowledged": true,
|
||||
"user_id": "alice@example.com",
|
||||
"profile": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `404`: User profile not found
|
||||
- `403`: Admin access required
|
||||
- `422`: Validation error (e.g. empty issue string)
|
||||
|
||||
---
|
||||
|
||||
### Settings Suggestions (Autocomplete)
|
||||
|
||||
**GET** `/api/settings/{key}/suggestions`
|
||||
@@ -1072,6 +1104,9 @@ Manage webhook configurations for notifying external systems when document event
|
||||
| `document.uploaded` | A new document has been ingested |
|
||||
| `document.processed` | A document finished processing successfully |
|
||||
| `document.failed` | Document processing failed |
|
||||
| `user.signup` | A new user account was created |
|
||||
| `user.plan_changed` | A user's subscription plan changed |
|
||||
| `user.payment_issue` | A payment issue was reported for a user |
|
||||
|
||||
### GET /api/webhooks/events/
|
||||
|
||||
@@ -1079,7 +1114,7 @@ List all valid webhook event types.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
["document.failed", "document.processed", "document.uploaded"]
|
||||
["document.failed", "document.processed", "document.uploaded", "user.payment_issue", "user.plan_changed", "user.signup"]
|
||||
```
|
||||
|
||||
### GET /api/webhooks/
|
||||
|
||||
@@ -853,6 +853,26 @@ For detailed setup instructions, see the [Amazon S3 Setup Guide](AmazonS3Setup.m
|
||||
| `NOTIFY_ON_CREDENTIAL_FAILURE` | Send notifications on credential failures (`True`/`False`) |
|
||||
| `NOTIFY_ON_STARTUP` | Send notification when system starts (`True`/`False`) |
|
||||
| `NOTIFY_ON_SHUTDOWN` | Send notification when system shuts down (`True`/`False`)|
|
||||
| `NOTIFY_ON_FILE_PROCESSED` | Send notification when a file is successfully processed (`True`/`False`) |
|
||||
| `NOTIFY_ON_USER_SIGNUP` | Send admin notification when a new user signs up (`True`/`False`, default `True`) |
|
||||
| `NOTIFY_ON_PLAN_CHANGE` | Send admin notification when a user changes their subscription plan (`True`/`False`, default `True`) |
|
||||
| `NOTIFY_ON_PAYMENT_ISSUE` | Send admin notification when a payment issue is reported for a user (`True`/`False`, default `True`) |
|
||||
|
||||
#### User-Event Notifications
|
||||
|
||||
DocuElevate sends admin push notifications (via Apprise) and fires outbound webhooks for three
|
||||
user-lifecycle events:
|
||||
|
||||
| Event | Trigger | Notification type |
|
||||
|-------|---------|-------------------|
|
||||
| **New signup** | A first-time user logs in and a UserProfile is created | `NOTIFY_ON_USER_SIGNUP` |
|
||||
| **Plan change** | A user selects a new subscription tier during onboarding, or an admin changes their tier | `NOTIFY_ON_PLAN_CHANGE` |
|
||||
| **Payment issue** | An admin POSTs to `/api/admin/users/{user_id}/payment-issue` | `NOTIFY_ON_PAYMENT_ISSUE` |
|
||||
|
||||
In addition to the Apprise push notification, each event also fires the matching webhook event
|
||||
(`user.signup`, `user.plan_changed`, `user.payment_issue`) to all active webhook configurations
|
||||
subscribed to that event, enabling integration with CRM, helpdesk (Jira, Zendesk, etc.), or
|
||||
payment processors.
|
||||
|
||||
For detailed setup instructions, see the [Notifications Setup Guide](NotificationsSetup.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Tests for user-event notifications and webhooks.
|
||||
|
||||
Covers:
|
||||
- notify_user_signup / notify_plan_changed / notify_payment_issue helpers
|
||||
- New webhook events: user.signup, user.plan_changed, user.payment_issue
|
||||
- Plan-change notification fired from POST /api/onboarding/plan
|
||||
- Plan-change notification fired from PUT /api/admin/users/{user_id}
|
||||
- Payment-issue endpoint: POST /api/admin/users/{user_id}/payment-issue
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import UserProfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ADMIN_USER = {
|
||||
"sub": "admin-001",
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com",
|
||||
"is_admin": True,
|
||||
}
|
||||
|
||||
_REGULAR_USER_ID = "user-evt-001"
|
||||
|
||||
|
||||
def _make_engine():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return engine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: notification helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotifyUserSignup:
|
||||
"""Tests for notify_user_signup()."""
|
||||
|
||||
def test_sends_notification_when_enabled(self, mocker):
|
||||
"""Notification is sent when notify_on_user_signup=True."""
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification", return_value=True)
|
||||
mocker.patch("app.config.settings.notify_on_user_signup", True)
|
||||
|
||||
from app.utils.notification import notify_user_signup
|
||||
|
||||
result = notify_user_signup("alice", display_name="Alice", email="alice@example.com")
|
||||
assert result is True
|
||||
mock_send.assert_called_once()
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert "alice" in call_kwargs["title"].lower() or "Alice" in call_kwargs["title"]
|
||||
assert "alice@example.com" in call_kwargs["message"]
|
||||
|
||||
def test_skips_notification_when_disabled(self, mocker):
|
||||
"""Notification is NOT sent when notify_on_user_signup=False."""
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification")
|
||||
mocker.patch("app.config.settings.notify_on_user_signup", False)
|
||||
|
||||
from app.utils.notification import notify_user_signup
|
||||
|
||||
result = notify_user_signup("alice")
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_uses_user_id_as_fallback_name(self, mocker):
|
||||
"""When display_name is None the user_id appears in the title."""
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification", return_value=True)
|
||||
mocker.patch("app.config.settings.notify_on_user_signup", True)
|
||||
|
||||
from app.utils.notification import notify_user_signup
|
||||
|
||||
notify_user_signup("bob123")
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert "bob123" in call_kwargs["title"]
|
||||
|
||||
def test_returns_false_when_no_notification_urls(self):
|
||||
"""Returns False gracefully when no notification URLs are configured."""
|
||||
from app.config import settings
|
||||
from app.utils.notification import notify_user_signup
|
||||
|
||||
original = settings.notify_on_user_signup
|
||||
original_urls = settings.notification_urls
|
||||
try:
|
||||
settings.notify_on_user_signup = True
|
||||
settings.notification_urls = []
|
||||
result = notify_user_signup("charlie")
|
||||
assert result is False
|
||||
finally:
|
||||
settings.notify_on_user_signup = original
|
||||
settings.notification_urls = original_urls
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotifyPlanChanged:
|
||||
"""Tests for notify_plan_changed()."""
|
||||
|
||||
def test_sends_notification_when_enabled(self, mocker):
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification", return_value=True)
|
||||
mocker.patch("app.config.settings.notify_on_plan_change", True)
|
||||
|
||||
from app.utils.notification import notify_plan_changed
|
||||
|
||||
result = notify_plan_changed("alice", old_tier="free", new_tier="starter", changed_by="user")
|
||||
assert result is True
|
||||
mock_send.assert_called_once()
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert "free" in call_kwargs["message"]
|
||||
assert "starter" in call_kwargs["message"]
|
||||
assert "user" in call_kwargs["message"]
|
||||
|
||||
def test_skips_when_disabled(self, mocker):
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification")
|
||||
mocker.patch("app.config.settings.notify_on_plan_change", False)
|
||||
|
||||
from app.utils.notification import notify_plan_changed
|
||||
|
||||
result = notify_plan_changed("alice", old_tier="free", new_tier="starter")
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_changed_by_defaults_to_user(self, mocker):
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification", return_value=True)
|
||||
mocker.patch("app.config.settings.notify_on_plan_change", True)
|
||||
|
||||
from app.utils.notification import notify_plan_changed
|
||||
|
||||
notify_plan_changed("alice", old_tier="free", new_tier="professional")
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert "user" in call_kwargs["message"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotifyPaymentIssue:
|
||||
"""Tests for notify_payment_issue()."""
|
||||
|
||||
def test_sends_notification_when_enabled(self, mocker):
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification", return_value=True)
|
||||
mocker.patch("app.config.settings.notify_on_payment_issue", True)
|
||||
|
||||
from app.utils.notification import notify_payment_issue
|
||||
|
||||
result = notify_payment_issue("alice", issue="Card declined")
|
||||
assert result is True
|
||||
mock_send.assert_called_once()
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert "Card declined" in call_kwargs["message"]
|
||||
assert call_kwargs["notification_type"] == "warning"
|
||||
|
||||
def test_skips_when_disabled(self, mocker):
|
||||
mock_send = mocker.patch("app.utils.notification.send_notification")
|
||||
mocker.patch("app.config.settings.notify_on_payment_issue", False)
|
||||
|
||||
from app.utils.notification import notify_payment_issue
|
||||
|
||||
result = notify_payment_issue("alice", issue="Card declined")
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: VALID_EVENTS contains new user events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWebhookValidEvents:
|
||||
"""Ensure new user-event types are registered in VALID_EVENTS."""
|
||||
|
||||
def test_user_signup_is_valid(self):
|
||||
from app.utils.webhook import VALID_EVENTS
|
||||
|
||||
assert "user.signup" in VALID_EVENTS
|
||||
|
||||
def test_user_plan_changed_is_valid(self):
|
||||
from app.utils.webhook import VALID_EVENTS
|
||||
|
||||
assert "user.plan_changed" in VALID_EVENTS
|
||||
|
||||
def test_user_payment_issue_is_valid(self):
|
||||
from app.utils.webhook import VALID_EVENTS
|
||||
|
||||
assert "user.payment_issue" in VALID_EVENTS
|
||||
|
||||
def test_dispatch_user_signup_event(self, mocker):
|
||||
"""dispatch_webhook_event accepts user.signup without warning."""
|
||||
mock_task = mocker.patch("app.tasks.webhook_tasks.deliver_webhook_task")
|
||||
mock_task.delay = MagicMock()
|
||||
mocker.patch(
|
||||
"app.utils.webhook.get_active_webhooks_for_event",
|
||||
return_value=[{"id": 1, "url": "https://hook.example.com", "secret": None}],
|
||||
)
|
||||
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
dispatch_webhook_event("user.signup", {"user_id": "alice"})
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
def test_dispatch_user_plan_changed_event(self, mocker):
|
||||
mock_task = mocker.patch("app.tasks.webhook_tasks.deliver_webhook_task")
|
||||
mock_task.delay = MagicMock()
|
||||
mocker.patch(
|
||||
"app.utils.webhook.get_active_webhooks_for_event",
|
||||
return_value=[{"id": 2, "url": "https://hook.example.com", "secret": "s"}],
|
||||
)
|
||||
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
dispatch_webhook_event("user.plan_changed", {"user_id": "alice", "old_tier": "free", "new_tier": "starter"})
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
def test_dispatch_user_payment_issue_event(self, mocker):
|
||||
mock_task = mocker.patch("app.tasks.webhook_tasks.deliver_webhook_task")
|
||||
mock_task.delay = MagicMock()
|
||||
mocker.patch(
|
||||
"app.utils.webhook.get_active_webhooks_for_event",
|
||||
return_value=[{"id": 3, "url": "https://hook.example.com", "secret": None}],
|
||||
)
|
||||
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
dispatch_webhook_event("user.payment_issue", {"user_id": "alice", "issue": "Card declined"})
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: /api/onboarding/plan fires plan-change notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOnboardingPlanNotification:
|
||||
"""Plan-change events are fired when the user changes tier via onboarding."""
|
||||
|
||||
@pytest.fixture()
|
||||
def _engine(self):
|
||||
engine = _make_engine()
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture()
|
||||
def _client(self, _engine):
|
||||
from app.api import onboarding as ob_module
|
||||
from app.main import app
|
||||
|
||||
original_get_user = ob_module._get_current_user_id
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def fake_user_id(_request):
|
||||
return _REGULAR_USER_ID
|
||||
|
||||
ob_module._get_current_user_id = fake_user_id
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
|
||||
ob_module._get_current_user_id = original_get_user
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_plan_change_fires_notification_and_webhook(self, _client, _engine, mocker):
|
||||
"""Changing from free → starter sends notification and webhook."""
|
||||
# Pre-create a profile with 'free' tier
|
||||
Session = sessionmaker(bind=_engine)
|
||||
session = Session()
|
||||
profile = UserProfile(user_id=_REGULAR_USER_ID, subscription_tier="free")
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_plan_changed", return_value=True)
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
resp = _client.post("/api/onboarding/plan", json={"subscription_tier": "starter", "billing_cycle": "monthly"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
mock_notify.assert_called_once_with(_REGULAR_USER_ID, old_tier="free", new_tier="starter", changed_by="user")
|
||||
mock_dispatch.assert_called_once()
|
||||
call_args = mock_dispatch.call_args
|
||||
assert call_args[0][0] == "user.plan_changed"
|
||||
assert call_args[0][1]["old_tier"] == "free"
|
||||
assert call_args[0][1]["new_tier"] == "starter"
|
||||
|
||||
def test_no_event_when_tier_unchanged(self, _client, _engine, mocker):
|
||||
"""No notification or webhook when the tier stays the same."""
|
||||
Session = sessionmaker(bind=_engine)
|
||||
session = Session()
|
||||
profile = UserProfile(user_id=_REGULAR_USER_ID, subscription_tier="starter")
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_plan_changed")
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
resp = _client.post("/api/onboarding/plan", json={"subscription_tier": "starter", "billing_cycle": "monthly"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: /api/admin/users/{user_id} fires plan-change notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAdminUsersPlanNotification:
|
||||
"""Plan-change events are fired when an admin changes a user's tier."""
|
||||
|
||||
@pytest.fixture()
|
||||
def _engine(self):
|
||||
engine = _make_engine()
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture()
|
||||
def _admin_client(self, _engine):
|
||||
from app.api.admin_users import _require_admin
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[_require_admin] = lambda: _ADMIN_USER
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def _seed_profile(self, engine, user_id: str, tier: str = "free") -> None:
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
profile = UserProfile(user_id=user_id, subscription_tier=tier)
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def test_admin_plan_change_fires_notification(self, _admin_client, _engine, mocker):
|
||||
"""Admin changing free → professional sends notification and webhook."""
|
||||
self._seed_profile(_engine, _REGULAR_USER_ID, tier="free")
|
||||
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_plan_changed", return_value=True)
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
resp = _admin_client.put(
|
||||
f"/api/admin/users/{_REGULAR_USER_ID}",
|
||||
json={"subscription_tier": "professional", "is_blocked": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
_REGULAR_USER_ID, old_tier="free", new_tier="professional", changed_by="admin"
|
||||
)
|
||||
mock_dispatch.assert_called_once()
|
||||
call_args = mock_dispatch.call_args
|
||||
assert call_args[0][0] == "user.plan_changed"
|
||||
assert call_args[0][1]["changed_by"] == "admin"
|
||||
|
||||
def test_admin_no_event_when_tier_unchanged(self, _admin_client, _engine, mocker):
|
||||
"""No notification when admin saves a profile without changing the tier."""
|
||||
self._seed_profile(_engine, _REGULAR_USER_ID, tier="starter")
|
||||
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_plan_changed")
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
resp = _admin_client.put(
|
||||
f"/api/admin/users/{_REGULAR_USER_ID}",
|
||||
json={"subscription_tier": "starter", "is_blocked": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_notify.assert_not_called()
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
def test_admin_no_event_for_brand_new_profile(self, _admin_client, _engine, mocker):
|
||||
"""Creating a brand-new profile via PUT does NOT fire a plan-changed event."""
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_plan_changed")
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
resp = _admin_client.put(
|
||||
"/api/admin/users/brand-new-user",
|
||||
json={"subscription_tier": "starter", "is_blocked": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_notify.assert_not_called()
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: POST /api/admin/users/{user_id}/payment-issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPaymentIssueEndpoint:
|
||||
"""Tests for POST /api/admin/users/{user_id}/payment-issue."""
|
||||
|
||||
@pytest.fixture()
|
||||
def _engine(self):
|
||||
engine = _make_engine()
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture()
|
||||
def _admin_client(self, _engine):
|
||||
from app.api.admin_users import _require_admin
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[_require_admin] = lambda: _ADMIN_USER
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def _seed_profile(self, engine, user_id: str) -> None:
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
session.add(UserProfile(user_id=user_id))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def test_payment_issue_returns_200(self, _admin_client, _engine, mocker):
|
||||
"""POST /payment-issue returns 200 with acknowledged=True."""
|
||||
self._seed_profile(_engine, _REGULAR_USER_ID)
|
||||
mocker.patch("app.utils.notification.notify_payment_issue", return_value=True)
|
||||
mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
resp = _admin_client.post(
|
||||
f"/api/admin/users/{_REGULAR_USER_ID}/payment-issue",
|
||||
json={"issue": "Card declined"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["acknowledged"] is True
|
||||
assert data["user_id"] == _REGULAR_USER_ID
|
||||
|
||||
def test_payment_issue_fires_notification_and_webhook(self, _admin_client, _engine, mocker):
|
||||
"""Notification and webhook are dispatched for a payment issue."""
|
||||
self._seed_profile(_engine, _REGULAR_USER_ID)
|
||||
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_payment_issue", return_value=True)
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
_admin_client.post(
|
||||
f"/api/admin/users/{_REGULAR_USER_ID}/payment-issue",
|
||||
json={"issue": "Disputed charge"},
|
||||
)
|
||||
|
||||
mock_notify.assert_called_once_with(_REGULAR_USER_ID, issue="Disputed charge")
|
||||
mock_dispatch.assert_called_once()
|
||||
call_args = mock_dispatch.call_args
|
||||
assert call_args[0][0] == "user.payment_issue"
|
||||
assert call_args[0][1]["issue"] == "Disputed charge"
|
||||
|
||||
def test_payment_issue_404_for_unknown_user(self, _admin_client, _engine, mocker):
|
||||
"""Returns 404 when the user profile does not exist."""
|
||||
mocker.patch("app.utils.notification.notify_payment_issue")
|
||||
resp = _admin_client.post(
|
||||
"/api/admin/users/ghost-user/payment-issue",
|
||||
json={"issue": "Unpaid invoice"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_payment_issue_requires_admin(self, _engine):
|
||||
"""Returns 403 without admin session."""
|
||||
from app.api.admin_users import _require_admin
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
# Remove the admin override so the real guard runs
|
||||
app.dependency_overrides.pop(_require_admin, None)
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
resp = client.post(
|
||||
"/api/admin/users/any-user/payment-issue",
|
||||
json={"issue": "Test"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_payment_issue_rejects_empty_issue(self, _admin_client, _engine, mocker):
|
||||
"""Empty issue string fails validation (422)."""
|
||||
self._seed_profile(_engine, _REGULAR_USER_ID)
|
||||
mocker.patch("app.utils.notification.notify_payment_issue")
|
||||
|
||||
resp = _admin_client.post(
|
||||
f"/api/admin/users/{_REGULAR_USER_ID}/payment-issue",
|
||||
json={"issue": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: signup notification is fired on new profile creation via auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSignupNotificationFromAuth:
|
||||
"""_ensure_user_profile fires signup notification for new users."""
|
||||
|
||||
def test_fires_signup_notification_for_new_user(self, mocker):
|
||||
"""Notification and webhook are triggered when a brand-new profile is created."""
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_user_signup", return_value=True)
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None # no existing profile
|
||||
|
||||
user_data = {
|
||||
"sub": "new-user-sub",
|
||||
"name": "New User",
|
||||
"email": "newuser@example.com",
|
||||
"preferred_username": "newuser",
|
||||
}
|
||||
|
||||
_ensure_user_profile(mock_db, user_data)
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
"new-user-sub",
|
||||
display_name="New User",
|
||||
email="newuser@example.com",
|
||||
)
|
||||
mock_dispatch.assert_called_once()
|
||||
call_args = mock_dispatch.call_args
|
||||
assert call_args[0][0] == "user.signup"
|
||||
assert call_args[0][1]["user_id"] == "new-user-sub"
|
||||
|
||||
def test_no_signup_notification_for_existing_user(self, mocker):
|
||||
"""No notification when the user profile already exists (returning user)."""
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_user_signup")
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
from app.auth import _ensure_user_profile
|
||||
from app.models import UserProfile
|
||||
|
||||
existing_profile = MagicMock(spec=UserProfile)
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = existing_profile
|
||||
|
||||
user_data = {
|
||||
"sub": "existing-user-sub",
|
||||
"name": "Existing User",
|
||||
"email": "existing@example.com",
|
||||
}
|
||||
|
||||
_ensure_user_profile(mock_db, user_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
def test_no_signup_notification_when_no_user_id(self, mocker):
|
||||
"""No notification when user_data has no stable identifier."""
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_user_signup")
|
||||
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
mock_db = MagicMock()
|
||||
_ensure_user_profile(mock_db, {})
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
def test_no_signup_notification_for_new_admin_user(self, mocker):
|
||||
"""Admin users never receive a signup notification even on first login."""
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_user_signup")
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None # no existing profile
|
||||
|
||||
user_data = {
|
||||
"sub": "admin-user-sub",
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
|
||||
_ensure_user_profile(mock_db, user_data, is_admin=True)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: webhook events listed via the API include new user events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestWebhookAPIEventsIncludeUserEvents:
|
||||
"""GET /api/webhooks/events/ includes the new user event types."""
|
||||
|
||||
def test_list_events_includes_user_signup(self, client):
|
||||
"""user.signup appears in the events list."""
|
||||
from app.api.webhooks import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: _ADMIN_USER
|
||||
resp = client.get("/api/webhooks/events/")
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
assert resp.status_code == 200
|
||||
assert "user.signup" in resp.json()
|
||||
|
||||
def test_list_events_includes_user_plan_changed(self, client):
|
||||
from app.api.webhooks import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: _ADMIN_USER
|
||||
resp = client.get("/api/webhooks/events/")
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
assert resp.status_code == 200
|
||||
assert "user.plan_changed" in resp.json()
|
||||
|
||||
def test_list_events_includes_user_payment_issue(self, client):
|
||||
from app.api.webhooks import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: _ADMIN_USER
|
||||
resp = client.get("/api/webhooks/events/")
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
assert resp.status_code == 200
|
||||
assert "user.payment_issue" in resp.json()
|
||||
Reference in New Issue
Block a user