refactor(api): complete account education boundary (#41153)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
非法操作
2026-08-25 01:48:12 +00:00
committed by GitHub
parent c9ea15a968
commit ef92cb7c65
16 changed files with 374 additions and 276 deletions
+18 -7
View File
@@ -31,6 +31,8 @@ from controllers.console.auth.error import (
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
EducationActivateLimitError,
EducationVerifyLimitError,
EmailDomainSuspendedError,
EmailSendIpLimitError,
)
@@ -239,6 +241,10 @@ class EducationVerifyResponse(ResponseModel):
token: str | None = None
class EducationActivateResponse(ResponseModel):
message: str
class EducationStatusResponse(ResponseModel):
result: bool | None = None
is_student: bool | None = None
@@ -263,6 +269,7 @@ register_response_schema_models(
AccountIntegrateResponse,
AccountIntegrateListResponse,
AvatarUrlResponse,
EducationActivateResponse,
EducationVerifyResponse,
EducationStatusResponse,
EducationAutocompleteResponse,
@@ -528,29 +535,33 @@ class EducationVerifyApi(Resource):
def get(self, request_context: RequestContext):
try:
verification = application_services().accounts.education.verify(request_context)
except account_errors.AccountNotFoundError:
raise AccountNotFound() from None
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
except account_errors.EducationRateLimitExceededError as error:
raise EducationVerifyLimitError() from error
return dump_response(EducationVerifyResponse, verification)
@console_ns.route("/account/education")
class EducationApi(Resource):
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
# response-contract:ignore billing-service activation payload; TODO: model education activation result.
@console_ns.response(HTTPStatus.OK, "Success")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__])
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = EducationActivatePayload.model_validate(payload)
try:
return application_services().accounts.education.activate(
activation = application_services().accounts.education.activate(
request_context,
token=args.token,
institution=args.institution,
role=args.role,
)
except account_errors.AccountNotFoundError:
raise AccountNotFound() from None
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
except account_errors.EducationRateLimitExceededError as error:
raise EducationActivateLimitError() from error
return dump_response(EducationActivateResponse, activation)
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
@@ -417,6 +417,7 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
def get(self, current_tenant_id: str, current_user: Account, provider: str):
if provider != "anthropic":
raise ValueError(f"provider name {provider} is invalid")
# pyrefly: ignore [deprecated]
data = BillingService.get_model_provider_payment_link(
provider_name=provider,
tenant_id=current_tenant_id,
@@ -233,6 +233,18 @@ def build_application_services(
education=AccountEducationService(
accounts=accounts,
education=BillingAccountEducationGateway(),
verification_rate_limiter=RateLimiter(
prefix="edu_verification_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
),
activation_rate_limiter=RateLimiter(
prefix="edu_activation_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
),
),
initialization=AccountInitializationService(
accounts=accounts,
+9 -3
View File
@@ -146,9 +146,9 @@ Deprecated. Use PATCH /account/profile instead.
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [EducationActivateResponse](#educationactivateresponse)<br> |
### [GET] /account/education/autocomplete
#### Parameters
@@ -17681,6 +17681,12 @@ Portable DSL reference that could not be restored in the target workspace.
| role | string | | Yes |
| token | string | | Yes |
#### EducationActivateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| message | string | | Yes |
#### EducationAutocompleteQuery
| Name | Type | Description | Required |
+15 -20
View File
@@ -1,33 +1,23 @@
"""Billing adapters for account education and deletion-feedback use cases."""
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast, override
from typing import override
from services.account_deletion_feedback_service import AccountDeletionFeedbackGateway
from services.account_education_service import AccountEducationGateway
from services.billing_service import BillingService
from services.entities.account_entities import (
AccountEducationActivation,
AccountEducationAutocomplete,
AccountEducationStatus,
AccountEducationVerification,
)
if TYPE_CHECKING:
from models.account import Account
@dataclass(frozen=True, slots=True)
class _EducationAccount:
id: str
email: str
current_tenant_id: str
class BillingAccountEducationGateway(AccountEducationGateway):
@override
def verify(self, *, account_id: str, email: str) -> AccountEducationVerification:
result = BillingService.EducationIdentity.verify(account_id, email) or {}
def verify(self, *, account_id: str) -> AccountEducationVerification:
result = BillingService.EducationIdentity.verify(account_id=account_id) or {}
return AccountEducationVerification(token=result.get("token"))
@override
@@ -35,18 +25,23 @@ class BillingAccountEducationGateway(AccountEducationGateway):
self,
*,
account_id: str,
email: str,
tenant_id: str,
token: str,
institution: str,
role: str,
) -> dict[str, Any] | None:
account = cast("Account", _EducationAccount(id=account_id, email=email, current_tenant_id=tenant_id))
return BillingService.EducationIdentity.activate(account, token, institution, role)
) -> AccountEducationActivation:
result = BillingService.EducationIdentity.activate(
account_id=account_id,
tenant_id=tenant_id,
token=token,
institution=institution,
role=role,
)
return AccountEducationActivation(message=result["message"])
@override
def status(self, account_id: str) -> AccountEducationStatus:
result: dict[str, Any] = BillingService.EducationIdentity.status(account_id) or {}
result = BillingService.EducationIdentity.status(account_id) or {}
expire_at = result.get("expire_at")
return AccountEducationStatus(
result=result.get("result"),
@@ -57,7 +52,7 @@ class BillingAccountEducationGateway(AccountEducationGateway):
@override
def autocomplete(self, *, keywords: str, page: int, limit: int) -> AccountEducationAutocomplete:
result: dict[str, Any] = BillingService.EducationIdentity.autocomplete(keywords, page, limit) or {}
result = BillingService.EducationIdentity.autocomplete(keywords, page, limit) or {}
return AccountEducationAutocomplete(
data=tuple(result.get("data") or ()),
curr_page=result.get("curr_page"),
+23 -8
View File
@@ -1,31 +1,37 @@
"""Application service for account education-discount use cases."""
from typing import Any, Protocol
from typing import Protocol
from machinery.context import RequestContext
from machinery.errors import ActiveWorkspaceRequiredError
from services.account_errors import AccountNotFoundError
from services.account_errors import AccountNotFoundError, EducationRateLimitExceededError
from services.account_ports import AccountRepository
from services.entities.account_entities import (
AccountEducationActivation,
AccountEducationAutocomplete,
AccountEducationStatus,
AccountEducationVerification,
)
class AccountEducationRateLimiter(Protocol):
def is_rate_limited(self, key: str, /) -> bool: ...
def increment_rate_limit(self, key: str, /) -> None: ...
class AccountEducationGateway(Protocol):
def verify(self, *, account_id: str, email: str) -> AccountEducationVerification: ...
def verify(self, *, account_id: str) -> AccountEducationVerification: ...
def activate(
self,
*,
account_id: str,
email: str,
tenant_id: str,
token: str,
institution: str,
role: str,
) -> dict[str, Any] | None: ...
) -> AccountEducationActivation: ...
def status(self, account_id: str) -> AccountEducationStatus: ...
@@ -38,15 +44,22 @@ class AccountEducationService:
*,
accounts: AccountRepository,
education: AccountEducationGateway,
verification_rate_limiter: AccountEducationRateLimiter,
activation_rate_limiter: AccountEducationRateLimiter,
) -> None:
self._accounts = accounts
self._education = education
self._verification_rate_limiter = verification_rate_limiter
self._activation_rate_limiter = activation_rate_limiter
def verify(self, context: RequestContext) -> AccountEducationVerification:
account = self._accounts.get(context.account_id)
if account is None:
raise AccountNotFoundError
return self._education.verify(account_id=account.id, email=account.email)
if self._verification_rate_limiter.is_rate_limited(account.email):
raise EducationRateLimitExceededError
self._verification_rate_limiter.increment_rate_limit(account.email)
return self._education.verify(account_id=account.id)
def activate(
self,
@@ -55,15 +68,17 @@ class AccountEducationService:
token: str,
institution: str,
role: str,
) -> dict[str, Any] | None:
) -> AccountEducationActivation:
account = self._accounts.get(context.account_id)
if account is None:
raise AccountNotFoundError
if context.active_workspace_id is None:
raise ActiveWorkspaceRequiredError
if self._activation_rate_limiter.is_rate_limited(account.email):
raise EducationRateLimitExceededError
self._activation_rate_limiter.increment_rate_limit(account.email)
return self._education.activate(
account_id=account.id,
email=account.email,
tenant_id=context.active_workspace_id,
token=token,
institution=institution,
+4
View File
@@ -87,3 +87,7 @@ class AccountEmailAlreadyInUseError(AccountApplicationError):
class EducationDiscountPausedError(AccountApplicationError):
"""Education discount activation is temporarily paused."""
class EducationRateLimitExceededError(AccountApplicationError):
"""Too many education verification or activation requests were made."""
+45 -37
View File
@@ -2,18 +2,18 @@ import json
import logging
import os
from collections.abc import Sequence
from typing import Literal, NotRequired, TypedDict
from typing import Any, Literal, NotRequired, TypedDict
import httpx
from pydantic import TypeAdapter, ValidationError
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
from typing_extensions import deprecated
from werkzeug.exceptions import InternalServerError
from core.helper.http_client_pooling import get_pooled_http_client
from enums import CloudPlan
from extensions.ext_redis import redis_client
from libs.helper import RateLimiter
from models import Account
from services.billing_portal_service import BillingPortalLink
from services.errors.billing import (
BillingUpstreamInvalidResponseError,
@@ -35,7 +35,7 @@ EmailFreezeType = Literal["freeze", "email_domain_suspended"]
class _BillingHTTPStatusError(ValueError):
def __init__(self, message: str, status_code: int):
def __init__(self, message: str, status_code: int) -> None:
super().__init__(message)
self.status_code = status_code
@@ -47,6 +47,27 @@ class SubscriptionPlan(TypedDict):
expiration_date: int
class MessageResponseDict(TypedDict):
message: str
class EducationVerifyResponseDict(TypedDict):
token: str
class EducationStatusResponseDict(TypedDict):
result: bool
is_student: bool
expire_at: str
allow_refresh: bool
class EducationAutocompleteResponseDict(TypedDict):
data: list[str]
curr_page: int
has_next: bool
_billing_portal_link_adapter = TypeAdapter(BillingPortalLink)
@@ -244,13 +265,6 @@ class BillingService:
def invalidate_vector_space_cache(cls, tenant_id: str) -> None:
cls.get_vector_space(tenant_id, bypass_cache=True)
@classmethod
def get_tenant_feature_plan_usage_info(cls, tenant_id: str):
"""Deprecated: Use get_quota_info instead."""
params = {"tenant_id": tenant_id}
usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params)
return usage_info
@classmethod
def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo:
params = {"tenant_id": tenant_id}
@@ -370,6 +384,7 @@ class BillingService:
return cls._send_billing_portal_request("/subscription/payment-link", params=params)
@classmethod
@deprecated("Only used by the deprecated model-provider checkout endpoint.")
def get_model_provider_payment_link(cls, provider_name: str, tenant_id: str, account_id: str, prefilled_email: str):
params = {
"provider_name": provider_name,
@@ -420,6 +435,7 @@ class BillingService:
return cls._send_request("POST", "/tenant-feature-usage/refund", params={"quota_usage_history_id": history_id})
@classmethod
@deprecated("Legacy tenant feature-plan usage endpoint; use the quota APIs instead.")
def get_tenant_feature_plan_usage(cls, tenant_id: str, feature_key: str):
params = {"tenant_id": tenant_id, "feature_key": feature_key}
return cls._send_request("GET", "/billing/tenant_feature_plan/usage", params=params)
@@ -427,7 +443,7 @@ class BillingService:
@classmethod
def _send_quota_request(
cls, method: Literal["GET", "POST", "DELETE", "PUT"], endpoint: str, json=None, params=None
):
) -> dict[str, Any]:
return cls._send_request(method, endpoint, json=json, params=params, base_url=cls.quota_base_url)
@classmethod
@@ -444,7 +460,7 @@ class BillingService:
json=None,
params=None,
base_url: str | None = None,
):
) -> Any:
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
url = f"{base_url or cls.base_url}{endpoint}"
@@ -492,7 +508,7 @@ class BillingService:
raise RuntimeError("Unexpected billing service value error") from error
@classmethod
def delete_account(cls, account_id: str):
def delete_account(cls, account_id: str) -> MessageResponseDict:
"""Delete account."""
params = {"account_id": account_id}
return cls._send_request("DELETE", "/account", params=params)
@@ -520,41 +536,33 @@ class BillingService:
return cls.get_email_freeze_type(email) is not None
@classmethod
def update_account_deletion_feedback(cls, email: str, feedback: str):
def update_account_deletion_feedback(cls, email: str, feedback: str) -> MessageResponseDict:
"""Update account deletion feedback."""
json = {"email": email, "feedback": feedback}
return cls._send_request("POST", "/account/delete-feedback", json=json)
class EducationIdentity:
verification_rate_limit = RateLimiter(prefix="edu_verification_rate_limit", max_attempts=10, time_window=60)
activation_rate_limit = RateLimiter(prefix="edu_activation_rate_limit", max_attempts=10, time_window=60)
@classmethod
def verify(cls, account_id: str, account_email: str):
if cls.verification_rate_limit.is_rate_limited(account_email):
from controllers.console.error import EducationVerifyLimitError
raise EducationVerifyLimitError()
cls.verification_rate_limit.increment_rate_limit(account_email)
def verify(cls, account_id: str) -> EducationVerifyResponseDict:
params = {"account_id": account_id}
return BillingService._send_request("GET", "/education/verify", params=params)
@classmethod
def status(cls, account_id: str):
def status(cls, account_id: str) -> EducationStatusResponseDict:
params = {"account_id": account_id}
return BillingService._send_request("GET", "/education/status", params=params)
@classmethod
def activate(cls, account: Account, token: str, institution: str, role: str):
if cls.activation_rate_limit.is_rate_limited(account.email):
from controllers.console.error import EducationActivateLimitError
raise EducationActivateLimitError()
cls.activation_rate_limit.increment_rate_limit(account.email)
params = {"account_id": account.id, "curr_tenant_id": account.current_tenant_id}
def activate(
cls,
*,
account_id: str,
tenant_id: str,
token: str,
institution: str,
role: str,
) -> MessageResponseDict:
params = {"account_id": account_id, "curr_tenant_id": tenant_id}
json = {
"institution": institution,
"token": token,
@@ -563,7 +571,7 @@ class BillingService:
return BillingService._send_request("POST", "/education/", json=json, params=params)
@classmethod
def autocomplete(cls, keywords: str, page: int = 0, limit: int = 20):
def autocomplete(cls, keywords: str, page: int = 0, limit: int = 20) -> EducationAutocompleteResponseDict:
params = {"keywords": keywords, "page": page, "limit": limit}
return BillingService._send_request("GET", "/education/autocomplete", params=params)
@@ -594,11 +602,11 @@ class BillingService:
return res
@classmethod
def clean_billing_info_cache(cls, tenant_id: str):
def clean_billing_info_cache(cls, tenant_id: str) -> None:
redis_client.delete(f"tenant:{tenant_id}:billing_info")
@classmethod
def sync_partner_tenants_bindings(cls, account_id: str, partner_key: str, click_id: str):
def sync_partner_tenants_bindings(cls, account_id: str, partner_key: str, click_id: str) -> dict[str, Any]:
payload = {"account_id": account_id, "click_id": click_id}
return cls._send_request("PUT", f"/partners/{partner_key}/tenants", json=payload)
@@ -183,6 +183,11 @@ class AccountEducationVerification:
token: str | None
@dataclass(frozen=True, slots=True)
class AccountEducationActivation:
message: str
@dataclass(frozen=True, slots=True)
class AccountEducationStatus:
result: bool | None
@@ -8,7 +8,7 @@ from flask import Flask
from sqlalchemy.orm import Session
from controllers.console.auth.error import InvalidTokenError
from controllers.console.error import EmailDomainSuspendedError
from controllers.console.error import EducationActivateLimitError, EducationVerifyLimitError, EmailDomainSuspendedError
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
ChangeEmailCheckApi,
@@ -16,13 +16,14 @@ from controllers.console.workspace.account import (
ChangeEmailSendEmailApi,
CheckEmailUnique,
EducationApi,
EducationVerifyApi,
)
from machinery.context import RequestContext
from models import Account, AccountStatus, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
from services import account_errors
from services.account_service import AccountService
from services.entities.account_entities import ChangeEmailVerification
from services.entities.account_entities import AccountEducationActivation, ChangeEmailVerification
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
@@ -105,7 +106,7 @@ def _build_change_email_token(
class TestEducationApi:
def test_post_activates_education_discount(self, app: Flask):
education = MagicMock()
education.activate.return_value = {"message": "success"}
education.activate.return_value = AccountEducationActivation(message="success")
request_context = RequestContext(
request_id="request-1",
trace_id=None,
@@ -136,6 +137,52 @@ class TestEducationApi:
role="Student",
)
def test_verify_maps_rate_limit_error(self, app: Flask):
education = MagicMock()
education.verify.side_effect = account_errors.EducationRateLimitExceededError
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
with (
app.test_request_context("/account/education/verify", method="GET"),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(education=education)),
),
):
api = EducationVerifyApi()
with pytest.raises(EducationVerifyLimitError):
inspect.unwrap(api.get)(api, request_context)
def test_post_maps_rate_limit_error(self, app: Flask):
education = MagicMock()
education.activate.side_effect = account_errors.EducationRateLimitExceededError
request_context = RequestContext(
request_id="request-1",
trace_id=None,
account_id="account-1",
active_workspace_id="workspace-1",
)
with (
app.test_request_context(
"/account/education",
method="POST",
json={"token": "education-token", "institution": "Dify University", "role": "Student"},
),
patch(
"controllers.console.workspace.account.application_services",
return_value=SimpleNamespace(accounts=SimpleNamespace(education=education)),
),
):
api = EducationApi()
with pytest.raises(EducationActivateLimitError):
inspect.unwrap(api.post)(api, request_context)
def _change_email_context(account_id: str = "acc") -> RequestContext:
return RequestContext(
@@ -233,6 +233,32 @@ def test_build_application_services_wires_billing_service(
sync_partner_tenants_bindings.assert_called_once_with("account-1", "partner-key", "click-1")
def test_build_application_services_wires_education_rate_limiters(
sqlite_session_factory: sessionmaker[Session],
) -> None:
redis = MagicMock(spec=RedisClientWrapper)
with patch("extensions.ext_application_services.RateLimiter") as rate_limiter_type:
ext_application_services.build_application_services(
database_client=sqlite_session_factory,
deployment_edition=DeploymentEdition.COMMUNITY,
initialization_password="",
redis=redis,
)
rate_limiter_type.assert_any_call(
prefix="edu_verification_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
)
rate_limiter_type.assert_any_call(
prefix="edu_activation_rate_limit",
max_attempts=10,
time_window=60,
redis_client=redis,
)
def test_build_application_services_wires_account_profile_repository(
sqlite_session_factory: sessionmaker[Session],
) -> None:
@@ -2,7 +2,11 @@ from datetime import UTC, datetime
from unittest.mock import patch
from services.account_billing_adapters import BillingAccountEducationGateway
from services.entities.account_entities import AccountEducationAutocomplete, AccountEducationStatus
from services.entities.account_entities import (
AccountEducationActivation,
AccountEducationAutocomplete,
AccountEducationStatus,
)
def test_education_gateway_normalizes_billing_status_timestamp() -> None:
@@ -36,19 +40,20 @@ def test_education_gateway_activates_with_primitive_account_context() -> None:
) as activate:
result = gateway.activate(
account_id="account-1",
email="student@example.edu",
tenant_id="workspace-1",
token="education-token",
institution="Dify University",
role="Student",
)
assert result == {"message": "success"}
account = activate.call_args.args[0]
assert account.id == "account-1"
assert account.email == "student@example.edu"
assert account.current_tenant_id == "workspace-1"
assert activate.call_args.args[1:] == ("education-token", "Dify University", "Student")
assert result == AccountEducationActivation(message="success")
activate.assert_called_once_with(
account_id="account-1",
tenant_id="workspace-1",
token="education-token",
institution="Dify University",
role="Student",
)
def test_education_gateway_normalizes_autocomplete_defaults() -> None:
@@ -3,10 +3,18 @@ from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import Mock
import pytest
from machinery.context import RequestContext
from services.account_education_service import AccountEducationGateway, AccountEducationService
from services.account_education_service import (
AccountEducationGateway,
AccountEducationRateLimiter,
AccountEducationService,
)
from services.account_errors import EducationRateLimitExceededError
from services.account_ports import AccountRepository
from services.entities.account_entities import (
AccountEducationActivation,
AccountEducationAutocomplete,
AccountEducationStatus,
AccountEducationVerification,
@@ -41,18 +49,40 @@ def _account() -> AccountSnapshot:
)
def _rate_limiter() -> Mock:
rate_limiter = Mock(spec=AccountEducationRateLimiter)
rate_limiter.is_rate_limited.return_value = False
return rate_limiter
def test_verify_reads_account_before_billing_gateway_call() -> None:
accounts = Mock(spec=AccountRepository)
accounts.get.return_value = _account()
education = Mock(spec=AccountEducationGateway)
education.verify.return_value = AccountEducationVerification(token="education-token")
service = AccountEducationService(accounts=accounts, education=education)
verification_rate_limiter = _rate_limiter()
activation_rate_limiter = _rate_limiter()
events: list[str] = []
verification_rate_limiter.is_rate_limited.side_effect = lambda _key: events.append("check") or False
verification_rate_limiter.increment_rate_limit.side_effect = lambda _key: events.append("increment")
education.verify.side_effect = lambda **_kwargs: (
events.append("verify") or AccountEducationVerification(token="education-token")
)
service = AccountEducationService(
accounts=accounts,
education=education,
verification_rate_limiter=verification_rate_limiter,
activation_rate_limiter=activation_rate_limiter,
)
result = service.verify(_context())
assert result == AccountEducationVerification(token="education-token")
assert events == ["check", "increment", "verify"]
accounts.get.assert_called_once_with("account-1")
education.verify.assert_called_once_with(account_id="account-1", email="student@example.edu")
verification_rate_limiter.is_rate_limited.assert_called_once_with("student@example.edu")
verification_rate_limiter.increment_rate_limit.assert_called_once_with("student@example.edu")
education.verify.assert_called_once_with(account_id="account-1")
activation_rate_limiter.is_rate_limited.assert_not_called()
def test_status_and_autocomplete_delegate_framework_neutral_contracts() -> None:
@@ -70,6 +100,8 @@ def test_status_and_autocomplete_delegate_framework_neutral_contracts() -> None:
service = AccountEducationService(
accounts=accounts,
education=education,
verification_rate_limiter=_rate_limiter(),
activation_rate_limiter=_rate_limiter(),
)
assert service.status(_context()) == status
@@ -82,10 +114,18 @@ def test_activate_delegates_account_and_workspace_context() -> None:
accounts = Mock(spec=AccountRepository)
accounts.get.return_value = _account()
education = Mock(spec=AccountEducationGateway)
education.activate.return_value = {"message": "success"}
activation = AccountEducationActivation(message="success")
verification_rate_limiter = _rate_limiter()
activation_rate_limiter = _rate_limiter()
events: list[str] = []
activation_rate_limiter.is_rate_limited.side_effect = lambda _key: events.append("check") or False
activation_rate_limiter.increment_rate_limit.side_effect = lambda _key: events.append("increment")
education.activate.side_effect = lambda **_kwargs: events.append("activate") or activation
service = AccountEducationService(
accounts=accounts,
education=education,
verification_rate_limiter=verification_rate_limiter,
activation_rate_limiter=activation_rate_limiter,
)
result = service.activate(
@@ -95,12 +135,60 @@ def test_activate_delegates_account_and_workspace_context() -> None:
role="Student",
)
assert result == {"message": "success"}
assert result == activation
assert events == ["check", "increment", "activate"]
activation_rate_limiter.is_rate_limited.assert_called_once_with("student@example.edu")
activation_rate_limiter.increment_rate_limit.assert_called_once_with("student@example.edu")
verification_rate_limiter.is_rate_limited.assert_not_called()
education.activate.assert_called_once_with(
account_id="account-1",
email="student@example.edu",
tenant_id="workspace-1",
token="education-token",
institution="Dify University",
role="Student",
)
def test_verify_rejects_rate_limited_request() -> None:
accounts = Mock(spec=AccountRepository)
accounts.get.return_value = _account()
education = Mock(spec=AccountEducationGateway)
verification_rate_limiter = _rate_limiter()
verification_rate_limiter.is_rate_limited.return_value = True
service = AccountEducationService(
accounts=accounts,
education=education,
verification_rate_limiter=verification_rate_limiter,
activation_rate_limiter=_rate_limiter(),
)
with pytest.raises(EducationRateLimitExceededError):
service.verify(_context())
verification_rate_limiter.increment_rate_limit.assert_not_called()
education.verify.assert_not_called()
def test_activate_rejects_rate_limited_request() -> None:
accounts = Mock(spec=AccountRepository)
accounts.get.return_value = _account()
education = Mock(spec=AccountEducationGateway)
activation_rate_limiter = _rate_limiter()
activation_rate_limiter.is_rate_limited.return_value = True
service = AccountEducationService(
accounts=accounts,
education=education,
verification_rate_limiter=_rate_limiter(),
activation_rate_limiter=activation_rate_limiter,
)
with pytest.raises(EducationRateLimitExceededError):
service.activate(
_context(),
token="education-token",
institution="Dify University",
role="Student",
)
activation_rate_limiter.increment_rate_limit.assert_not_called()
education.activate.assert_not_called()
@@ -697,7 +697,7 @@ class TestBillingServiceUsageCalculation:
"""Unit tests for usage calculation and credit management.
Tests cover:
- Feature plan usage information retrieval
- Quota information retrieval
- Credit addition (positive delta)
- Credit consumption (negative delta)
- Usage refunds
@@ -710,20 +710,6 @@ class TestBillingServiceUsageCalculation:
with patch.object(BillingService, "_send_request") as mock:
yield mock
def test_get_tenant_feature_plan_usage_info(self, mock_send_request):
"""Test retrieval of tenant feature plan usage information (legacy endpoint)."""
# Arrange
tenant_id = "tenant-123"
expected_response = {"features": {"trigger": {"used": 50, "limit": 100}, "workflow": {"used": 20, "limit": 50}}}
mock_send_request.return_value = expected_response
# Act
result = BillingService.get_tenant_feature_plan_usage_info(tenant_id)
# Assert
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id})
def test_get_quota_info(self):
"""Test retrieval of quota info from new endpoint."""
# Arrange
@@ -1043,15 +1029,7 @@ class TestBillingServiceQuotaOperations:
class TestBillingServiceRateLimitEnforcement:
"""Unit tests for rate limit enforcement mechanisms.
Tests cover:
- Compliance download rate limiting (4 requests per 60 seconds)
- Education verification rate limiting (10 requests per 60 seconds)
- Education activation rate limiting (10 requests per 60 seconds)
- Rate limit increment after successful operations
- Proper exception raising when limits are exceeded
"""
"""Unit tests for compliance download rate-limit enforcement."""
@pytest.fixture
def mock_send_request(self):
@@ -1121,109 +1099,6 @@ class TestBillingServiceRateLimitEnforcement:
mock_is_limited.assert_called_once_with(f"{account_id}:{tenant_id}")
mock_send_request.assert_not_called()
def test_education_verify_rate_limit_not_exceeded(self, mock_send_request):
"""Test education verification when rate limit is not exceeded."""
# Arrange
account_id = "account-123"
account_email = "student@university.edu"
expected_response = {"verified": True, "institution": "University"}
# Mock the rate limiter to return False (not limited)
with (
patch.object(
BillingService.EducationIdentity.verification_rate_limit, "is_rate_limited", return_value=False
) as mock_is_limited,
patch.object(
BillingService.EducationIdentity.verification_rate_limit, "increment_rate_limit"
) as mock_increment,
):
mock_send_request.return_value = expected_response
# Act
result = BillingService.EducationIdentity.verify(account_id, account_email)
# Assert
assert result == expected_response
mock_is_limited.assert_called_once_with(account_email)
mock_send_request.assert_called_once_with("GET", "/education/verify", params={"account_id": account_id})
mock_increment.assert_called_once_with(account_email)
def test_education_verify_rate_limit_exceeded(self, mock_send_request):
"""Test education verification when rate limit is exceeded."""
# Arrange
account_id = "account-123"
account_email = "student@university.edu"
# Import the error class to properly catch it
from controllers.console.error import EducationVerifyLimitError
# Mock the rate limiter to return True (rate limited)
with patch.object(
BillingService.EducationIdentity.verification_rate_limit, "is_rate_limited", return_value=True
) as mock_is_limited:
# Act & Assert
with pytest.raises(EducationVerifyLimitError):
BillingService.EducationIdentity.verify(account_id, account_email)
mock_is_limited.assert_called_once_with(account_email)
mock_send_request.assert_not_called()
def test_education_activate_rate_limit_not_exceeded(self, mock_send_request):
"""Test education activation when rate limit is not exceeded."""
# Arrange
account = _account(email="student@university.edu")
token = "verification-token"
institution = "MIT"
role = "student"
expected_response = {"result": "success", "activated": True}
# Mock the rate limiter to return False (not limited)
with (
patch.object(
BillingService.EducationIdentity.activation_rate_limit, "is_rate_limited", return_value=False
) as mock_is_limited,
patch.object(
BillingService.EducationIdentity.activation_rate_limit, "increment_rate_limit"
) as mock_increment,
):
mock_send_request.return_value = expected_response
# Act
result = BillingService.EducationIdentity.activate(account, token, institution, role)
# Assert
assert result == expected_response
mock_is_limited.assert_called_once_with(account.email)
mock_send_request.assert_called_once_with(
"POST",
"/education/",
json={"institution": institution, "token": token, "role": role},
params={"account_id": account.id, "curr_tenant_id": account.current_tenant_id},
)
mock_increment.assert_called_once_with(account.email)
def test_education_activate_rate_limit_exceeded(self, mock_send_request):
"""Test education activation when rate limit is exceeded."""
# Arrange
account = _account(email="student@university.edu")
token = "verification-token"
institution = "MIT"
role = "student"
# Import the error class to properly catch it
from controllers.console.error import EducationActivateLimitError
# Mock the rate limiter to return True (rate limited)
with patch.object(
BillingService.EducationIdentity.activation_rate_limit, "is_rate_limited", return_value=True
) as mock_is_limited:
# Act & Assert
with pytest.raises(EducationActivateLimitError):
BillingService.EducationIdentity.activate(account, token, institution, role)
mock_is_limited.assert_called_once_with(account.email)
mock_send_request.assert_not_called()
class TestBillingServiceEducationIdentity:
"""Unit tests for education identity verification and management.
@@ -1240,11 +1115,46 @@ class TestBillingServiceEducationIdentity:
with patch.object(BillingService, "_send_request") as mock:
yield mock
def test_education_verify(self, mock_send_request):
account_id = "account-123"
expected_response = {"token": "education-token"}
mock_send_request.return_value = expected_response
result = BillingService.EducationIdentity.verify(account_id)
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/education/verify", params={"account_id": account_id})
def test_education_activate(self, mock_send_request):
expected_response = {"message": "success"}
mock_send_request.return_value = expected_response
result = BillingService.EducationIdentity.activate(
account_id="account-123",
tenant_id="tenant-456",
token="verification-token",
institution="MIT",
role="student",
)
assert result == expected_response
mock_send_request.assert_called_once_with(
"POST",
"/education/",
json={"institution": "MIT", "token": "verification-token", "role": "student"},
params={"account_id": "account-123", "curr_tenant_id": "tenant-456"},
)
def test_education_status(self, mock_send_request):
"""Test checking education verification status."""
# Arrange
account_id = "account-123"
expected_response = {"verified": True, "institution": "MIT", "role": "student"}
expected_response = {
"result": True,
"is_student": True,
"expire_at": "2027-01-01T00:00:00Z",
"allow_refresh": False,
}
mock_send_request.return_value = expected_response
# Act
@@ -1261,10 +1171,9 @@ class TestBillingServiceEducationIdentity:
page = 0
limit = 20
expected_response = {
"institutions": [
{"name": "Massachusetts Institute of Technology", "domain": "mit.edu"},
{"name": "University of Massachusetts", "domain": "umass.edu"},
]
"data": ["Massachusetts Institute of Technology", "University of Massachusetts"],
"curr_page": 0,
"has_next": False,
}
mock_send_request.return_value = expected_response
@@ -1281,7 +1190,7 @@ class TestBillingServiceEducationIdentity:
"""Test education institution autocomplete with default parameters."""
# Arrange
keywords = "Stanford"
expected_response = {"institutions": [{"name": "Stanford University", "domain": "stanford.edu"}]}
expected_response = {"data": ["Stanford University"], "curr_page": 0, "has_next": False}
mock_send_request.return_value = expected_response
# Act
@@ -1315,7 +1224,7 @@ class TestBillingServiceAccountManagement:
"""Test account deletion."""
# Arrange
account_id = "account-123"
expected_response = {"result": "success", "deleted": True}
expected_response = {"message": "Account deleted successfully."}
mock_send_request.return_value = expected_response
# Act
@@ -1377,7 +1286,7 @@ class TestBillingServiceAccountManagement:
# Arrange
email = "user@example.com"
feedback = "Service was too expensive"
expected_response = {"result": "success"}
expected_response = {"message": "Reason added successfully."}
mock_send_request.return_value = expected_response
# Act
@@ -1437,7 +1346,7 @@ class TestBillingServicePartnerIntegration:
account_id = "account-123"
partner_key = "partner-xyz"
click_id = "click-789"
expected_response = {"result": "success", "synced": True}
expected_response = {"message": "Successfully synced partner tenants"}
mock_send_request.return_value = expected_response
# Act
@@ -1933,49 +1842,6 @@ class TestBillingServiceIntegrationScenarios:
assert mock_is_limited.call_count == 3
assert mock_increment.call_count == 3
def test_education_verification_and_activation_flow(self, mock_send_request):
"""Test complete education verification and activation flow."""
# Arrange
account = _account(email="student@mit.edu")
# Step 1: Search for institution
with (
patch.object(
BillingService.EducationIdentity.verification_rate_limit, "is_rate_limited", return_value=False
),
patch.object(BillingService.EducationIdentity.verification_rate_limit, "increment_rate_limit"),
):
mock_send_request.return_value = {
"institutions": [{"name": "Massachusetts Institute of Technology", "domain": "mit.edu"}]
}
institutions = BillingService.EducationIdentity.autocomplete("MIT")
assert len(institutions["institutions"]) > 0
# Step 2: Verify email
with (
patch.object(
BillingService.EducationIdentity.verification_rate_limit, "is_rate_limited", return_value=False
),
patch.object(BillingService.EducationIdentity.verification_rate_limit, "increment_rate_limit"),
):
mock_send_request.return_value = {"verified": True, "institution": "MIT"}
verify_result = BillingService.EducationIdentity.verify(account.id, account.email)
assert verify_result["verified"] is True
# Step 3: Check status
mock_send_request.return_value = {"verified": True, "institution": "MIT", "role": "student"}
status = BillingService.EducationIdentity.status(account.id)
assert status["verified"] is True
# Step 4: Activate education benefits
with (
patch.object(BillingService.EducationIdentity.activation_rate_limit, "is_rate_limited", return_value=False),
patch.object(BillingService.EducationIdentity.activation_rate_limit, "increment_rate_limit"),
):
mock_send_request.return_value = {"result": "success", "activated": True}
activate_result = BillingService.EducationIdentity.activate(account, "token-123", "MIT", "student")
assert activate_result["activated"] is True
class TestBillingServiceSubscriptionInfoDataType:
"""Unit tests for data type coercion in BillingService.get_info
@@ -87,6 +87,10 @@ export type EducationActivatePayload = {
token: string
}
export type EducationActivateResponse = {
message: string
}
export type EducationAutocompleteResponse = {
curr_page?: number | null
data?: Array<string>
@@ -305,9 +309,7 @@ export type PostAccountEducationData = {
}
export type PostAccountEducationResponses = {
200: {
[key: string]: unknown
}
200: EducationActivateResponse
}
export type PostAccountEducationResponse =
@@ -127,6 +127,13 @@ export const zEducationActivatePayload = z.object({
token: z.string(),
})
/**
* EducationActivateResponse
*/
export const zEducationActivateResponse = z.object({
message: z.string(),
})
/**
* EducationAutocompleteResponse
*/
@@ -309,7 +316,7 @@ export const zPostAccountEducationBody = zEducationActivatePayload
/**
* Success
*/
export const zPostAccountEducationResponse = z.record(z.string(), z.unknown())
export const zPostAccountEducationResponse = zEducationActivateResponse
export const zGetAccountEducationAutocompleteQuery = z.object({
keywords: z.string(),