diff --git a/app/api/admin_users.py b/app/api/admin_users.py index f80d463f..be343ea4 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -61,6 +61,12 @@ class UserProfileUpsert(BaseModel): allow_overage: bool = False +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.""" @@ -258,6 +264,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 @@ -265,6 +272,8 @@ def upsert_user_profile( profile.subscription_billing_cycle = body.subscription_billing_cycle profile.subscription_period_start = body.subscription_period_start profile.allow_overage = body.allow_overage + tier_changed = False + new_tier: str | None = None if body.subscription_tier is not None: from app.utils.subscription import TIERS @@ -273,6 +282,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: @@ -283,9 +296,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*. diff --git a/app/api/onboarding.py b/app/api/onboarding.py index 396f645d..f6d0bbe5 100644 --- a/app/api/onboarding.py +++ b/app/api/onboarding.py @@ -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) diff --git a/app/auth.py b/app/auth.py index e0399915..46331843 100644 --- a/app/auth.py +++ b/app/auth.py @@ -151,10 +151,27 @@ def _ensure_user_profile(db: Session, user_data: dict) -> None: 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) db.add(profile) db.commit() logger.info("Auto-created UserProfile for user_id=%s", user_id) + # Notify admins and fire webhook for new signup + 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) except Exception: db.rollback() logger.exception("Failed to auto-create UserProfile for user_id=%s", user_id) diff --git a/app/config.py b/app/config.py index 848846a6..0f13c5f8 100644 --- a/app/config.py +++ b/app/config.py @@ -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( diff --git a/app/utils/notification.py b/app/utils/notification.py index be39aa04..a1463401 100644 --- a/app/utils/notification.py +++ b/app/utils/notification.py @@ -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"], + ) diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index eb622f1b..1c3557d8 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -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", diff --git a/app/utils/webhook.py b/app/utils/webhook.py index cb302121..3bc54f82 100644 --- a/app/utils/webhook.py +++ b/app/utils/webhook.py @@ -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", } ) diff --git a/docs/API.md b/docs/API.md index 2d016ddc..e3bca54a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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/ diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 863afb09..25068635 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -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). diff --git a/tests/test_user_event_notifications.py b/tests/test_user_event_notifications.py new file mode 100644 index 00000000..9d04bacf --- /dev/null +++ b/tests/test_user_event_notifications.py @@ -0,0 +1,649 @@ +"""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() + + +# --------------------------------------------------------------------------- +# 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()