mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user