Compare commits

..

7 Commits

Author SHA1 Message Date
-LAN-
a22cc5bc5e chore: Bump Dify version to 1.11.3 (#30903)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-01-13 17:49:13 +08:00
yyh
1fbdf6b465 refactor(web): setup status caching (#30798) 2026-01-13 16:59:49 +08:00
非法操作
491e1fd6a4 chore: case insensitive email (#29978)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-01-13 15:42:44 +08:00
青枕
0e33dfb5c2 fix: In the LLM model in dify, when a message is added, the first cli… (#29540)
Co-authored-by: 青枕 <qingzhen.ww@alibaba-inc.com>
2026-01-13 15:42:32 +08:00
lif
ea708e7a32 fix(web): add null check for SSE stream bufferObj to prevent TypeError (#30131)
Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:40:43 +08:00
非法操作
c09e29c3f8 chore: rename the migration file (#30893) 2026-01-13 15:26:41 +08:00
wangxiaolei
2d53ba8671 fix: fix object value is optional should skip validate (#30894) 2026-01-13 15:21:06 +08:00
58 changed files with 2339 additions and 4299 deletions

View File

@@ -35,7 +35,7 @@ from libs.rsa import generate_key_pair
from models import Tenant
from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, DatasetMetadataBinding, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.model import Account, App, AppAnnotationSetting, AppMode, Conversation, MessageAnnotation, UploadFile
from models.model import App, AppAnnotationSetting, AppMode, Conversation, MessageAnnotation, UploadFile
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider import Provider, ProviderModel
from models.provider_ids import DatasourceProviderID, ToolProviderID
@@ -64,8 +64,10 @@ def reset_password(email, new_password, password_confirm):
if str(new_password).strip() != str(password_confirm).strip():
click.echo(click.style("Passwords do not match.", fg="red"))
return
normalized_email = email.strip().lower()
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
account = session.query(Account).where(Account.email == email).one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session)
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -86,7 +88,7 @@ def reset_password(email, new_password, password_confirm):
base64_password_hashed = base64.b64encode(password_hashed).decode()
account.password = base64_password_hashed
account.password_salt = base64_salt
AccountService.reset_login_error_rate_limit(email)
AccountService.reset_login_error_rate_limit(normalized_email)
click.echo(click.style("Password reset successfully.", fg="green"))
@@ -102,20 +104,22 @@ def reset_email(email, new_email, email_confirm):
if str(new_email).strip() != str(email_confirm).strip():
click.echo(click.style("New emails do not match.", fg="red"))
return
normalized_new_email = new_email.strip().lower()
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
account = session.query(Account).where(Account.email == email).one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session)
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
return
try:
email_validate(new_email)
email_validate(normalized_new_email)
except:
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
return
account.email = new_email
account.email = normalized_new_email
click.echo(click.style("Email updated successfully.", fg="green"))
@@ -660,7 +664,7 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
return
# Create account
email = email.strip()
email = email.strip().lower()
if "@" not in email:
click.echo(click.style("Invalid email address.", fg="red"))

View File

@@ -63,10 +63,9 @@ class ActivateCheckApi(Resource):
args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
workspaceId = args.workspace_id
reg_email = args.email
token = args.token
invitation = RegisterService.get_invitation_if_token_valid(workspaceId, reg_email, token)
invitation = RegisterService.get_invitation_with_case_fallback(workspaceId, args.email, token)
if invitation:
data = invitation.get("data", {})
tenant = invitation.get("tenant", None)
@@ -100,11 +99,12 @@ class ActivateApi(Resource):
def post(self):
args = ActivatePayload.model_validate(console_ns.payload)
invitation = RegisterService.get_invitation_if_token_valid(args.workspace_id, args.email, args.token)
normalized_request_email = args.email.lower() if args.email else None
invitation = RegisterService.get_invitation_with_case_fallback(args.workspace_id, args.email, args.token)
if invitation is None:
raise AlreadyActivateError()
RegisterService.revoke_token(args.workspace_id, args.email, args.token)
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
account = invitation["account"]
account.name = args.name

View File

@@ -1,7 +1,6 @@
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
@@ -62,6 +61,7 @@ class EmailRegisterSendEmailApi(Resource):
@email_register_enabled
def post(self):
args = EmailRegisterSendPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
@@ -70,13 +70,12 @@ class EmailRegisterSendEmailApi(Resource):
if args.language in languages:
language = args.language
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args.email):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
raise AccountInFreezeError()
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=args.email)).scalar_one_or_none()
token = None
token = AccountService.send_email_register_email(email=args.email, account=account, language=language)
account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session)
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
return {"result": "success", "data": token}
@@ -88,9 +87,9 @@ class EmailRegisterCheckApi(Resource):
def post(self):
args = EmailRegisterValidityPayload.model_validate(console_ns.payload)
user_email = args.email
user_email = args.email.lower()
is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(args.email)
is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(user_email)
if is_email_register_error_rate_limit:
raise EmailRegisterLimitError()
@@ -98,11 +97,14 @@ class EmailRegisterCheckApi(Resource):
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
token_email = token_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.get("code"):
AccountService.add_email_register_error_rate_limit(args.email)
AccountService.add_email_register_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
@@ -113,8 +115,8 @@ class EmailRegisterCheckApi(Resource):
user_email, code=args.code, additional_data={"phase": "register"}
)
AccountService.reset_email_register_error_rate_limit(args.email)
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
AccountService.reset_email_register_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
@console_ns.route("/email-register")
@@ -141,22 +143,23 @@ class EmailRegisterResetApi(Resource):
AccountService.revoke_email_register_token(args.token)
email = register_data.get("email", "")
normalized_email = email.lower()
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(email, session=session)
if account:
raise EmailAlreadyInUseError()
else:
account = self._create_new_account(email, args.password_confirm)
account = self._create_new_account(normalized_email, args.password_confirm)
if not account:
raise AccountNotFoundError()
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(email)
AccountService.reset_login_error_rate_limit(normalized_email)
return {"result": "success", "data": token_pair.model_dump()}
def _create_new_account(self, email, password) -> Account | None:
def _create_new_account(self, email: str, password: str) -> Account | None:
# Create new account if allowed
account = None
try:

View File

@@ -4,7 +4,6 @@ import secrets
from flask import request
from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.console import console_ns
@@ -21,7 +20,6 @@ from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from libs.helper import EmailStr, extract_remote_ip
from libs.password import hash_password, valid_password
from models import Account
from services.account_service import AccountService, TenantService
from services.feature_service import FeatureService
@@ -76,6 +74,7 @@ class ForgotPasswordSendEmailApi(Resource):
@email_password_login_enabled
def post(self):
args = ForgotPasswordSendPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
@@ -87,11 +86,11 @@ class ForgotPasswordSendEmailApi(Resource):
language = "en-US"
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=args.email)).scalar_one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session)
token = AccountService.send_reset_password_email(
account=account,
email=args.email,
email=normalized_email,
language=language,
is_allow_register=FeatureService.get_system_features().is_allow_register,
)
@@ -122,9 +121,9 @@ class ForgotPasswordCheckApi(Resource):
def post(self):
args = ForgotPasswordCheckPayload.model_validate(console_ns.payload)
user_email = args.email
user_email = args.email.lower()
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(args.email)
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(user_email)
if is_forgot_password_error_rate_limit:
raise EmailPasswordResetLimitError()
@@ -132,11 +131,16 @@ class ForgotPasswordCheckApi(Resource):
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
token_email = token_data.get("email")
if not isinstance(token_email, str):
raise InvalidEmailError()
normalized_token_email = token_email.lower()
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.get("code"):
AccountService.add_forgot_password_error_rate_limit(args.email)
AccountService.add_forgot_password_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
@@ -144,11 +148,11 @@ class ForgotPasswordCheckApi(Resource):
# Refresh token data by generating a new token
_, new_token = AccountService.generate_reset_password_token(
user_email, code=args.code, additional_data={"phase": "reset"}
token_email, code=args.code, additional_data={"phase": "reset"}
)
AccountService.reset_forgot_password_error_rate_limit(args.email)
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
AccountService.reset_forgot_password_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
@console_ns.route("/forgot-password/resets")
@@ -187,9 +191,8 @@ class ForgotPasswordResetApi(Resource):
password_hashed = hash_password(args.new_password, salt)
email = reset_data.get("email", "")
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(email, session=session)
if account:
self._update_existing_account(account, password_hashed, salt, session)

View File

@@ -90,32 +90,38 @@ class LoginApi(Resource):
def post(self):
"""Authenticate user and login."""
args = LoginPayload.model_validate(console_ns.payload)
request_email = args.email
normalized_email = request_email.lower()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args.email):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
raise AccountInFreezeError()
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args.email)
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
if is_login_error_rate_limit:
raise EmailPasswordLoginLimitError()
invite_token = args.invite_token
invitation_data: dict[str, Any] | None = None
if args.invite_token:
invitation_data = RegisterService.get_invitation_if_token_valid(None, args.email, args.invite_token)
if invite_token:
invitation_data = RegisterService.get_invitation_with_case_fallback(None, request_email, invite_token)
if invitation_data is None:
invite_token = None
try:
if invitation_data:
data = invitation_data.get("data", {})
invitee_email = data.get("email") if data else None
if invitee_email != args.email:
invitee_email_normalized = invitee_email.lower() if isinstance(invitee_email, str) else invitee_email
if invitee_email_normalized != normalized_email:
raise InvalidEmailError()
account = AccountService.authenticate(args.email, args.password, args.invite_token)
else:
account = AccountService.authenticate(args.email, args.password)
account = _authenticate_account_with_case_fallback(
request_email, normalized_email, args.password, invite_token
)
except services.errors.account.AccountLoginError:
raise AccountBannedError()
except services.errors.account.AccountPasswordError:
AccountService.add_login_error_rate_limit(args.email)
raise AuthenticationFailedError()
except services.errors.account.AccountPasswordError as exc:
AccountService.add_login_error_rate_limit(normalized_email)
raise AuthenticationFailedError() from exc
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account)
if len(tenants) == 0:
@@ -130,7 +136,7 @@ class LoginApi(Resource):
}
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(args.email)
AccountService.reset_login_error_rate_limit(normalized_email)
# Create response with cookies instead of returning tokens in body
response = make_response({"result": "success"})
@@ -170,18 +176,19 @@ class ResetPasswordSendEmailApi(Resource):
@console_ns.expect(console_ns.models[EmailPayload.__name__])
def post(self):
args = EmailPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
if args.language is not None and args.language == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
try:
account = AccountService.get_user_through_email(args.email)
account = _get_account_with_case_fallback(args.email)
except AccountRegisterError:
raise AccountInFreezeError()
token = AccountService.send_reset_password_email(
email=args.email,
email=normalized_email,
account=account,
language=language,
is_allow_register=FeatureService.get_system_features().is_allow_register,
@@ -196,6 +203,7 @@ class EmailCodeLoginSendEmailApi(Resource):
@console_ns.expect(console_ns.models[EmailPayload.__name__])
def post(self):
args = EmailPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
@@ -206,13 +214,13 @@ class EmailCodeLoginSendEmailApi(Resource):
else:
language = "en-US"
try:
account = AccountService.get_user_through_email(args.email)
account = _get_account_with_case_fallback(args.email)
except AccountRegisterError:
raise AccountInFreezeError()
if account is None:
if FeatureService.get_system_features().is_allow_register:
token = AccountService.send_email_code_login_email(email=args.email, language=language)
token = AccountService.send_email_code_login_email(email=normalized_email, language=language)
else:
raise AccountNotFound()
else:
@@ -229,14 +237,17 @@ class EmailCodeLoginApi(Resource):
def post(self):
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
user_email = args.email
original_email = args.email
user_email = original_email.lower()
language = args.language
token_data = AccountService.get_email_code_login_data(args.token)
if token_data is None:
raise InvalidTokenError()
if token_data["email"] != args.email:
token_email = token_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if normalized_token_email != user_email:
raise InvalidEmailError()
if token_data["code"] != args.code:
@@ -244,7 +255,7 @@ class EmailCodeLoginApi(Resource):
AccountService.revoke_email_code_login_token(args.token)
try:
account = AccountService.get_user_through_email(user_email)
account = _get_account_with_case_fallback(original_email)
except AccountRegisterError:
raise AccountInFreezeError()
if account:
@@ -275,7 +286,7 @@ class EmailCodeLoginApi(Resource):
except WorkspacesLimitExceededError:
raise WorkspacesLimitExceeded()
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(args.email)
AccountService.reset_login_error_rate_limit(user_email)
# Create response with cookies instead of returning tokens in body
response = make_response({"result": "success"})
@@ -309,3 +320,22 @@ class RefreshTokenApi(Resource):
return response
except Exception as e:
return {"result": "fail", "message": str(e)}, 401
def _get_account_with_case_fallback(email: str):
account = AccountService.get_user_through_email(email)
if account or email == email.lower():
return account
return AccountService.get_user_through_email(email.lower())
def _authenticate_account_with_case_fallback(
original_email: str, normalized_email: str, password: str, invite_token: str | None
):
try:
return AccountService.authenticate(original_email, password, invite_token)
except services.errors.account.AccountPasswordError:
if original_email == normalized_email:
raise
return AccountService.authenticate(normalized_email, password, invite_token)

View File

@@ -3,7 +3,6 @@ import logging
import httpx
from flask import current_app, redirect, request
from flask_restx import Resource
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Unauthorized
@@ -118,7 +117,10 @@ class OAuthCallback(Resource):
invitation = RegisterService.get_invitation_by_token(token=invite_token)
if invitation:
invitation_email = invitation.get("email", None)
if invitation_email != user_info.email:
invitation_email_normalized = (
invitation_email.lower() if isinstance(invitation_email, str) else invitation_email
)
if invitation_email_normalized != user_info.email.lower():
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Invalid invitation token.")
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}")
@@ -175,7 +177,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
if not account:
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=user_info.email)).scalar_one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(user_info.email, session=session)
return account
@@ -197,9 +199,10 @@ def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account,
tenant_was_created.send(new_tenant)
if not account:
normalized_email = user_info.email.lower()
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
@@ -210,7 +213,11 @@ def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account,
raise AccountRegisterError(description=("Invalid email or password"))
account_name = user_info.name or "Dify"
account = RegisterService.register(
email=user_info.email, name=account_name, password=None, open_id=user_info.id, provider=provider
email=normalized_email,
name=account_name,
password=None,
open_id=user_info.id,
provider=provider,
)
# Set interface language

View File

@@ -84,10 +84,11 @@ class SetupApi(Resource):
raise NotInitValidateError()
args = SetupRequestPayload.model_validate(console_ns.payload)
normalized_email = args.email.lower()
# setup
RegisterService.setup(
email=args.email,
email=normalized_email,
name=args.name,
password=args.password,
ip_address=extract_remote_ip(request),

View File

@@ -41,7 +41,7 @@ from fields.member_fields import account_fields
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, TimestampField, extract_remote_ip, timezone
from libs.login import current_account_with_tenant, login_required
from models import Account, AccountIntegrate, InvitationCode
from models import AccountIntegrate, InvitationCode
from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
@@ -536,7 +536,8 @@ class ChangeEmailSendEmailApi(Resource):
else:
language = "en-US"
account = None
user_email = args.email
user_email = None
email_for_sending = args.email.lower()
if args.phase is not None and args.phase == "new_email":
if args.token is None:
raise InvalidTokenError()
@@ -546,16 +547,24 @@ class ChangeEmailSendEmailApi(Resource):
raise InvalidTokenError()
user_email = reset_data.get("email", "")
if user_email != current_user.email:
if user_email.lower() != current_user.email.lower():
raise InvalidEmailError()
user_email = current_user.email
else:
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=args.email)).scalar_one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session)
if account is None:
raise AccountNotFound()
email_for_sending = account.email
user_email = account.email
token = AccountService.send_change_email_email(
account=account, email=args.email, old_email=user_email, language=language, phase=args.phase
account=account,
email=email_for_sending,
old_email=user_email,
language=language,
phase=args.phase,
)
return {"result": "success", "data": token}
@@ -571,9 +580,9 @@ class ChangeEmailCheckApi(Resource):
payload = console_ns.payload or {}
args = ChangeEmailValidityPayload.model_validate(payload)
user_email = args.email
user_email = args.email.lower()
is_change_email_error_rate_limit = AccountService.is_change_email_error_rate_limit(args.email)
is_change_email_error_rate_limit = AccountService.is_change_email_error_rate_limit(user_email)
if is_change_email_error_rate_limit:
raise EmailChangeLimitError()
@@ -581,11 +590,13 @@ class ChangeEmailCheckApi(Resource):
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
token_email = token_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.get("code"):
AccountService.add_change_email_error_rate_limit(args.email)
AccountService.add_change_email_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
@@ -596,8 +607,8 @@ class ChangeEmailCheckApi(Resource):
user_email, code=args.code, old_email=token_data.get("old_email"), additional_data={}
)
AccountService.reset_change_email_error_rate_limit(args.email)
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
AccountService.reset_change_email_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
@console_ns.route("/account/change-email/reset")
@@ -611,11 +622,12 @@ class ChangeEmailResetApi(Resource):
def post(self):
payload = console_ns.payload or {}
args = ChangeEmailResetPayload.model_validate(payload)
normalized_new_email = args.new_email.lower()
if AccountService.is_account_in_freeze(args.new_email):
if AccountService.is_account_in_freeze(normalized_new_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(args.new_email):
if not AccountService.check_email_unique(normalized_new_email):
raise EmailAlreadyInUseError()
reset_data = AccountService.get_change_email_data(args.token)
@@ -626,13 +638,13 @@ class ChangeEmailResetApi(Resource):
old_email = reset_data.get("old_email", "")
current_user, _ = current_account_with_tenant()
if current_user.email != old_email:
if current_user.email.lower() != old_email.lower():
raise AccountNotFound()
updated_account = AccountService.update_account_email(current_user, email=args.new_email)
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
AccountService.send_change_email_completed_notify_email(
email=args.new_email,
email=normalized_new_email,
)
return updated_account
@@ -645,8 +657,9 @@ class CheckEmailUnique(Resource):
def post(self):
payload = console_ns.payload or {}
args = CheckEmailUniquePayload.model_validate(payload)
if AccountService.is_account_in_freeze(args.email):
normalized_email = args.email.lower()
if AccountService.is_account_in_freeze(normalized_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(args.email):
if not AccountService.check_email_unique(normalized_email):
raise EmailAlreadyInUseError()
return {"result": "success"}

View File

@@ -116,26 +116,31 @@ class MemberInviteEmailApi(Resource):
raise WorkspaceMembersLimitExceeded()
for invitee_email in invitee_emails:
normalized_invitee_email = invitee_email.lower()
try:
if not inviter.current_tenant:
raise ValueError("No current tenant")
token = RegisterService.invite_new_member(
inviter.current_tenant, invitee_email, interface_language, role=invitee_role, inviter=inviter
tenant=inviter.current_tenant,
email=invitee_email,
language=interface_language,
role=invitee_role,
inviter=inviter,
)
encoded_invitee_email = parse.quote(invitee_email)
encoded_invitee_email = parse.quote(normalized_invitee_email)
invitation_results.append(
{
"status": "success",
"email": invitee_email,
"email": normalized_invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
)
except AccountAlreadyInTenantError:
invitation_results.append(
{"status": "success", "email": invitee_email, "url": f"{console_web_url}/signin"}
{"status": "success", "email": normalized_invitee_email, "url": f"{console_web_url}/signin"}
)
except Exception as e:
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
invitation_results.append({"status": "failed", "email": normalized_invitee_email, "message": str(e)})
return {
"result": "success",

View File

@@ -4,7 +4,6 @@ import secrets
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_models
@@ -22,7 +21,7 @@ from controllers.web import web_ns
from extensions.ext_database import db
from libs.helper import EmailStr, extract_remote_ip
from libs.password import hash_password, valid_password
from models import Account
from models.account import Account
from services.account_service import AccountService
@@ -70,6 +69,9 @@ class ForgotPasswordSendEmailApi(Resource):
def post(self):
payload = ForgotPasswordSendPayload.model_validate(web_ns.payload or {})
request_email = payload.email
normalized_email = request_email.lower()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
@@ -80,12 +82,12 @@ class ForgotPasswordSendEmailApi(Resource):
language = "en-US"
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=payload.email)).scalar_one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(request_email, session=session)
token = None
if account is None:
raise AuthenticationFailedError()
else:
token = AccountService.send_reset_password_email(account=account, email=payload.email, language=language)
token = AccountService.send_reset_password_email(account=account, email=normalized_email, language=language)
return {"result": "success", "data": token}
@@ -104,9 +106,9 @@ class ForgotPasswordCheckApi(Resource):
def post(self):
payload = ForgotPasswordCheckPayload.model_validate(web_ns.payload or {})
user_email = payload.email
user_email = payload.email.lower()
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(payload.email)
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(user_email)
if is_forgot_password_error_rate_limit:
raise EmailPasswordResetLimitError()
@@ -114,11 +116,16 @@ class ForgotPasswordCheckApi(Resource):
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
token_email = token_data.get("email")
if not isinstance(token_email, str):
raise InvalidEmailError()
normalized_token_email = token_email.lower()
if user_email != normalized_token_email:
raise InvalidEmailError()
if payload.code != token_data.get("code"):
AccountService.add_forgot_password_error_rate_limit(payload.email)
AccountService.add_forgot_password_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
@@ -126,11 +133,11 @@ class ForgotPasswordCheckApi(Resource):
# Refresh token data by generating a new token
_, new_token = AccountService.generate_reset_password_token(
user_email, code=payload.code, additional_data={"phase": "reset"}
token_email, code=payload.code, additional_data={"phase": "reset"}
)
AccountService.reset_forgot_password_error_rate_limit(payload.email)
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
AccountService.reset_forgot_password_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
@web_ns.route("/forgot-password/resets")
@@ -174,7 +181,7 @@ class ForgotPasswordResetApi(Resource):
email = reset_data.get("email", "")
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none()
account = AccountService.get_account_by_email_with_case_fallback(email, session=session)
if account:
self._update_existing_account(account, password_hashed, salt, session)

View File

@@ -197,25 +197,29 @@ class EmailCodeLoginApi(Resource):
)
args = parser.parse_args()
user_email = args["email"]
user_email = args["email"].lower()
token_data = WebAppAuthService.get_email_code_login_data(args["token"])
if token_data is None:
raise InvalidTokenError()
if token_data["email"] != args["email"]:
token_email = token_data.get("email")
if not isinstance(token_email, str):
raise InvalidEmailError()
normalized_token_email = token_email.lower()
if normalized_token_email != user_email:
raise InvalidEmailError()
if token_data["code"] != args["code"]:
raise EmailCodeError()
WebAppAuthService.revoke_email_code_login_token(args["token"])
account = WebAppAuthService.get_user_through_email(user_email)
account = WebAppAuthService.get_user_through_email(token_email)
if not account:
raise AuthenticationFailedError()
token = WebAppAuthService.login(account=account)
AccountService.reset_login_error_rate_limit(args["email"])
AccountService.reset_login_error_rate_limit(user_email)
response = make_response({"result": "success", "data": {"access_token": token}})
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
return response

View File

@@ -189,7 +189,7 @@ class BaseAppGenerator:
elif value == 0:
value = False
case VariableEntityType.JSON_OBJECT:
if not isinstance(value, dict):
if value and not isinstance(value, dict):
raise ValueError(f"{variable_entity.variable} in input form must be a dict")
case _:
raise AssertionError("this statement should be unreachable.")

View File

@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.11.2"
version = "1.11.3"
requires-python = ">=3.11,<3.13"
dependencies = [

View File

@@ -8,7 +8,7 @@ from hashlib import sha256
from typing import Any, cast
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Unauthorized
@@ -748,6 +748,21 @@ class AccountService:
cls.email_code_login_rate_limiter.increment_rate_limit(email)
return token
@staticmethod
def get_account_by_email_with_case_fallback(email: str, session: Session | None = None) -> Account | None:
"""
Retrieve an account by email and fall back to the lowercase email if the original lookup fails.
This keeps backward compatibility for older records that stored uppercase emails while the
rest of the system gradually normalizes new inputs.
"""
query_session = session or db.session
account = query_session.execute(select(Account).filter_by(email=email)).scalar_one_or_none()
if account or email == email.lower():
return account
return query_session.execute(select(Account).filter_by(email=email.lower())).scalar_one_or_none()
@classmethod
def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None:
return TokenManager.get_token_data(token, "email_code_login")
@@ -1363,16 +1378,22 @@ class RegisterService:
if not inviter:
raise ValueError("Inviter is required")
normalized_email = email.lower()
"""Invite new member"""
with Session(db.engine) as session:
account = session.query(Account).filter_by(email=email).first()
account = AccountService.get_account_by_email_with_case_fallback(email, session=session)
if not account:
TenantService.check_member_permission(tenant, inviter, None, "add")
name = email.split("@")[0]
name = normalized_email.split("@")[0]
account = cls.register(
email=email, name=name, language=language, status=AccountStatus.PENDING, is_setup=True
email=normalized_email,
name=name,
language=language,
status=AccountStatus.PENDING,
is_setup=True,
)
# Create new tenant member for invited tenant
TenantService.create_tenant_member(tenant, account, role)
@@ -1394,7 +1415,7 @@ class RegisterService:
# send email
send_invite_member_mail_task.delay(
language=language,
to=email,
to=account.email,
token=token,
inviter_name=inviter.name if inviter else "Dify",
workspace_name=tenant.name,
@@ -1493,6 +1514,16 @@ class RegisterService:
invitation: dict = json.loads(data)
return invitation
@classmethod
def get_invitation_with_case_fallback(
cls, workspace_id: str | None, email: str | None, token: str
) -> dict[str, Any] | None:
invitation = cls.get_invitation_if_token_valid(workspace_id, email, token)
if invitation or not email or email == email.lower():
return invitation
normalized_email = email.lower()
return cls.get_invitation_if_token_valid(workspace_id, normalized_email, token)
def _generate_refresh_token(length: int = 64):
token = secrets.token_hex(length)

View File

@@ -12,6 +12,7 @@ from libs.passport import PassportService
from libs.password import compare_password
from models import Account, AccountStatus
from models.model import App, EndUser, Site
from services.account_service import AccountService
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
@@ -32,7 +33,7 @@ class WebAppAuthService:
@staticmethod
def authenticate(email: str, password: str) -> Account:
"""authenticate account with email and password"""
account = db.session.query(Account).filter_by(email=email).first()
account = AccountService.get_account_by_email_with_case_fallback(email)
if not account:
raise AccountNotFoundError()
@@ -52,7 +53,7 @@ class WebAppAuthService:
@classmethod
def get_user_through_email(cls, email: str):
account = db.session.query(Account).where(Account.email == email).first()
account = AccountService.get_account_by_email_with_case_fallback(email)
if not account:
return None

View File

@@ -40,7 +40,7 @@ class TestActivateCheckApi:
"tenant": tenant,
}
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
def test_check_valid_invitation_token(self, mock_get_invitation, app, mock_invitation):
"""
Test checking valid invitation token.
@@ -66,7 +66,7 @@ class TestActivateCheckApi:
assert response["data"]["workspace_id"] == "workspace-123"
assert response["data"]["email"] == "invitee@example.com"
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
def test_check_invalid_invitation_token(self, mock_get_invitation, app):
"""
Test checking invalid invitation token.
@@ -88,7 +88,7 @@ class TestActivateCheckApi:
# Assert
assert response["is_valid"] is False
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
def test_check_token_without_workspace_id(self, mock_get_invitation, app, mock_invitation):
"""
Test checking token without workspace ID.
@@ -109,7 +109,7 @@ class TestActivateCheckApi:
assert response["is_valid"] is True
mock_get_invitation.assert_called_once_with(None, "invitee@example.com", "valid_token")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
def test_check_token_without_email(self, mock_get_invitation, app, mock_invitation):
"""
Test checking token without email parameter.
@@ -130,6 +130,20 @@ class TestActivateCheckApi:
assert response["is_valid"] is True
mock_get_invitation.assert_called_once_with("workspace-123", None, "valid_token")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
def test_check_token_normalizes_email_to_lowercase(self, mock_get_invitation, app, mock_invitation):
"""Ensure token validation uses lowercase emails."""
mock_get_invitation.return_value = mock_invitation
with app.test_request_context(
"/activate/check?workspace_id=workspace-123&email=Invitee@Example.com&token=valid_token"
):
api = ActivateCheckApi()
response = api.get()
assert response["is_valid"] is True
mock_get_invitation.assert_called_once_with("workspace-123", "Invitee@Example.com", "valid_token")
class TestActivateApi:
"""Test cases for account activation endpoint."""
@@ -212,7 +226,7 @@ class TestActivateApi:
mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token")
mock_db.session.commit.assert_called_once()
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
def test_activation_with_invalid_token(self, mock_get_invitation, app):
"""
Test account activation with invalid token.
@@ -241,7 +255,7 @@ class TestActivateApi:
with pytest.raises(AlreadyActivateError):
api.post()
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
def test_activation_sets_interface_theme(
@@ -290,7 +304,7 @@ class TestActivateApi:
("es-ES", "Europe/Madrid"),
],
)
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
def test_activation_with_different_locales(
@@ -336,7 +350,7 @@ class TestActivateApi:
assert mock_account.interface_language == language
assert mock_account.timezone == timezone
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
def test_activation_returns_success_response(
@@ -376,7 +390,7 @@ class TestActivateApi:
# Assert
assert response == {"result": "success"}
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
def test_activation_without_workspace_id(
@@ -415,3 +429,37 @@ class TestActivateApi:
# Assert
assert response["result"] == "success"
mock_revoke_token.assert_called_once_with(None, "invitee@example.com", "valid_token")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
def test_activation_normalizes_email_before_lookup(
self,
mock_db,
mock_revoke_token,
mock_get_invitation,
app,
mock_invitation,
mock_account,
):
"""Ensure uppercase emails are normalized before lookup and revocation."""
mock_get_invitation.return_value = mock_invitation
with app.test_request_context(
"/activate",
method="POST",
json={
"workspace_id": "workspace-123",
"email": "Invitee@Example.com",
"token": "valid_token",
"name": "John Doe",
"interface_language": "en-US",
"timezone": "UTC",
},
):
api = ActivateApi()
response = api.post()
assert response["result"] == "success"
mock_get_invitation.assert_called_once_with("workspace-123", "Invitee@Example.com", "valid_token")
mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token")

View File

@@ -34,7 +34,7 @@ class TestAuthenticationSecurity:
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_invalid_email_with_registration_allowed(
self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db
):
@@ -67,7 +67,7 @@ class TestAuthenticationSecurity:
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_wrong_password_returns_error(
self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_db
):
@@ -100,7 +100,7 @@ class TestAuthenticationSecurity:
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_invalid_email_with_registration_disabled(
self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db
):

View File

@@ -0,0 +1,177 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.console.auth.email_register import (
EmailRegisterCheckApi,
EmailRegisterResetApi,
EmailRegisterSendEmailApi,
)
from services.account_service import AccountService
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
return flask_app
class TestEmailRegisterSendEmailApi:
@patch("controllers.console.auth.email_register.Session")
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.send_email_register_email")
@patch("controllers.console.auth.email_register.BillingService.is_email_in_freeze")
@patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_send_email_normalizes_and_falls_back(
self,
mock_extract_ip,
mock_is_email_send_ip_limit,
mock_is_freeze,
mock_send_mail,
mock_get_account,
mock_session_cls,
app,
):
mock_send_mail.return_value = "token-123"
mock_is_freeze.return_value = False
mock_account = MagicMock()
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
mock_get_account.return_value = mock_account
feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
with (
patch("controllers.console.auth.email_register.db", SimpleNamespace(engine="engine")),
patch("controllers.console.auth.email_register.dify_config", SimpleNamespace(BILLING_ENABLED=True)),
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register/send-email",
method="POST",
json={"email": "Invitee@Example.com", "language": "en-US"},
):
response = EmailRegisterSendEmailApi().post()
assert response == {"result": "success", "data": "token-123"}
mock_is_freeze.assert_called_once_with("invitee@example.com")
mock_send_mail.assert_called_once_with(email="invitee@example.com", account=mock_account, language="en-US")
mock_get_account.assert_called_once_with("Invitee@Example.com", session=mock_session)
mock_extract_ip.assert_called_once()
mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1")
class TestEmailRegisterCheckApi:
@patch("controllers.console.auth.email_register.AccountService.reset_email_register_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.generate_email_register_token")
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
@patch("controllers.console.auth.email_register.AccountService.add_email_register_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
@patch("controllers.console.auth.email_register.AccountService.is_email_register_error_rate_limit")
def test_validity_normalizes_email_before_checks(
self,
mock_rate_limit_check,
mock_get_data,
mock_add_rate,
mock_revoke,
mock_generate_token,
mock_reset_rate,
app,
):
mock_rate_limit_check.return_value = False
mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
with (
patch("controllers.console.auth.email_register.db", SimpleNamespace(engine="engine")),
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register/validity",
method="POST",
json={"email": "User@Example.com", "code": "4321", "token": "token-123"},
):
response = EmailRegisterCheckApi().post()
assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"}
mock_rate_limit_check.assert_called_once_with("user@example.com")
mock_generate_token.assert_called_once_with(
"user@example.com", code="4321", additional_data={"phase": "register"}
)
mock_reset_rate.assert_called_once_with("user@example.com")
mock_add_rate.assert_not_called()
mock_revoke.assert_called_once_with("token-123")
class TestEmailRegisterResetApi:
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.login")
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
@patch("controllers.console.auth.email_register.Session")
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_reset_creates_account_with_normalized_email(
self,
mock_extract_ip,
mock_get_data,
mock_revoke_token,
mock_get_account,
mock_session_cls,
mock_create_account,
mock_login,
mock_reset_login_rate,
app,
):
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
mock_create_account.return_value = MagicMock()
token_pair = MagicMock()
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
mock_login.return_value = token_pair
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
mock_get_account.return_value = None
feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
with (
patch("controllers.console.auth.email_register.db", SimpleNamespace(engine="engine")),
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register",
method="POST",
json={"token": "token-123", "new_password": "ValidPass123!", "password_confirm": "ValidPass123!"},
):
response = EmailRegisterResetApi().post()
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
mock_create_account.assert_called_once_with("invitee@example.com", "ValidPass123!")
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_extract_ip.assert_called_once()
mock_get_account.assert_called_once_with("Invitee@Example.com", session=mock_session)
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup():
mock_session = MagicMock()
first_query = MagicMock()
first_query.scalar_one_or_none.return_value = None
expected_account = MagicMock()
second_query = MagicMock()
second_query.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first_query, second_query]
account = AccountService.get_account_by_email_with_case_fallback("Case@Test.com", session=mock_session)
assert account is expected_account
assert mock_session.execute.call_count == 2

View File

@@ -0,0 +1,176 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.console.auth.forgot_password import (
ForgotPasswordCheckApi,
ForgotPasswordResetApi,
ForgotPasswordSendEmailApi,
)
from services.account_service import AccountService
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
return flask_app
class TestForgotPasswordSendEmailApi:
@patch("controllers.console.auth.forgot_password.Session")
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.auth.forgot_password.extract_remote_ip", return_value="127.0.0.1")
def test_send_normalizes_email(
self,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_get_account,
mock_session_cls,
app,
):
mock_account = MagicMock()
mock_get_account.return_value = mock_account
mock_send_email.return_value = "token-123"
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
wraps_features = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
controller_features = SimpleNamespace(is_allow_register=True)
with (
patch("controllers.console.auth.forgot_password.db", SimpleNamespace(engine="engine")),
patch(
"controllers.console.auth.forgot_password.FeatureService.get_system_features",
return_value=controller_features,
),
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
):
with app.test_request_context(
"/forgot-password",
method="POST",
json={"email": "User@Example.com", "language": "zh-Hans"},
):
response = ForgotPasswordSendEmailApi().post()
assert response == {"result": "success", "data": "token-123"}
mock_get_account.assert_called_once_with("User@Example.com", session=mock_session)
mock_send_email.assert_called_once_with(
account=mock_account,
email="user@example.com",
language="zh-Hans",
is_allow_register=True,
)
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_extract_ip.assert_called_once()
class TestForgotPasswordCheckApi:
@patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
@patch("controllers.console.auth.forgot_password.AccountService.generate_reset_password_token")
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.console.auth.forgot_password.AccountService.add_forgot_password_error_rate_limit")
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
def test_check_normalizes_email(
self,
mock_rate_limit_check,
mock_get_data,
mock_add_rate,
mock_revoke_token,
mock_generate_token,
mock_reset_rate,
app,
):
mock_rate_limit_check.return_value = False
mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
wraps_features = SimpleNamespace(enable_email_password_login=True)
with (
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
):
with app.test_request_context(
"/forgot-password/validity",
method="POST",
json={"email": "ADMIN@Example.com", "code": "4321", "token": "token-123"},
):
response = ForgotPasswordCheckApi().post()
assert response == {"is_valid": True, "email": "admin@example.com", "token": "new-token"}
mock_rate_limit_check.assert_called_once_with("admin@example.com")
mock_generate_token.assert_called_once_with(
"Admin@Example.com",
code="4321",
additional_data={"phase": "reset"},
)
mock_reset_rate.assert_called_once_with("admin@example.com")
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-123")
class TestForgotPasswordResetApi:
@patch("controllers.console.auth.forgot_password.ForgotPasswordResetApi._update_existing_account")
@patch("controllers.console.auth.forgot_password.Session")
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
def test_reset_fetches_account_with_original_email(
self,
mock_get_reset_data,
mock_revoke_token,
mock_get_account,
mock_session_cls,
mock_update_account,
app,
):
mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com"}
mock_account = MagicMock()
mock_get_account.return_value = mock_account
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
wraps_features = SimpleNamespace(enable_email_password_login=True)
with (
patch("controllers.console.auth.forgot_password.db", SimpleNamespace(engine="engine")),
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
):
with app.test_request_context(
"/forgot-password/resets",
method="POST",
json={
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
},
):
response = ForgotPasswordResetApi().post()
assert response == {"result": "success"}
mock_get_reset_data.assert_called_once_with("token-123")
mock_revoke_token.assert_called_once_with("token-123")
mock_get_account.assert_called_once_with("User@Example.com", session=mock_session)
mock_update_account.assert_called_once()
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup():
mock_session = MagicMock()
first_query = MagicMock()
first_query.scalar_one_or_none.return_value = None
expected_account = MagicMock()
second_query = MagicMock()
second_query.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first_query, second_query]
account = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=mock_session)
assert account is expected_account
assert mock_session.execute.call_count == 2

View File

@@ -76,7 +76,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.AccountService.login")
@@ -120,7 +120,7 @@ class TestLoginApi:
response = login_api.post()
# Assert
mock_authenticate.assert_called_once_with("test@example.com", "ValidPass123!")
mock_authenticate.assert_called_once_with("test@example.com", "ValidPass123!", None)
mock_login.assert_called_once()
mock_reset_rate_limit.assert_called_once_with("test@example.com")
assert response.json["result"] == "success"
@@ -128,7 +128,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.AccountService.login")
@@ -182,7 +182,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app):
"""
Test login rejection when rate limit is exceeded.
@@ -230,7 +230,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit")
def test_login_fails_with_invalid_credentials(
@@ -269,7 +269,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
def test_login_fails_for_banned_account(
self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app
@@ -298,7 +298,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_system_features")
@@ -343,7 +343,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_invitation_email_mismatch(self, mock_get_invitation, mock_is_rate_limit, mock_db, app):
"""
Test login failure when invitation email doesn't match login email.
@@ -371,6 +371,52 @@ class TestLoginApi:
with pytest.raises(InvalidEmailError):
login_api.post()
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.AccountService.login")
@patch("controllers.console.auth.login.AccountService.reset_login_error_rate_limit")
def test_login_retries_with_lowercase_email(
self,
mock_reset_rate_limit,
mock_login_service,
mock_get_tenants,
mock_add_rate_limit,
mock_authenticate,
mock_get_invitation,
mock_is_rate_limit,
mock_db,
app,
mock_account,
mock_token_pair,
):
"""Test that login retries with lowercase email when uppercase lookup fails."""
mock_db.session.query.return_value.first.return_value = MagicMock()
mock_is_rate_limit.return_value = False
mock_get_invitation.return_value = None
mock_authenticate.side_effect = [AccountPasswordError("Invalid"), mock_account]
mock_get_tenants.return_value = [MagicMock()]
mock_login_service.return_value = mock_token_pair
with app.test_request_context(
"/login",
method="POST",
json={"email": "Upper@Example.com", "password": encode_password("ValidPass123!")},
):
response = LoginApi().post()
assert response.json["result"] == "success"
assert mock_authenticate.call_args_list == [
(("Upper@Example.com", "ValidPass123!", None), {}),
(("upper@example.com", "ValidPass123!", None), {}),
]
mock_add_rate_limit.assert_not_called()
mock_reset_rate_limit.assert_called_once_with("upper@example.com")
class TestLogoutApi:
"""Test cases for the LogoutApi endpoint."""

View File

@@ -12,6 +12,7 @@ from controllers.console.auth.oauth import (
)
from libs.oauth import OAuthUserInfo
from models.account import AccountStatus
from services.account_service import AccountService
from services.errors.account import AccountRegisterError
@@ -215,6 +216,34 @@ class TestOAuthCallback:
assert status_code == 400
assert response["error"] == expected_error
@patch("controllers.console.auth.oauth.dify_config")
@patch("controllers.console.auth.oauth.get_oauth_providers")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.redirect")
def test_invitation_comparison_is_case_insensitive(
self,
mock_redirect,
mock_register_service,
mock_get_providers,
mock_config,
resource,
app,
oauth_setup,
):
mock_config.CONSOLE_WEB_URL = "http://localhost:3000"
oauth_setup["provider"].get_user_info.return_value = OAuthUserInfo(
id="123", name="Test User", email="User@Example.com"
)
mock_get_providers.return_value = {"github": oauth_setup["provider"]}
mock_register_service.is_valid_invite_token.return_value = True
mock_register_service.get_invitation_by_token.return_value = {"email": "user@example.com"}
with app.test_request_context("/auth/oauth/github/callback?code=test_code&state=invite123"):
resource.get("github")
mock_register_service.get_invitation_by_token.assert_called_once_with(token="invite123")
mock_redirect.assert_called_once_with("http://localhost:3000/signin/invite-settings?invite_token=invite123")
@pytest.mark.parametrize(
("account_status", "expected_redirect"),
[
@@ -395,12 +424,12 @@ class TestAccountGeneration:
account.name = "Test User"
return account
@patch("controllers.console.auth.oauth.db")
@patch("controllers.console.auth.oauth.Account")
@patch("controllers.console.auth.oauth.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.oauth.Session")
@patch("controllers.console.auth.oauth.select")
@patch("controllers.console.auth.oauth.Account")
@patch("controllers.console.auth.oauth.db")
def test_should_get_account_by_openid_or_email(
self, mock_select, mock_session, mock_account_model, mock_db, user_info, mock_account
self, mock_db, mock_account_model, mock_session, mock_get_account, user_info, mock_account
):
# Mock db.engine for Session creation
mock_db.engine = MagicMock()
@@ -410,15 +439,31 @@ class TestAccountGeneration:
result = _get_account_by_openid_or_email("github", user_info)
assert result == mock_account
mock_account_model.get_by_openid.assert_called_once_with("github", "123")
mock_get_account.assert_not_called()
# Test fallback to email
# Test fallback to email lookup
mock_account_model.get_by_openid.return_value = None
mock_session_instance = MagicMock()
mock_session_instance.execute.return_value.scalar_one_or_none.return_value = mock_account
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_get_account.return_value = mock_account
result = _get_account_by_openid_or_email("github", user_info)
assert result == mock_account
mock_get_account.assert_called_once_with(user_info.email, session=mock_session_instance)
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup(self):
mock_session = MagicMock()
first_result = MagicMock()
first_result.scalar_one_or_none.return_value = None
expected_account = MagicMock()
second_result = MagicMock()
second_result.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first_result, second_result]
result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com", session=mock_session)
assert result == expected_account
assert mock_session.execute.call_count == 2
@pytest.mark.parametrize(
("allow_register", "existing_account", "should_create"),
@@ -466,6 +511,35 @@ class TestAccountGeneration:
mock_register_service.register.assert_called_once_with(
email="test@example.com", name="Test User", password=None, open_id="123", provider="github"
)
else:
mock_register_service.register.assert_not_called()
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.AccountService")
@patch("controllers.console.auth.oauth.TenantService")
@patch("controllers.console.auth.oauth.db")
def test_should_register_with_lowercase_email(
self,
mock_db,
mock_tenant_service,
mock_account_service,
mock_register_service,
mock_feature_service,
mock_get_account,
app,
):
user_info = OAuthUserInfo(id="123", name="Test User", email="Upper@Example.com")
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_register_service.register.return_value = MagicMock()
with app.test_request_context(headers={"Accept-Language": "en-US"}):
_generate_account("github", user_info)
mock_register_service.register.assert_called_once_with(
email="upper@example.com", name="Test User", password=None, open_id="123", provider="github"
)
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email")
@patch("controllers.console.auth.oauth.TenantService")

View File

@@ -28,6 +28,22 @@ from controllers.console.auth.forgot_password import (
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
@pytest.fixture(autouse=True)
def _mock_forgot_password_session():
with patch("controllers.console.auth.forgot_password.Session") as mock_session_cls:
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
mock_session_cls.return_value.__exit__.return_value = None
yield mock_session
@pytest.fixture(autouse=True)
def _mock_forgot_password_db():
with patch("controllers.console.auth.forgot_password.db") as mock_db:
mock_db.engine = MagicMock()
yield mock_db
class TestForgotPasswordSendEmailApi:
"""Test cases for sending password reset emails."""
@@ -47,20 +63,16 @@ class TestForgotPasswordSendEmailApi:
return account
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.forgot_password.db")
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
@patch("controllers.console.auth.forgot_password.Session")
@patch("controllers.console.auth.forgot_password.select")
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
@patch("controllers.console.auth.forgot_password.FeatureService.get_system_features")
def test_send_reset_email_success(
self,
mock_get_features,
mock_send_email,
mock_select,
mock_session,
mock_get_account,
mock_is_ip_limit,
mock_forgot_db,
mock_wraps_db,
app,
mock_account,
@@ -75,11 +87,8 @@ class TestForgotPasswordSendEmailApi:
"""
# Arrange
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
mock_forgot_db.engine = MagicMock()
mock_is_ip_limit.return_value = False
mock_session_instance = MagicMock()
mock_session_instance.execute.return_value.scalar_one_or_none.return_value = mock_account
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_get_account.return_value = mock_account
mock_send_email.return_value = "reset_token_123"
mock_get_features.return_value.is_allow_register = True
@@ -125,20 +134,16 @@ class TestForgotPasswordSendEmailApi:
],
)
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.forgot_password.db")
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
@patch("controllers.console.auth.forgot_password.Session")
@patch("controllers.console.auth.forgot_password.select")
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
@patch("controllers.console.auth.forgot_password.FeatureService.get_system_features")
def test_send_reset_email_language_handling(
self,
mock_get_features,
mock_send_email,
mock_select,
mock_session,
mock_get_account,
mock_is_ip_limit,
mock_forgot_db,
mock_wraps_db,
app,
mock_account,
@@ -154,11 +159,8 @@ class TestForgotPasswordSendEmailApi:
"""
# Arrange
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
mock_forgot_db.engine = MagicMock()
mock_is_ip_limit.return_value = False
mock_session_instance = MagicMock()
mock_session_instance.execute.return_value.scalar_one_or_none.return_value = mock_account
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_get_account.return_value = mock_account
mock_send_email.return_value = "token"
mock_get_features.return_value.is_allow_register = True
@@ -229,8 +231,46 @@ class TestForgotPasswordCheckApi:
assert response["email"] == "test@example.com"
assert response["token"] == "new_token"
mock_revoke_token.assert_called_once_with("old_token")
mock_generate_token.assert_called_once_with(
"test@example.com", code="123456", additional_data={"phase": "reset"}
)
mock_reset_rate_limit.assert_called_once_with("test@example.com")
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.console.auth.forgot_password.AccountService.generate_reset_password_token")
@patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
def test_verify_code_preserves_token_email_case(
self,
mock_reset_rate_limit,
mock_generate_token,
mock_revoke_token,
mock_get_data,
mock_is_rate_limit,
mock_db,
app,
):
mock_db.session.query.return_value.first.return_value = MagicMock()
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {"email": "User@Example.com", "code": "999888"}
mock_generate_token.return_value = (None, "fresh-token")
with app.test_request_context(
"/forgot-password/validity",
method="POST",
json={"email": "user@example.com", "code": "999888", "token": "upper_token"},
):
response = ForgotPasswordCheckApi().post()
assert response == {"is_valid": True, "email": "user@example.com", "token": "fresh-token"}
mock_generate_token.assert_called_once_with(
"User@Example.com", code="999888", additional_data={"phase": "reset"}
)
mock_revoke_token.assert_called_once_with("upper_token")
mock_reset_rate_limit.assert_called_once_with("user@example.com")
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
def test_verify_code_rate_limited(self, mock_is_rate_limit, mock_db, app):
@@ -355,20 +395,16 @@ class TestForgotPasswordResetApi:
return account
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.forgot_password.db")
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.console.auth.forgot_password.Session")
@patch("controllers.console.auth.forgot_password.select")
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.forgot_password.TenantService.get_join_tenants")
def test_reset_password_success(
self,
mock_get_tenants,
mock_select,
mock_session,
mock_get_account,
mock_revoke_token,
mock_get_data,
mock_forgot_db,
mock_wraps_db,
app,
mock_account,
@@ -383,11 +419,8 @@ class TestForgotPasswordResetApi:
"""
# Arrange
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
mock_forgot_db.engine = MagicMock()
mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"}
mock_session_instance = MagicMock()
mock_session_instance.execute.return_value.scalar_one_or_none.return_value = mock_account
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_get_account.return_value = mock_account
mock_get_tenants.return_value = [MagicMock()]
# Act
@@ -475,13 +508,11 @@ class TestForgotPasswordResetApi:
api.post()
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.forgot_password.db")
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.console.auth.forgot_password.Session")
@patch("controllers.console.auth.forgot_password.select")
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
def test_reset_password_account_not_found(
self, mock_select, mock_session, mock_revoke_token, mock_get_data, mock_forgot_db, mock_wraps_db, app
self, mock_get_account, mock_revoke_token, mock_get_data, mock_wraps_db, app
):
"""
Test password reset for non-existent account.
@@ -491,11 +522,8 @@ class TestForgotPasswordResetApi:
"""
# Arrange
mock_wraps_db.session.query.return_value.first.return_value = MagicMock()
mock_forgot_db.engine = MagicMock()
mock_get_data.return_value = {"email": "nonexistent@example.com", "phase": "reset"}
mock_session_instance = MagicMock()
mock_session_instance.execute.return_value.scalar_one_or_none.return_value = None
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_get_account.return_value = None
# Act & Assert
with app.test_request_context(

View File

@@ -0,0 +1,39 @@
from types import SimpleNamespace
from unittest.mock import patch
from controllers.console.setup import SetupApi
class TestSetupApi:
def test_post_lowercases_email_before_register(self):
"""Ensure setup registration normalizes email casing."""
payload = {
"email": "Admin@Example.com",
"name": "Admin User",
"password": "ValidPass123!",
"language": "en-US",
}
setup_api = SetupApi(api=None)
mock_console_ns = SimpleNamespace(payload=payload)
with (
patch("controllers.console.setup.console_ns", mock_console_ns),
patch("controllers.console.setup.get_setup_status", return_value=False),
patch("controllers.console.setup.TenantService.get_tenant_count", return_value=0),
patch("controllers.console.setup.get_init_validate_status", return_value=True),
patch("controllers.console.setup.extract_remote_ip", return_value="127.0.0.1"),
patch("controllers.console.setup.request", object()),
patch("controllers.console.setup.RegisterService.setup") as mock_register,
):
response, status = setup_api.post()
assert response == {"result": "success"}
assert status == 201
mock_register.assert_called_once_with(
email="admin@example.com",
name=payload["name"],
password=payload["password"],
ip_address="127.0.0.1",
language=payload["language"],
)

View File

@@ -0,0 +1,247 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, g
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
ChangeEmailCheckApi,
ChangeEmailResetApi,
ChangeEmailSendEmailApi,
CheckEmailUnique,
)
from models import Account
from services.account_service import AccountService
@pytest.fixture
def app():
app = Flask(__name__)
app.config["TESTING"] = True
app.config["RESTX_MASK_HEADER"] = "X-Fields"
app.login_manager = SimpleNamespace(_load_user=lambda: None)
return app
def _mock_wraps_db(mock_db):
mock_db.session.query.return_value.first.return_value = MagicMock()
def _build_account(email: str, account_id: str = "acc", tenant: object | None = None) -> Account:
tenant_obj = tenant if tenant is not None else SimpleNamespace(id="tenant-id")
account = Account(name=account_id, email=email)
account.email = email
account.id = account_id
account.status = "active"
account._current_tenant = tenant_obj
return account
def _set_logged_in_user(account: Account):
g._login_user = account
g._current_tenant = account.current_tenant
class TestChangeEmailSend:
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_normalize_new_email_phase(
self,
mock_features,
mock_csrf,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_get_change_data,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = {"email": "current@example.com"}
mock_send_email.return_value = "token-abc"
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "New@Example.com", "language": "en-US", "phase": "new_email", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailSendEmailApi().post()
assert response == {"result": "success", "data": "token-abc"}
mock_send_email.assert_called_once_with(
account=None,
email="new@example.com",
old_email="current@example.com",
language="en-US",
phase="new_email",
)
mock_extract_ip.assert_called_once()
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_csrf.assert_called_once()
class TestChangeEmailValidity:
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit")
@patch("controllers.console.workspace.account.AccountService.generate_change_email_token")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_validate_with_normalized_email(
self,
mock_features,
mock_csrf,
mock_is_rate_limit,
mock_get_data,
mock_add_rate,
mock_revoke_token,
mock_generate_token,
mock_reset_rate,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("user@example.com", "acc2")
mock_current_account.return_value = (mock_account, None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {"email": "user@example.com", "code": "1234", "old_email": "old@example.com"}
mock_generate_token.return_value = (None, "new-token")
with app.test_request_context(
"/account/change-email/validity",
method="POST",
json={"email": "User@Example.com", "code": "1234", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailCheckApi().post()
assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"}
mock_is_rate_limit.assert_called_once_with("user@example.com")
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-123")
mock_generate_token.assert_called_once_with(
"user@example.com", code="1234", old_email="old@example.com", additional_data={}
)
mock_reset_rate.assert_called_once_with("user@example.com")
mock_csrf.assert_called_once()
class TestChangeEmailReset:
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_normalize_new_email_before_update(
self,
mock_features,
mock_csrf,
mock_is_freeze,
mock_check_unique,
mock_get_data,
mock_revoke_token,
mock_update_account,
mock_send_notify,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
current_user = _build_account("old@example.com", "acc3")
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {"old_email": "OLD@example.com"}
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
mock_update_account.return_value = mock_account_after_update
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "New@Example.com", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
ChangeEmailResetApi().post()
mock_is_freeze.assert_called_once_with("new@example.com")
mock_check_unique.assert_called_once_with("new@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_update_account.assert_called_once_with(current_user, email="new@example.com")
mock_send_notify.assert_called_once_with(email="new@example.com")
mock_csrf.assert_called_once()
class TestAccountDeletionFeedback:
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.BillingService.update_account_deletion_feedback")
def test_should_normalize_feedback_email(self, mock_update, mock_db, app):
_mock_wraps_db(mock_db)
with app.test_request_context(
"/account/delete/feedback",
method="POST",
json={"email": "User@Example.com", "feedback": "test"},
):
response = AccountDeleteUpdateFeedbackApi().post()
assert response == {"result": "success"}
mock_update.assert_called_once_with("User@Example.com", "test")
class TestCheckEmailUnique:
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
def test_should_normalize_email(self, mock_is_freeze, mock_check_unique, mock_db, app):
_mock_wraps_db(mock_db)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
with app.test_request_context(
"/account/change-email/check-email-unique",
method="POST",
json={"email": "Case@Test.com"},
):
response = CheckEmailUnique().post()
assert response == {"result": "success"}
mock_is_freeze.assert_called_once_with("case@test.com")
mock_check_unique.assert_called_once_with("case@test.com")
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup():
session = MagicMock()
first = MagicMock()
first.scalar_one_or_none.return_value = None
second = MagicMock()
expected_account = MagicMock()
second.scalar_one_or_none.return_value = expected_account
session.execute.side_effect = [first, second]
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=session)
assert result is expected_account
assert session.execute.call_count == 2

View File

@@ -0,0 +1,82 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, g
from controllers.console.workspace.members import MemberInviteEmailApi
from models.account import Account, TenantAccountRole
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
flask_app.login_manager = SimpleNamespace(_load_user=lambda: None)
return flask_app
def _mock_wraps_db(mock_db):
mock_db.session.query.return_value.first.return_value = MagicMock()
def _build_feature_flags():
placeholder_quota = SimpleNamespace(limit=0, size=0)
workspace_members = SimpleNamespace(is_available=lambda count: True)
return SimpleNamespace(
billing=SimpleNamespace(enabled=False),
workspace_members=workspace_members,
members=placeholder_quota,
apps=placeholder_quota,
vector_space=placeholder_quota,
documents_upload_quota=placeholder_quota,
annotation_quota_limit=placeholder_quota,
)
class TestMemberInviteEmailApi:
@patch("controllers.console.workspace.members.FeatureService.get_features")
@patch("controllers.console.workspace.members.RegisterService.invite_new_member")
@patch("controllers.console.workspace.members.current_account_with_tenant")
@patch("controllers.console.wraps.db")
@patch("libs.login.check_csrf_token", return_value=None)
def test_invite_normalizes_emails(
self,
mock_csrf,
mock_db,
mock_current_account,
mock_invite_member,
mock_get_features,
app,
):
_mock_wraps_db(mock_db)
mock_get_features.return_value = _build_feature_flags()
mock_invite_member.return_value = "token-abc"
tenant = SimpleNamespace(id="tenant-1", name="Test Tenant")
inviter = SimpleNamespace(email="Owner@Example.com", current_tenant=tenant, status="active")
mock_current_account.return_value = (inviter, tenant.id)
with patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"):
with app.test_request_context(
"/workspaces/current/members/invite-email",
method="POST",
json={"emails": ["User@Example.com"], "role": TenantAccountRole.EDITOR.value, "language": "en-US"},
):
account = Account(name="tester", email="tester@example.com")
account._current_tenant = tenant
g._login_user = account
g._current_tenant = tenant
response, status_code = MemberInviteEmailApi().post()
assert status_code == 201
assert response["invitation_results"][0]["email"] == "user@example.com"
assert mock_invite_member.call_count == 1
call_args = mock_invite_member.call_args
assert call_args.kwargs["tenant"] == tenant
assert call_args.kwargs["email"] == "User@Example.com"
assert call_args.kwargs["language"] == "en-US"
assert call_args.kwargs["role"] == TenantAccountRole.EDITOR
assert call_args.kwargs["inviter"] == inviter
mock_csrf.assert_called_once()

View File

@@ -1,195 +0,0 @@
"""Unit tests for controllers.web.forgot_password endpoints."""
from __future__ import annotations
import base64
import builtins
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from flask.views import MethodView
# Ensure flask_restx.api finds MethodView during import.
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
def _load_controller_module():
"""Import controllers.web.forgot_password using a stub package."""
import importlib
import importlib.util
import sys
from types import ModuleType
parent_module_name = "controllers.web"
module_name = f"{parent_module_name}.forgot_password"
if parent_module_name not in sys.modules:
from flask_restx import Namespace
stub = ModuleType(parent_module_name)
stub.__file__ = "controllers/web/__init__.py"
stub.__path__ = ["controllers/web"]
stub.__package__ = "controllers"
stub.__spec__ = importlib.util.spec_from_loader(parent_module_name, loader=None, is_package=True)
stub.web_ns = Namespace("web", description="Web API", path="/")
sys.modules[parent_module_name] = stub
return importlib.import_module(module_name)
forgot_password_module = _load_controller_module()
ForgotPasswordCheckApi = forgot_password_module.ForgotPasswordCheckApi
ForgotPasswordResetApi = forgot_password_module.ForgotPasswordResetApi
ForgotPasswordSendEmailApi = forgot_password_module.ForgotPasswordSendEmailApi
@pytest.fixture
def app() -> Flask:
"""Configure a minimal Flask app for request contexts."""
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture(autouse=True)
def _enable_web_endpoint_guards():
"""Stub enterprise and feature toggles used by route decorators."""
features = SimpleNamespace(enable_email_password_login=True)
with (
patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=features),
):
yield
@pytest.fixture(autouse=True)
def _mock_controller_db():
"""Replace controller-level db reference with a simple stub."""
fake_db = SimpleNamespace(engine=MagicMock(name="engine"))
fake_wraps_db = SimpleNamespace(
session=MagicMock(query=MagicMock(return_value=MagicMock(first=MagicMock(return_value=True))))
)
with (
patch("controllers.web.forgot_password.db", fake_db),
patch("controllers.console.wraps.db", fake_wraps_db),
):
yield fake_db
@patch("controllers.web.forgot_password.AccountService.send_reset_password_email", return_value="reset-token")
@patch("controllers.web.forgot_password.Session")
@patch("controllers.web.forgot_password.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.web.forgot_password.extract_remote_ip", return_value="203.0.113.10")
def test_send_reset_email_success(
mock_extract_ip: MagicMock,
mock_is_ip_limit: MagicMock,
mock_session: MagicMock,
mock_send_email: MagicMock,
app: Flask,
):
"""POST /forgot-password returns token when email exists and limits allow."""
mock_account = MagicMock()
session_ctx = MagicMock()
mock_session.return_value.__enter__.return_value = session_ctx
session_ctx.execute.return_value.scalar_one_or_none.return_value = mock_account
with app.test_request_context(
"/forgot-password",
method="POST",
json={"email": "user@example.com"},
):
response = ForgotPasswordSendEmailApi().post()
assert response == {"result": "success", "data": "reset-token"}
mock_extract_ip.assert_called_once()
mock_is_ip_limit.assert_called_once_with("203.0.113.10")
mock_send_email.assert_called_once_with(account=mock_account, email="user@example.com", language="en-US")
@patch("controllers.web.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
@patch("controllers.web.forgot_password.AccountService.generate_reset_password_token", return_value=({}, "new-token"))
@patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.web.forgot_password.AccountService.is_forgot_password_error_rate_limit", return_value=False)
def test_check_token_success(
mock_is_rate_limited: MagicMock,
mock_get_data: MagicMock,
mock_revoke: MagicMock,
mock_generate: MagicMock,
mock_reset_limit: MagicMock,
app: Flask,
):
"""POST /forgot-password/validity validates the code and refreshes token."""
mock_get_data.return_value = {"email": "user@example.com", "code": "123456"}
with app.test_request_context(
"/forgot-password/validity",
method="POST",
json={"email": "user@example.com", "code": "123456", "token": "old-token"},
):
response = ForgotPasswordCheckApi().post()
assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"}
mock_is_rate_limited.assert_called_once_with("user@example.com")
mock_get_data.assert_called_once_with("old-token")
mock_revoke.assert_called_once_with("old-token")
mock_generate.assert_called_once_with(
"user@example.com",
code="123456",
additional_data={"phase": "reset"},
)
mock_reset_limit.assert_called_once_with("user@example.com")
@patch("controllers.web.forgot_password.hash_password", return_value=b"hashed-value")
@patch("controllers.web.forgot_password.secrets.token_bytes", return_value=b"0123456789abcdef")
@patch("controllers.web.forgot_password.Session")
@patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.get_reset_password_data")
def test_reset_password_success(
mock_get_data: MagicMock,
mock_revoke_token: MagicMock,
mock_session: MagicMock,
mock_token_bytes: MagicMock,
mock_hash_password: MagicMock,
app: Flask,
):
"""POST /forgot-password/resets updates the stored password when token is valid."""
mock_get_data.return_value = {"email": "user@example.com", "phase": "reset"}
account = MagicMock()
session_ctx = MagicMock()
mock_session.return_value.__enter__.return_value = session_ctx
session_ctx.execute.return_value.scalar_one_or_none.return_value = account
with app.test_request_context(
"/forgot-password/resets",
method="POST",
json={
"token": "reset-token",
"new_password": "StrongPass123!",
"password_confirm": "StrongPass123!",
},
):
response = ForgotPasswordResetApi().post()
assert response == {"result": "success"}
mock_get_data.assert_called_once_with("reset-token")
mock_revoke_token.assert_called_once_with("reset-token")
mock_token_bytes.assert_called_once_with(16)
mock_hash_password.assert_called_once_with("StrongPass123!", b"0123456789abcdef")
expected_password = base64.b64encode(b"hashed-value").decode()
assert account.password == expected_password
expected_salt = base64.b64encode(b"0123456789abcdef").decode()
assert account.password_salt == expected_salt
session_ctx.commit.assert_called_once()

View File

@@ -0,0 +1,226 @@
import base64
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.forgot_password import (
ForgotPasswordCheckApi,
ForgotPasswordResetApi,
ForgotPasswordSendEmailApi,
)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture(autouse=True)
def _patch_wraps():
wraps_features = SimpleNamespace(enable_email_password_login=True)
dify_settings = SimpleNamespace(ENTERPRISE_ENABLED=True, EDITION="CLOUD")
with (
patch("controllers.console.wraps.db") as mock_db,
patch("controllers.console.wraps.dify_config", dify_settings),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
):
mock_db.session.query.return_value.first.return_value = MagicMock()
yield
class TestForgotPasswordSendEmailApi:
@patch("controllers.web.forgot_password.AccountService.send_reset_password_email")
@patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.web.forgot_password.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.web.forgot_password.extract_remote_ip", return_value="127.0.0.1")
@patch("controllers.web.forgot_password.Session")
def test_should_normalize_email_before_sending(
self,
mock_session_cls,
mock_extract_ip,
mock_rate_limit,
mock_get_account,
mock_send_mail,
app,
):
mock_account = MagicMock()
mock_get_account.return_value = mock_account
mock_send_mail.return_value = "token-123"
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")):
with app.test_request_context(
"/web/forgot-password",
method="POST",
json={"email": "User@Example.com", "language": "zh-Hans"},
):
response = ForgotPasswordSendEmailApi().post()
assert response == {"result": "success", "data": "token-123"}
mock_get_account.assert_called_once_with("User@Example.com", session=mock_session)
mock_send_mail.assert_called_once_with(account=mock_account, email="user@example.com", language="zh-Hans")
mock_extract_ip.assert_called_once()
mock_rate_limit.assert_called_once_with("127.0.0.1")
class TestForgotPasswordCheckApi:
@patch("controllers.web.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
@patch("controllers.web.forgot_password.AccountService.generate_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.add_forgot_password_error_rate_limit")
@patch("controllers.web.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.web.forgot_password.AccountService.is_forgot_password_error_rate_limit")
def test_should_normalize_email_for_validity_checks(
self,
mock_is_rate_limit,
mock_get_data,
mock_add_rate,
mock_revoke_token,
mock_generate_token,
mock_reset_rate,
app,
):
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {"email": "User@Example.com", "code": "1234"}
mock_generate_token.return_value = (None, "new-token")
with app.test_request_context(
"/web/forgot-password/validity",
method="POST",
json={"email": "User@Example.com", "code": "1234", "token": "token-123"},
):
response = ForgotPasswordCheckApi().post()
assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"}
mock_is_rate_limit.assert_called_once_with("user@example.com")
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-123")
mock_generate_token.assert_called_once_with(
"User@Example.com",
code="1234",
additional_data={"phase": "reset"},
)
mock_reset_rate.assert_called_once_with("user@example.com")
@patch("controllers.web.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
@patch("controllers.web.forgot_password.AccountService.generate_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.web.forgot_password.AccountService.is_forgot_password_error_rate_limit")
def test_should_preserve_token_email_case(
self,
mock_is_rate_limit,
mock_get_data,
mock_revoke_token,
mock_generate_token,
mock_reset_rate,
app,
):
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {"email": "MixedCase@Example.com", "code": "5678"}
mock_generate_token.return_value = (None, "fresh-token")
with app.test_request_context(
"/web/forgot-password/validity",
method="POST",
json={"email": "mixedcase@example.com", "code": "5678", "token": "token-upper"},
):
response = ForgotPasswordCheckApi().post()
assert response == {"is_valid": True, "email": "mixedcase@example.com", "token": "fresh-token"}
mock_generate_token.assert_called_once_with(
"MixedCase@Example.com",
code="5678",
additional_data={"phase": "reset"},
)
mock_revoke_token.assert_called_once_with("token-upper")
mock_reset_rate.assert_called_once_with("mixedcase@example.com")
class TestForgotPasswordResetApi:
@patch("controllers.web.forgot_password.ForgotPasswordResetApi._update_existing_account")
@patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.web.forgot_password.Session")
@patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.get_reset_password_data")
def test_should_fetch_account_with_fallback(
self,
mock_get_reset_data,
mock_revoke_token,
mock_session_cls,
mock_get_account,
mock_update_account,
app,
):
mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com", "code": "1234"}
mock_account = MagicMock()
mock_get_account.return_value = mock_account
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")):
with app.test_request_context(
"/web/forgot-password/resets",
method="POST",
json={
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
},
):
response = ForgotPasswordResetApi().post()
assert response == {"result": "success"}
mock_get_account.assert_called_once_with("User@Example.com", session=mock_session)
mock_update_account.assert_called_once()
mock_revoke_token.assert_called_once_with("token-123")
@patch("controllers.web.forgot_password.hash_password", return_value=b"hashed-value")
@patch("controllers.web.forgot_password.secrets.token_bytes", return_value=b"0123456789abcdef")
@patch("controllers.web.forgot_password.Session")
@patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token")
@patch("controllers.web.forgot_password.AccountService.get_reset_password_data")
@patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback")
def test_should_update_password_and_commit(
self,
mock_get_account,
mock_get_reset_data,
mock_revoke_token,
mock_session_cls,
mock_token_bytes,
mock_hash_password,
app,
):
mock_get_reset_data.return_value = {"phase": "reset", "email": "user@example.com"}
account = MagicMock()
mock_get_account.return_value = account
mock_session = MagicMock()
mock_session_cls.return_value.__enter__.return_value = mock_session
with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")):
with app.test_request_context(
"/web/forgot-password/resets",
method="POST",
json={
"token": "reset-token",
"new_password": "StrongPass123!",
"password_confirm": "StrongPass123!",
},
):
response = ForgotPasswordResetApi().post()
assert response == {"result": "success"}
mock_get_reset_data.assert_called_once_with("reset-token")
mock_revoke_token.assert_called_once_with("reset-token")
mock_token_bytes.assert_called_once_with(16)
mock_hash_password.assert_called_once_with("StrongPass123!", b"0123456789abcdef")
expected_password = base64.b64encode(b"hashed-value").decode()
assert account.password == expected_password
expected_salt = base64.b64encode(b"0123456789abcdef").decode()
assert account.password_salt == expected_salt
mock_session.commit.assert_called_once()

View File

@@ -0,0 +1,91 @@
import base64
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi
def encode_code(code: str) -> str:
return base64.b64encode(code.encode("utf-8")).decode()
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture(autouse=True)
def _patch_wraps():
wraps_features = SimpleNamespace(enable_email_password_login=True)
console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, EDITION="CLOUD")
web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True)
with (
patch("controllers.console.wraps.db") as mock_db,
patch("controllers.console.wraps.dify_config", console_dify),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
patch("controllers.web.login.dify_config", web_dify),
):
mock_db.session.query.return_value.first.return_value = MagicMock()
yield
class TestEmailCodeLoginSendEmailApi:
@patch("controllers.web.login.WebAppAuthService.send_email_code_login_email")
@patch("controllers.web.login.WebAppAuthService.get_user_through_email")
def test_should_fetch_account_with_original_email(
self,
mock_get_user,
mock_send_email,
app,
):
mock_account = MagicMock()
mock_get_user.return_value = mock_account
mock_send_email.return_value = "token-123"
with app.test_request_context(
"/web/email-code-login",
method="POST",
json={"email": "User@Example.com", "language": "en-US"},
):
response = EmailCodeLoginSendEmailApi().post()
assert response == {"result": "success", "data": "token-123"}
mock_get_user.assert_called_once_with("User@Example.com")
mock_send_email.assert_called_once_with(account=mock_account, language="en-US")
class TestEmailCodeLoginApi:
@patch("controllers.web.login.AccountService.reset_login_error_rate_limit")
@patch("controllers.web.login.WebAppAuthService.login", return_value="new-access-token")
@patch("controllers.web.login.WebAppAuthService.get_user_through_email")
@patch("controllers.web.login.WebAppAuthService.revoke_email_code_login_token")
@patch("controllers.web.login.WebAppAuthService.get_email_code_login_data")
def test_should_normalize_email_before_validating(
self,
mock_get_token_data,
mock_revoke_token,
mock_get_user,
mock_login,
mock_reset_login_rate,
app,
):
mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
mock_get_user.return_value = MagicMock()
with app.test_request_context(
"/web/email-code-login/validity",
method="POST",
json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
):
response = EmailCodeLoginApi().post()
assert response.get_json() == {"result": "success", "data": {"access_token": "new-access-token"}}
mock_get_user.assert_called_once_with("User@Example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_login.assert_called_once()
mock_reset_login_rate.assert_called_once_with("user@example.com")

View File

@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
from configs import dify_config
from models.account import Account
from models.account import Account, AccountStatus
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import (
AccountAlreadyInTenantError,
@@ -1147,9 +1147,13 @@ class TestRegisterService:
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None # No existing account
with patch("services.account_service.Session") as mock_session_class:
with (
patch("services.account_service.Session") as mock_session_class,
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
):
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session_class.return_value.__exit__.return_value = None
mock_lookup.return_value = None
# Mock RegisterService.register
mock_new_account = TestAccountAssociatedDataFactory.create_account_mock(
@@ -1182,9 +1186,59 @@ class TestRegisterService:
email="newuser@example.com",
name="newuser",
language="en-US",
status="pending",
status=AccountStatus.PENDING,
is_setup=True,
)
mock_lookup.assert_called_once_with("newuser@example.com", session=mock_session)
def test_invite_new_member_normalizes_new_account_email(
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
):
"""Ensure inviting with mixed-case email normalizes before registering."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter")
mixed_email = "Invitee@Example.com"
mock_session = MagicMock()
with (
patch("services.account_service.Session") as mock_session_class,
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
):
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session_class.return_value.__exit__.return_value = None
mock_lookup.return_value = None
mock_new_account = TestAccountAssociatedDataFactory.create_account_mock(
account_id="new-user-789", email="invitee@example.com", name="invitee", status="pending"
)
with patch("services.account_service.RegisterService.register") as mock_register:
mock_register.return_value = mock_new_account
with (
patch("services.account_service.TenantService.check_member_permission") as mock_check_permission,
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
patch("services.account_service.TenantService.switch_tenant") as mock_switch_tenant,
patch("services.account_service.RegisterService.generate_invite_token") as mock_generate_token,
):
mock_generate_token.return_value = "invite-token-abc"
RegisterService.invite_new_member(
tenant=mock_tenant,
email=mixed_email,
language="en-US",
role="normal",
inviter=mock_inviter,
)
mock_register.assert_called_once_with(
email="invitee@example.com",
name="invitee",
language="en-US",
status=AccountStatus.PENDING,
is_setup=True,
)
mock_lookup.assert_called_once_with(mixed_email, session=mock_session)
mock_check_permission.assert_called_once_with(mock_tenant, mock_inviter, None, "add")
mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "normal")
mock_switch_tenant.assert_called_once_with(mock_new_account, mock_tenant.id)
mock_generate_token.assert_called_once_with(mock_tenant, mock_new_account)
@@ -1207,9 +1261,13 @@ class TestRegisterService:
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_existing_account
with patch("services.account_service.Session") as mock_session_class:
with (
patch("services.account_service.Session") as mock_session_class,
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
):
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session_class.return_value.__exit__.return_value = None
mock_lookup.return_value = mock_existing_account
# Mock the db.session.query for TenantAccountJoin
mock_db_query = MagicMock()
@@ -1238,6 +1296,7 @@ class TestRegisterService:
mock_create_member.assert_called_once_with(mock_tenant, mock_existing_account, "normal")
mock_generate_token.assert_called_once_with(mock_tenant, mock_existing_account)
mock_task_dependencies.delay.assert_called_once()
mock_lookup.assert_called_once_with("existing@example.com", session=mock_session)
def test_invite_new_member_already_in_tenant(self, mock_db_dependencies, mock_redis_dependencies):
"""Test inviting a member who is already in the tenant."""
@@ -1251,7 +1310,6 @@ class TestRegisterService:
# Mock database queries
query_results = {
("Account", "email", "existing@example.com"): mock_existing_account,
(
"TenantAccountJoin",
"tenant_id",
@@ -1261,7 +1319,11 @@ class TestRegisterService:
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
# Mock TenantService methods
with patch("services.account_service.TenantService.check_member_permission") as mock_check_permission:
with (
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
patch("services.account_service.TenantService.check_member_permission") as mock_check_permission,
):
mock_lookup.return_value = mock_existing_account
# Execute test and verify exception
self._assert_exception_raised(
AccountAlreadyInTenantError,
@@ -1272,6 +1334,7 @@ class TestRegisterService:
role="normal",
inviter=mock_inviter,
)
mock_lookup.assert_called_once()
def test_invite_new_member_no_inviter(self):
"""Test inviting a member without providing an inviter."""
@@ -1497,6 +1560,30 @@ class TestRegisterService:
# Verify results
assert result is None
def test_get_invitation_with_case_fallback_returns_initial_match(self):
"""Fallback helper should return the initial invitation when present."""
invitation = {"workspace_id": "tenant-456"}
with patch(
"services.account_service.RegisterService.get_invitation_if_token_valid", return_value=invitation
) as mock_get:
result = RegisterService.get_invitation_with_case_fallback("tenant-456", "User@Test.com", "token-123")
assert result == invitation
mock_get.assert_called_once_with("tenant-456", "User@Test.com", "token-123")
def test_get_invitation_with_case_fallback_retries_with_lowercase(self):
"""Fallback helper should retry with lowercase email when needed."""
invitation = {"workspace_id": "tenant-456"}
with patch("services.account_service.RegisterService.get_invitation_if_token_valid") as mock_get:
mock_get.side_effect = [None, invitation]
result = RegisterService.get_invitation_with_case_fallback("tenant-456", "User@Test.com", "token-123")
assert result == invitation
assert mock_get.call_args_list == [
(("tenant-456", "User@Test.com", "token-123"),),
(("tenant-456", "user@test.com", "token-123"),),
]
# ==================== Helper Method Tests ====================
def test_get_invitation_token_key(self):

2
api/uv.lock generated
View File

@@ -1368,7 +1368,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.11.2"
version = "1.11.3"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.3
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.3
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.3
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.11.2
image: langgenius/dify-web:1.11.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@@ -704,7 +704,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.3
restart: always
environment:
# Use the shared environment variables.
@@ -746,7 +746,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.3
restart: always
environment:
# Use the shared environment variables.
@@ -785,7 +785,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.3
restart: always
environment:
# Use the shared environment variables.
@@ -815,7 +815,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.11.2
image: langgenius/dify-web:1.11.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@@ -53,6 +53,7 @@ vi.mock('@/context/global-public-context', () => {
)
return {
useGlobalPublicStore,
useIsSystemFeaturesPending: () => false,
}
})

View File

@@ -9,8 +9,8 @@ import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { fetchSetupStatus } from '@/service/common'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
@@ -33,15 +33,8 @@ export const AppInitializer = ({
const isSetupFinished = useCallback(async () => {
try {
if (localStorage.getItem('setup_status') === 'finished')
return true
const setUpStatus = await fetchSetupStatus()
if (setUpStatus.step !== 'finished') {
localStorage.removeItem('setup_status')
return false
}
localStorage.setItem('setup_status', 'finished')
return true
const setUpStatus = await fetchSetupStatusWithCache()
return setUpStatus.step === 'finished'
}
catch (error) {
console.error(error)

View File

@@ -125,7 +125,6 @@ const resetAccessControlStore = () => {
const resetGlobalStore = () => {
useGlobalPublicStore.setState({
systemFeatures: defaultSystemFeatures,
isGlobalPending: false,
})
}

View File

@@ -897,58 +897,6 @@ describe('Icon', () => {
const iconDiv = container.firstChild as HTMLElement
expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/icon?name=test&size=large)' })
})
it('should not render status indicators when src is object with installed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installed={true} />)
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument()
})
it('should not render status indicators when src is object with installFailed=true', () => {
render(<Icon src={{ content: '🎉', background: '#fff' }} installFailed={true} />)
// Status indicators should not render for object src
expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument()
})
it('should render object src with all size variants', () => {
const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large']
sizes.forEach((size) => {
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} size={size} />)
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size)
unmount()
})
})
it('should render object src with custom className', () => {
const { container } = render(
<Icon src={{ content: '🎉', background: '#fff' }} className="custom-object-icon" />,
)
expect(container.querySelector('.custom-object-icon')).toBeInTheDocument()
})
it('should pass correct props to AppIcon for object src', () => {
render(<Icon src={{ content: '😀', background: '#123456' }} />)
const appIcon = screen.getByTestId('app-icon')
expect(appIcon).toHaveAttribute('data-icon', '😀')
expect(appIcon).toHaveAttribute('data-background', '#123456')
expect(appIcon).toHaveAttribute('data-icon-type', 'emoji')
})
it('should render inner icon only when shouldUseMcpIcon returns true', () => {
// Test with MCP icon content
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} />)
expect(screen.getByTestId('inner-icon')).toBeInTheDocument()
unmount()
// Test without MCP icon content
render(<Icon src={{ content: '🎉', background: '#fff' }} />)
expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,123 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocks
import { useGlobalPublicStore } from '@/context/global-public-context'
import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from './context'
// Mock dependencies
vi.mock('nuqs', () => ({
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('../hooks', () => ({
PLUGIN_PAGE_TABS_MAP: {
plugins: 'plugins',
marketplace: 'discover',
},
usePluginPageTabs: () => [
{ value: 'plugins', text: 'Plugins' },
{ value: 'discover', text: 'Explore Marketplace' },
],
}))
// Helper function to mock useGlobalPublicStore with marketplace setting
const mockGlobalPublicStore = (enableMarketplace: boolean) => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = { systemFeatures: { enable_marketplace: enableMarketplace } }
return selector(state as Parameters<typeof selector>[0])
})
}
// Test component that uses the context
const TestConsumer = () => {
const containerRef = usePluginPageContext(v => v.containerRef)
const options = usePluginPageContext(v => v.options)
const activeTab = usePluginPageContext(v => v.activeTab)
return (
<div>
<span data-testid="has-container-ref">{containerRef ? 'true' : 'false'}</span>
<span data-testid="options-count">{options.length}</span>
<span data-testid="active-tab">{activeTab}</span>
{options.map((opt: { value: string, text: string }) => (
<span key={opt.value} data-testid={`option-${opt.value}`}>{opt.text}</span>
))}
</div>
)
}
describe('PluginPageContext', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('PluginPageContextProvider', () => {
it('should provide context values to children', () => {
mockGlobalPublicStore(true)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
expect(screen.getByTestId('has-container-ref')).toHaveTextContent('true')
expect(screen.getByTestId('options-count')).toHaveTextContent('2')
})
it('should include marketplace tab when enable_marketplace is true', () => {
mockGlobalPublicStore(true)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
expect(screen.getByTestId('option-discover')).toBeInTheDocument()
})
it('should filter out marketplace tab when enable_marketplace is false', () => {
mockGlobalPublicStore(false)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
expect(screen.queryByTestId('option-discover')).not.toBeInTheDocument()
expect(screen.getByTestId('options-count')).toHaveTextContent('1')
})
})
describe('usePluginPageContext', () => {
it('should select specific context values', () => {
mockGlobalPublicStore(true)
render(
<PluginPageContextProvider>
<TestConsumer />
</PluginPageContextProvider>,
)
// activeTab should be 'plugins' from the mock
expect(screen.getByTestId('active-tab')).toHaveTextContent('plugins')
})
})
describe('Default Context Values', () => {
it('should have empty options by default from context', () => {
// Test that the context has proper default values by checking the exported constant
// The PluginPageContext is created with default values including empty options array
expect(PluginPageContext).toBeDefined()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -207,7 +207,6 @@ const PluginPage = ({
popupContent={t('privilege.title', { ns: 'plugin' })}
>
<Button
data-testid="plugin-settings-button"
className="group h-full w-full p-2 text-components-button-secondary-text"
onClick={setShowPluginSettingModal}
>

View File

@@ -1,219 +0,0 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoaderLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
import { useGetLanguage } from '@/context/i18n'
// Types
type PluginItemProps = {
plugin: PluginStatus
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
statusText: string
statusClassName?: string
action?: ReactNode
}
type PluginSectionProps = {
title: string
count: number
plugins: PluginStatus[]
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
defaultStatusText: string
statusClassName?: string
headerAction?: ReactNode
renderItemAction?: (plugin: PluginStatus) => ReactNode
}
type PluginTaskListProps = {
runningPlugins: PluginStatus[]
successPlugins: PluginStatus[]
errorPlugins: PluginStatus[]
getIconUrl: (icon: string) => string
onClearAll: () => void
onClearErrors: () => void
onClearSingle: (taskId: string, pluginId: string) => void
}
// Plugin Item Component
const PluginItem: FC<PluginItemProps> = ({
plugin,
getIconUrl,
language,
statusIcon,
statusText,
statusClassName,
action,
}) => {
return (
<div className="flex items-center rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
{statusIcon}
<CardIcon
size="tiny"
src={getIconUrl(plugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{plugin.labels[language]}
</div>
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
{statusText}
</div>
</div>
{action}
</div>
)
}
// Plugin Section Component
const PluginSection: FC<PluginSectionProps> = ({
title,
count,
plugins,
getIconUrl,
language,
statusIcon,
defaultStatusText,
statusClassName,
headerAction,
renderItemAction,
}) => {
if (plugins.length === 0)
return null
return (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{title}
{' '}
(
{count}
)
{headerAction}
</div>
<div className="max-h-[200px] overflow-y-auto">
{plugins.map(plugin => (
<PluginItem
key={plugin.plugin_unique_identifier}
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
statusIcon={statusIcon}
statusText={plugin.message || defaultStatusText}
statusClassName={statusClassName}
action={renderItemAction?.(plugin)}
/>
))}
</div>
</>
)
}
// Main Plugin Task List Component
const PluginTaskList: FC<PluginTaskListProps> = ({
runningPlugins,
successPlugins,
errorPlugins,
getIconUrl,
onClearAll,
onClearErrors,
onClearSingle,
}) => {
const { t } = useTranslation()
const language = useGetLanguage()
return (
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{/* Running Plugins Section */}
{runningPlugins.length > 0 && (
<PluginSection
title={t('task.installing', { ns: 'plugin' })}
count={runningPlugins.length}
plugins={runningPlugins}
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
}
defaultStatusText={t('task.installing', { ns: 'plugin' })}
/>
)}
{/* Success Plugins Section */}
{successPlugins.length > 0 && (
<PluginSection
title={t('task.installed', { ns: 'plugin' })}
count={successPlugins.length}
plugins={successPlugins}
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
}
defaultStatusText={t('task.installed', { ns: 'plugin' })}
statusClassName="text-text-success"
headerAction={(
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={onClearAll}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
)}
/>
)}
{/* Error Plugins Section */}
{errorPlugins.length > 0 && (
<PluginSection
title={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
count={errorPlugins.length}
plugins={errorPlugins}
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
}
defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
statusClassName="text-text-destructive break-all"
headerAction={(
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={onClearErrors}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
)}
renderItemAction={plugin => (
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
>
{t('operation.clear', { ns: 'common' })}
</Button>
)}
/>
)}
</div>
)
}
export default PluginTaskList

View File

@@ -1,96 +0,0 @@
import type { FC } from 'react'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInstallLine,
} from '@remixicon/react'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import Tooltip from '@/app/components/base/tooltip'
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
import { cn } from '@/utils/classnames'
export type TaskStatusIndicatorProps = {
tip: string
isInstalling: boolean
isInstallingWithSuccess: boolean
isInstallingWithError: boolean
isSuccess: boolean
isFailed: boolean
successPluginsLength: number
runningPluginsLength: number
totalPluginsLength: number
onClick: () => void
}
const TaskStatusIndicator: FC<TaskStatusIndicatorProps> = ({
tip,
isInstalling,
isInstallingWithSuccess,
isInstallingWithError,
isSuccess,
isFailed,
successPluginsLength,
runningPluginsLength,
totalPluginsLength,
onClick,
}) => {
const showDownloadingIcon = isInstalling || isInstallingWithError
const showErrorStyle = isInstallingWithError || isFailed
const showSuccessIcon = isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0)
return (
<Tooltip
popupContent={tip}
asChild
offset={8}
>
<div
className={cn(
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
showErrorStyle && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
)}
id="plugin-task-trigger"
onClick={onClick}
>
{/* Main Icon */}
{showDownloadingIcon
? <DownloadingIcon />
: (
<RiInstallLine
className={cn(
'h-4 w-4 text-components-button-secondary-text',
showErrorStyle && 'text-components-button-destructive-secondary-text',
)}
/>
)}
{/* Status Indicator Badge */}
<div className="absolute -right-1 -top-1">
{(isInstalling || isInstallingWithSuccess) && (
<ProgressCircle
percentage={(totalPluginsLength > 0 ? successPluginsLength / totalPluginsLength : 0) * 100}
circleFillColor="fill-components-progress-brand-bg"
/>
)}
{isInstallingWithError && (
<ProgressCircle
percentage={(totalPluginsLength > 0 ? runningPluginsLength / totalPluginsLength : 0) * 100}
circleFillColor="fill-components-progress-brand-bg"
sectorFillColor="fill-components-progress-error-border"
circleStrokeColor="stroke-components-progress-error-border"
/>
)}
{showSuccessIcon && !isInstalling && !isInstallingWithSuccess && !isInstallingWithError && (
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-text-success" />
)}
{isFailed && (
<RiErrorWarningFill className="h-3.5 w-3.5 text-text-destructive" />
)}
</div>
</div>
</Tooltip>
)
}
export default TaskStatusIndicator

View File

@@ -1,856 +0,0 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TaskStatus } from '@/app/components/plugins/types'
// Import mocked modules
import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins'
import PluginTaskList from './components/plugin-task-list'
import TaskStatusIndicator from './components/task-status-indicator'
import { usePluginTaskStatus } from './hooks'
import PluginTasks from './index'
// Mock external dependencies
vi.mock('@/service/use-plugins', () => ({
usePluginTaskList: vi.fn(),
useMutationClearTaskPlugin: vi.fn(),
}))
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://example.com/${icon}`,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
// Helper to create mock plugin
const createMockPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: `plugin-${Math.random().toString(36).substr(2, 9)}`,
plugin_id: 'test-plugin',
status: TaskStatus.running,
message: '',
icon: 'test-icon.png',
labels: {
en_US: 'Test Plugin',
zh_Hans: '测试插件',
} as Record<string, string>,
taskId: 'task-1',
...overrides,
})
// Helper to setup mock hook returns
const setupMocks = (plugins: PluginStatus[] = []) => {
const mockMutateAsync = vi.fn().mockResolvedValue({})
const mockHandleRefetch = vi.fn()
vi.mocked(usePluginTaskList).mockReturnValue({
pluginTasks: plugins.length > 0
? [{ id: 'task-1', plugins, created_at: '', updated_at: '', status: 'running', total_plugins: plugins.length, completed_plugins: 0 }]
: [],
handleRefetch: mockHandleRefetch,
} as any)
vi.mocked(useMutationClearTaskPlugin).mockReturnValue({
mutateAsync: mockMutateAsync,
} as any)
return { mockMutateAsync, mockHandleRefetch }
}
// ============================================================================
// usePluginTaskStatus Hook Tests
// ============================================================================
describe('usePluginTaskStatus Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Plugin categorization', () => {
it('should categorize running plugins correctly', () => {
const runningPlugin = createMockPlugin({ status: TaskStatus.running })
setupMocks([runningPlugin])
const TestComponent = () => {
const { runningPlugins, runningPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="running-count">{runningPluginsLength}</span>
<span data-testid="running-id">{runningPlugins[0]?.plugin_unique_identifier}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('running-count')).toHaveTextContent('1')
expect(screen.getByTestId('running-id')).toHaveTextContent(runningPlugin.plugin_unique_identifier)
})
it('should categorize success plugins correctly', () => {
const successPlugin = createMockPlugin({ status: TaskStatus.success })
setupMocks([successPlugin])
const TestComponent = () => {
const { successPlugins, successPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="success-count">{successPluginsLength}</span>
<span data-testid="success-id">{successPlugins[0]?.plugin_unique_identifier}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('success-count')).toHaveTextContent('1')
expect(screen.getByTestId('success-id')).toHaveTextContent(successPlugin.plugin_unique_identifier)
})
it('should categorize error plugins correctly', () => {
const errorPlugin = createMockPlugin({ status: TaskStatus.failed, message: 'Install failed' })
setupMocks([errorPlugin])
const TestComponent = () => {
const { errorPlugins, errorPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="error-count">{errorPluginsLength}</span>
<span data-testid="error-id">{errorPlugins[0]?.plugin_unique_identifier}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('error-count')).toHaveTextContent('1')
expect(screen.getByTestId('error-id')).toHaveTextContent(errorPlugin.plugin_unique_identifier)
})
it('should categorize mixed plugins correctly', () => {
const plugins = [
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }),
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
]
setupMocks(plugins)
const TestComponent = () => {
const { runningPluginsLength, successPluginsLength, errorPluginsLength, totalPluginsLength } = usePluginTaskStatus()
return (
<div>
<span data-testid="running">{runningPluginsLength}</span>
<span data-testid="success">{successPluginsLength}</span>
<span data-testid="error">{errorPluginsLength}</span>
<span data-testid="total">{totalPluginsLength}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('running')).toHaveTextContent('1')
expect(screen.getByTestId('success')).toHaveTextContent('1')
expect(screen.getByTestId('error')).toHaveTextContent('1')
expect(screen.getByTestId('total')).toHaveTextContent('3')
})
})
describe('Status flags', () => {
it('should set isInstalling when only running plugins exist', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
const TestComponent = () => {
const { isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess, isFailed } = usePluginTaskStatus()
return (
<div>
<span data-testid="isInstalling">{String(isInstalling)}</span>
<span data-testid="isInstallingWithSuccess">{String(isInstallingWithSuccess)}</span>
<span data-testid="isInstallingWithError">{String(isInstallingWithError)}</span>
<span data-testid="isSuccess">{String(isSuccess)}</span>
<span data-testid="isFailed">{String(isFailed)}</span>
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('isInstalling')).toHaveTextContent('true')
expect(screen.getByTestId('isInstallingWithSuccess')).toHaveTextContent('false')
expect(screen.getByTestId('isInstallingWithError')).toHaveTextContent('false')
expect(screen.getByTestId('isSuccess')).toHaveTextContent('false')
expect(screen.getByTestId('isFailed')).toHaveTextContent('false')
})
it('should set isInstallingWithSuccess when running and success plugins exist', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running }),
createMockPlugin({ status: TaskStatus.success }),
])
const TestComponent = () => {
const { isInstallingWithSuccess } = usePluginTaskStatus()
return <span data-testid="flag">{String(isInstallingWithSuccess)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
it('should set isInstallingWithError when running and error plugins exist', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running }),
createMockPlugin({ status: TaskStatus.failed }),
])
const TestComponent = () => {
const { isInstallingWithError } = usePluginTaskStatus()
return <span data-testid="flag">{String(isInstallingWithError)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
it('should set isSuccess when all plugins succeeded', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.success }),
createMockPlugin({ status: TaskStatus.success }),
])
const TestComponent = () => {
const { isSuccess } = usePluginTaskStatus()
return <span data-testid="flag">{String(isSuccess)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
it('should set isFailed when no running plugins and some failed', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.success }),
createMockPlugin({ status: TaskStatus.failed }),
])
const TestComponent = () => {
const { isFailed } = usePluginTaskStatus()
return <span data-testid="flag">{String(isFailed)}</span>
}
render(<TestComponent />)
expect(screen.getByTestId('flag')).toHaveTextContent('true')
})
})
describe('handleClearErrorPlugin', () => {
it('should call mutateAsync and handleRefetch', async () => {
const { mockMutateAsync, mockHandleRefetch } = setupMocks([
createMockPlugin({ status: TaskStatus.failed }),
])
const TestComponent = () => {
const { handleClearErrorPlugin } = usePluginTaskStatus()
return (
<button onClick={() => handleClearErrorPlugin('task-1', 'plugin-1')}>
Clear
</button>
)
}
render(<TestComponent />)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
taskId: 'task-1',
pluginId: 'plugin-1',
})
expect(mockHandleRefetch).toHaveBeenCalled()
})
})
})
})
// ============================================================================
// TaskStatusIndicator Component Tests
// ============================================================================
describe('TaskStatusIndicator Component', () => {
const defaultProps = {
tip: 'Test tooltip',
isInstalling: false,
isInstallingWithSuccess: false,
isInstallingWithError: false,
isSuccess: false,
isFailed: false,
successPluginsLength: 0,
runningPluginsLength: 0,
totalPluginsLength: 1,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should render with correct id', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Icon display', () => {
it('should show downloading icon when installing', () => {
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
// DownloadingIcon is rendered when isInstalling is true
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show downloading icon when installing with error', () => {
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show install icon when not installing', () => {
render(<TaskStatusIndicator {...defaultProps} isSuccess />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Status badge', () => {
it('should show progress circle when installing', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
successPluginsLength={1}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show progress circle when installing with success', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithSuccess
successPluginsLength={2}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show error progress circle when installing with error', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithError
runningPluginsLength={1}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show success icon when all completed successfully', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isSuccess
successPluginsLength={3}
runningPluginsLength={0}
totalPluginsLength={3}
/>,
)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show error icon when failed', () => {
render(<TaskStatusIndicator {...defaultProps} isFailed />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should apply error styles when installing with error', () => {
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
const trigger = document.getElementById('plugin-task-trigger')
expect(trigger).toHaveClass('bg-state-destructive-hover')
})
it('should apply error styles when failed', () => {
render(<TaskStatusIndicator {...defaultProps} isFailed />)
const trigger = document.getElementById('plugin-task-trigger')
expect(trigger).toHaveClass('bg-state-destructive-hover')
})
it('should apply cursor-pointer when clickable', () => {
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
const trigger = document.getElementById('plugin-task-trigger')
expect(trigger).toHaveClass('cursor-pointer')
})
})
describe('User interactions', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<TaskStatusIndicator {...defaultProps} onClick={handleClick} />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})
// ============================================================================
// PluginTaskList Component Tests
// ============================================================================
describe('PluginTaskList Component', () => {
const defaultProps = {
runningPlugins: [] as PluginStatus[],
successPlugins: [] as PluginStatus[],
errorPlugins: [] as PluginStatus[],
getIconUrl: (icon: string) => `https://example.com/${icon}`,
onClearAll: vi.fn(),
onClearErrors: vi.fn(),
onClearSingle: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing with empty lists', () => {
render(<PluginTaskList {...defaultProps} />)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
it('should render running plugins section when plugins exist', () => {
const runningPlugins = [createMockPlugin({ status: TaskStatus.running })]
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// Translation key is returned as text in tests, multiple matches expected (title + status)
expect(screen.getAllByText(/task\.installing/i).length).toBeGreaterThan(0)
// Verify section container is rendered
expect(document.querySelector('.max-h-\\[200px\\]')).toBeInTheDocument()
})
it('should render success plugins section when plugins exist', () => {
const successPlugins = [createMockPlugin({ status: TaskStatus.success })]
render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
// Translation key is returned as text in tests, multiple matches expected
expect(screen.getAllByText(/task\.installed/i).length).toBeGreaterThan(0)
})
it('should render error plugins section when plugins exist', () => {
const errorPlugins = [createMockPlugin({ status: TaskStatus.failed, message: 'Error occurred' })]
render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
expect(screen.getByText('Error occurred')).toBeInTheDocument()
})
it('should render all sections when all types exist', () => {
render(
<PluginTaskList
{...defaultProps}
runningPlugins={[createMockPlugin({ status: TaskStatus.running })]}
successPlugins={[createMockPlugin({ status: TaskStatus.success })]}
errorPlugins={[createMockPlugin({ status: TaskStatus.failed })]}
/>,
)
// All sections should be present
expect(document.querySelectorAll('.max-h-\\[200px\\]').length).toBe(3)
})
})
describe('User interactions', () => {
it('should call onClearAll when clear all button is clicked in success section', () => {
const handleClearAll = vi.fn()
const successPlugins = [createMockPlugin({ status: TaskStatus.success })]
render(
<PluginTaskList
{...defaultProps}
successPlugins={successPlugins}
onClearAll={handleClearAll}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
expect(handleClearAll).toHaveBeenCalledTimes(1)
})
it('should call onClearErrors when clear all button is clicked in error section', () => {
const handleClearErrors = vi.fn()
const errorPlugins = [createMockPlugin({ status: TaskStatus.failed })]
render(
<PluginTaskList
{...defaultProps}
errorPlugins={errorPlugins}
onClearErrors={handleClearErrors}
/>,
)
const clearButtons = screen.getAllByRole('button')
fireEvent.click(clearButtons.find(btn => btn.textContent?.includes('task.clearAll'))!)
expect(handleClearErrors).toHaveBeenCalledTimes(1)
})
it('should call onClearSingle with correct args when individual clear is clicked', () => {
const handleClearSingle = vi.fn()
const errorPlugin = createMockPlugin({
status: TaskStatus.failed,
plugin_unique_identifier: 'error-plugin-1',
taskId: 'task-123',
})
render(
<PluginTaskList
{...defaultProps}
errorPlugins={[errorPlugin]}
onClearSingle={handleClearSingle}
/>,
)
// The individual clear button has the text 'operation.clear'
fireEvent.click(screen.getByRole('button', { name: /operation\.clear/i }))
expect(handleClearSingle).toHaveBeenCalledWith('task-123', 'error-plugin-1')
})
})
describe('Plugin display', () => {
it('should display plugin name from labels', () => {
const plugin = createMockPlugin({
status: TaskStatus.running,
labels: { en_US: 'My Test Plugin' } as Record<string, string>,
})
render(<PluginTaskList {...defaultProps} runningPlugins={[plugin]} />)
expect(screen.getByText('My Test Plugin')).toBeInTheDocument()
})
it('should display plugin message when available', () => {
const plugin = createMockPlugin({
status: TaskStatus.success,
message: 'Successfully installed!',
})
render(<PluginTaskList {...defaultProps} successPlugins={[plugin]} />)
expect(screen.getByText('Successfully installed!')).toBeInTheDocument()
})
it('should display multiple plugins in each section', () => {
const runningPlugins = [
createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin A' } as Record<string, string> }),
createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin B' } as Record<string, string> }),
]
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
expect(screen.getByText('Plugin A')).toBeInTheDocument()
expect(screen.getByText('Plugin B')).toBeInTheDocument()
// Count is rendered, verify multiple items are in list
expect(document.querySelectorAll('.hover\\:bg-state-base-hover').length).toBe(2)
})
})
})
// ============================================================================
// PluginTasks Main Component Tests
// ============================================================================
describe('PluginTasks Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return null when no plugins exist', () => {
setupMocks([])
const { container } = render(<PluginTasks />)
expect(container.firstChild).toBeNull()
})
it('should render when plugins exist', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Tooltip text (tip memoization)', () => {
it('should show installing tip when isInstalling', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
// The component renders with a tooltip, we verify it exists
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show success tip when all succeeded', () => {
setupMocks([createMockPlugin({ status: TaskStatus.success })])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show error tip when some failed', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.success }),
createMockPlugin({ status: TaskStatus.failed }),
])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Popover interaction', () => {
it('should toggle popover when trigger is clicked and status allows', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
// Click to open
fireEvent.click(document.getElementById('plugin-task-trigger')!)
// The popover content should be visible (PluginTaskList)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
it('should not toggle when status does not allow', () => {
// Setup with no actionable status (edge case - should not happen in practice)
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
// Component should still render
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
})
describe('Clear handlers', () => {
it('should clear all completed plugins when onClearAll is called', async () => {
const { mockMutateAsync } = setupMocks([
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
// Wait for popover content to render
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
// Find and click clear all button
const clearButtons = screen.getAllByRole('button')
const clearAllButton = clearButtons.find(btn => btn.textContent?.includes('clearAll'))
if (clearAllButton)
fireEvent.click(clearAllButton)
// Verify mutateAsync was called for each completed plugin
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should clear only error plugins when onClearErrors is called', async () => {
const { mockMutateAsync } = setupMocks([
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
// Find and click the clear all button in error section
const clearButtons = screen.getAllByRole('button')
if (clearButtons.length > 0)
fireEvent.click(clearButtons[0])
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should clear single plugin when onClearSingle is called', async () => {
const { mockMutateAsync } = setupMocks([
createMockPlugin({
status: TaskStatus.failed,
plugin_unique_identifier: 'error-plugin',
taskId: 'task-1',
}),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
// Find and click individual clear button (usually the last one)
const clearButtons = screen.getAllByRole('button')
const individualClearButton = clearButtons[clearButtons.length - 1]
fireEvent.click(individualClearButton)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
taskId: 'task-1',
pluginId: 'error-plugin',
})
})
})
})
describe('Edge cases', () => {
it('should handle empty plugin tasks array', () => {
setupMocks([])
const { container } = render(<PluginTasks />)
expect(container.firstChild).toBeNull()
})
it('should handle single running plugin', () => {
setupMocks([createMockPlugin({ status: TaskStatus.running })])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle many plugins', () => {
const manyPlugins = Array.from({ length: 10 }, (_, i) =>
createMockPlugin({
status: i % 3 === 0 ? TaskStatus.running : i % 3 === 1 ? TaskStatus.success : TaskStatus.failed,
plugin_unique_identifier: `plugin-${i}`,
}))
setupMocks(manyPlugins)
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle plugins with empty labels', () => {
const plugin = createMockPlugin({
status: TaskStatus.running,
labels: {} as Record<string, string>,
})
setupMocks([plugin])
render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle plugins with long messages', () => {
const plugin = createMockPlugin({
status: TaskStatus.failed,
message: 'A'.repeat(500),
})
setupMocks([plugin])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('PluginTasks Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show correct UI flow from installing to success', async () => {
// Start with installing state
setupMocks([createMockPlugin({ status: TaskStatus.running })])
const { rerender } = render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
// Simulate completion by re-rendering with success
setupMocks([createMockPlugin({ status: TaskStatus.success })])
rerender(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should show correct UI flow from installing to failure', async () => {
// Start with installing state
setupMocks([createMockPlugin({ status: TaskStatus.running })])
const { rerender } = render(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
// Simulate failure by re-rendering with failed
setupMocks([createMockPlugin({ status: TaskStatus.failed, message: 'Network error' })])
rerender(<PluginTasks />)
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
})
it('should handle mixed status during installation', () => {
setupMocks([
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'p1' }),
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'p2' }),
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'p3' }),
])
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
// All sections should be visible
const sections = document.querySelectorAll('.max-h-\\[200px\\]')
expect(sections.length).toBe(3)
})
})

View File

@@ -1,21 +1,33 @@
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInstallLine,
RiLoaderLine,
} from '@remixicon/react'
import {
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import Tooltip from '@/app/components/base/tooltip'
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import PluginTaskList from './components/plugin-task-list'
import TaskStatusIndicator from './components/task-status-indicator'
import { useGetLanguage } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import { usePluginTaskStatus } from './hooks'
const PluginTasks = () => {
const { t } = useTranslation()
const language = useGetLanguage()
const [open, setOpen] = useState(false)
const {
errorPlugins,
@@ -34,7 +46,35 @@ const PluginTasks = () => {
} = usePluginTaskStatus()
const { getIconUrl } = useGetIcon()
// Generate tooltip text based on status
const handleClearAllWithModal = useCallback(async () => {
// Clear all completed plugins (success and error) but keep running ones
const completedPlugins = [...successPlugins, ...errorPlugins]
// Clear all completed plugins individually
for (const plugin of completedPlugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [successPlugins, errorPlugins, handleClearErrorPlugin, runningPluginsLength])
const handleClearErrorsWithModal = useCallback(async () => {
// Clear only error plugins, not all plugins
for (const plugin of errorPlugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [errorPlugins, handleClearErrorPlugin, runningPluginsLength])
const handleClearSingleWithModal = useCallback(async (taskId: string, pluginId: string) => {
await handleClearErrorPlugin(taskId, pluginId)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [handleClearErrorPlugin, runningPluginsLength])
const tip = useMemo(() => {
if (isInstallingWithError)
return t('task.installingWithError', { ns: 'plugin', installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength })
@@ -59,38 +99,8 @@ const PluginTasks = () => {
t,
])
// Generic clear function that handles clearing and modal closing
const clearPluginsAndClose = useCallback(async (
plugins: Array<{ taskId: string, plugin_unique_identifier: string }>,
) => {
for (const plugin of plugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
if (runningPluginsLength === 0)
setOpen(false)
}, [handleClearErrorPlugin, runningPluginsLength])
// Clear handlers using the generic function
const handleClearAll = useCallback(
() => clearPluginsAndClose([...successPlugins, ...errorPlugins]),
[clearPluginsAndClose, successPlugins, errorPlugins],
)
const handleClearErrors = useCallback(
() => clearPluginsAndClose(errorPlugins),
[clearPluginsAndClose, errorPlugins],
)
const handleClearSingle = useCallback(
(taskId: string, pluginId: string) => clearPluginsAndClose([{ taskId, plugin_unique_identifier: pluginId }]),
[clearPluginsAndClose],
)
const handleTriggerClick = useCallback(() => {
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
setOpen(v => !v)
}, [isFailed, isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess])
// Hide when no plugin tasks
// Show icon if there are any plugin tasks (completed, running, or failed)
// Only hide when there are absolutely no plugin tasks
if (totalPluginsLength === 0)
return null
@@ -105,30 +115,206 @@ const PluginTasks = () => {
crossAxis: 79,
}}
>
<PortalToFollowElemTrigger onClick={handleTriggerClick}>
<TaskStatusIndicator
tip={tip}
isInstalling={isInstalling}
isInstallingWithSuccess={isInstallingWithSuccess}
isInstallingWithError={isInstallingWithError}
isSuccess={isSuccess}
isFailed={isFailed}
successPluginsLength={successPluginsLength}
runningPluginsLength={runningPluginsLength}
totalPluginsLength={totalPluginsLength}
onClick={() => {}}
/>
<PortalToFollowElemTrigger
onClick={() => {
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
setOpen(v => !v)
}}
>
<Tooltip
popupContent={tip}
asChild
offset={8}
>
<div
className={cn(
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
(isInstallingWithError || isFailed) && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
)}
id="plugin-task-trigger"
>
{
(isInstalling || isInstallingWithError) && (
<DownloadingIcon />
)
}
{
!(isInstalling || isInstallingWithError) && (
<RiInstallLine
className={cn(
'h-4 w-4 text-components-button-secondary-text',
(isInstallingWithError || isFailed) && 'text-components-button-destructive-secondary-text',
)}
/>
)
}
<div className="absolute -right-1 -top-1">
{
(isInstalling || isInstallingWithSuccess) && (
<ProgressCircle
percentage={successPluginsLength / totalPluginsLength * 100}
circleFillColor="fill-components-progress-brand-bg"
/>
)
}
{
isInstallingWithError && (
<ProgressCircle
percentage={runningPluginsLength / totalPluginsLength * 100}
circleFillColor="fill-components-progress-brand-bg"
sectorFillColor="fill-components-progress-error-border"
circleStrokeColor="stroke-components-progress-error-border"
/>
)
}
{
(isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && (
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-text-success" />
)
}
{
isFailed && (
<RiErrorWarningFill className="h-3.5 w-3.5 text-text-destructive" />
)
}
</div>
</div>
</Tooltip>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[11]">
<PluginTaskList
runningPlugins={runningPlugins}
successPlugins={successPlugins}
errorPlugins={errorPlugins}
getIconUrl={getIconUrl}
onClearAll={handleClearAll}
onClearErrors={handleClearErrors}
onClearSingle={handleClearSingle}
/>
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{/* Running Plugins */}
{runningPlugins.length > 0 && (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{t('task.installing', { ns: 'plugin' })}
{' '}
(
{runningPlugins.length}
)
</div>
<div className="max-h-[200px] overflow-y-auto">
{runningPlugins.map(runningPlugin => (
<div
key={runningPlugin.plugin_unique_identifier}
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
>
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
<CardIcon
size="tiny"
src={getIconUrl(runningPlugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{runningPlugin.labels[language]}
</div>
<div className="system-xs-regular text-text-tertiary">
{t('task.installing', { ns: 'plugin' })}
</div>
</div>
</div>
))}
</div>
</>
)}
{/* Success Plugins */}
{successPlugins.length > 0 && (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{t('task.installed', { ns: 'plugin' })}
{' '}
(
{successPlugins.length}
)
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => handleClearAllWithModal()}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<div className="max-h-[200px] overflow-y-auto">
{successPlugins.map(successPlugin => (
<div
key={successPlugin.plugin_unique_identifier}
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
>
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
<CardIcon
size="tiny"
src={getIconUrl(successPlugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{successPlugin.labels[language]}
</div>
<div className="system-xs-regular text-text-success">
{successPlugin.message || t('task.installed', { ns: 'plugin' })}
</div>
</div>
</div>
))}
</div>
</>
)}
{/* Error Plugins */}
{errorPlugins.length > 0 && (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => handleClearErrorsWithModal()}
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<div className="max-h-[200px] overflow-y-auto">
{errorPlugins.map(errorPlugin => (
<div
key={errorPlugin.plugin_unique_identifier}
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
>
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
<CardIcon
size="tiny"
src={getIconUrl(errorPlugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{errorPlugin.labels[language]}
</div>
<div className="system-xs-regular break-all text-text-destructive">
{errorPlugin.message}
</div>
</div>
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => handleClearSingleWithModal(errorPlugin.taskId, errorPlugin.plugin_unique_identifier)}
>
{t('operation.clear', { ns: 'common' })}
</Button>
</div>
))}
</div>
</>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>

View File

@@ -1,388 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocks for assertions
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import Toast from '../../base/toast'
import { PermissionType } from '../types'
import useReferenceSetting, { useCanInstallPluginFromMarketplace } from './use-reference-setting'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useReferenceSettings: vi.fn(),
useMutationReferenceSettings: vi.fn(),
useInvalidateReferenceSettings: vi.fn(),
}))
vi.mock('../../base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
describe('useReferenceSetting Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default mocks
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
},
} as ReturnType<typeof useReferenceSettings>)
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
})
describe('hasPermission logic', () => {
it('should return false when permission is undefined', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: undefined,
debug_permission: undefined,
},
},
} as unknown as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
it('should return false when permission is noOne', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
it('should return true when permission is everyone', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return isAdmin when permission is admin and user is manager', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return isAdmin when permission is admin and user is owner', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return false when permission is admin and user is not admin', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
})
describe('canSetPermissions', () => {
it('should be true when user is workspace manager', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canSetPermissions).toBe(true)
})
it('should be true when user is workspace owner', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canSetPermissions).toBe(true)
})
it('should be false when user is neither manager nor owner', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canSetPermissions).toBe(false)
})
})
describe('setReferenceSettings callback', () => {
it('should call invalidateReferenceSettings and show toast on success', async () => {
const mockInvalidate = vi.fn()
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(mockInvalidate)
let onSuccessCallback: (() => void) | undefined
vi.mocked(useMutationReferenceSettings).mockImplementation((options) => {
onSuccessCallback = options?.onSuccess as () => void
return {
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationReferenceSettings>
})
renderHook(() => useReferenceSetting())
// Trigger the onSuccess callback
if (onSuccessCallback)
onSuccessCallback()
await waitFor(() => {
expect(mockInvalidate).toHaveBeenCalled()
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: 'api.actionSuccess',
})
})
})
})
describe('returned values', () => {
it('should return referenceSetting data', () => {
const mockData = {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
}
vi.mocked(useReferenceSettings).mockReturnValue({
data: mockData,
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.referenceSetting).toEqual(mockData)
})
it('should return isUpdatePending from mutation', () => {
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
isPending: true,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.isUpdatePending).toBe(true)
})
it('should handle null data', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: null,
} as unknown as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
})
})
describe('useCanInstallPluginFromMarketplace Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
},
} as ReturnType<typeof useReferenceSettings>)
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
})
it('should return true when marketplace is enabled and canManagement is true', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: true,
},
}
return selector(state as Parameters<typeof selector>[0])
})
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(true)
})
it('should return false when marketplace is disabled', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: false,
},
}
return selector(state as Parameters<typeof selector>[0])
})
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
it('should return false when canManagement is false', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: true,
},
}
return selector(state as Parameters<typeof selector>[0])
})
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
it('should return false when both marketplace is disabled and canManagement is false', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
const state = {
systemFeatures: {
enable_marketplace: false,
},
}
return selector(state as Parameters<typeof selector>[0])
})
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
})

View File

@@ -1,487 +0,0 @@
import type { RefObject } from 'react'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUploader } from './use-uploader'
describe('useUploader Hook', () => {
let mockContainerRef: RefObject<HTMLDivElement | null>
let mockOnFileChange: (file: File | null) => void
let mockContainer: HTMLDivElement
beforeEach(() => {
vi.clearAllMocks()
mockContainer = document.createElement('div')
document.body.appendChild(mockContainer)
mockContainerRef = { current: mockContainer }
mockOnFileChange = vi.fn()
})
afterEach(() => {
if (mockContainer.parentNode)
document.body.removeChild(mockContainer)
})
describe('Initial State', () => {
it('should return initial state with dragging false', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
expect(result.current.dragging).toBe(false)
expect(result.current.fileUploader.current).toBeNull()
expect(result.current.fileChangeHandle).not.toBeNull()
expect(result.current.removeFile).not.toBeNull()
})
it('should return null handlers when disabled', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: false,
}),
)
expect(result.current.dragging).toBe(false)
expect(result.current.fileChangeHandle).toBeNull()
expect(result.current.removeFile).toBeNull()
})
})
describe('Drag Events', () => {
it('should handle dragenter and set dragging to true', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
})
it('should not set dragging when dragenter without Files type', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['text/plain'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should handle dragover event', () => {
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
act(() => {
mockContainer.dispatchEvent(dragOverEvent)
})
// dragover should prevent default and stop propagation
expect(mockContainer).toBeInTheDocument()
})
it('should handle dragleave when relatedTarget is null', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
// Then trigger dragleave with null relatedTarget
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
value: null,
})
act(() => {
mockContainer.dispatchEvent(dragLeaveEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should handle dragleave when relatedTarget is outside container', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
// Create element outside container
const outsideElement = document.createElement('div')
document.body.appendChild(outsideElement)
// Trigger dragleave with relatedTarget outside container
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
value: outsideElement,
})
act(() => {
mockContainer.dispatchEvent(dragLeaveEvent)
})
expect(result.current.dragging).toBe(false)
document.body.removeChild(outsideElement)
})
it('should not set dragging to false when relatedTarget is inside container', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
expect(result.current.dragging).toBe(true)
// Create element inside container
const insideElement = document.createElement('div')
mockContainer.appendChild(insideElement)
// Trigger dragleave with relatedTarget inside container
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
value: insideElement,
})
act(() => {
mockContainer.dispatchEvent(dragLeaveEvent)
})
// Should still be dragging since relatedTarget is inside container
expect(result.current.dragging).toBe(true)
})
})
describe('Drop Events', () => {
it('should handle drop event with files', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// First set dragging to true
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
// Create mock file
const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' })
// Trigger drop event
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
})
act(() => {
mockContainer.dispatchEvent(dropEvent)
})
expect(result.current.dragging).toBe(false)
expect(mockOnFileChange).toHaveBeenCalledWith(file)
})
it('should not call onFileChange when drop has no dataTransfer', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// Set dragging first
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'] },
})
act(() => {
mockContainer.dispatchEvent(dragEnterEvent)
})
// Drop without dataTransfer
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
// No dataTransfer property
act(() => {
mockContainer.dispatchEvent(dropEvent)
})
expect(result.current.dragging).toBe(false)
expect(mockOnFileChange).not.toHaveBeenCalled()
})
it('should not call onFileChange when drop has empty files array', () => {
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [] },
})
act(() => {
mockContainer.dispatchEvent(dropEvent)
})
expect(mockOnFileChange).not.toHaveBeenCalled()
})
})
describe('File Change Handler', () => {
it('should call onFileChange with file from input', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' })
const mockEvent = {
target: {
files: [file],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle?.(mockEvent)
})
expect(mockOnFileChange).toHaveBeenCalledWith(file)
})
it('should call onFileChange with null when no files', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
const mockEvent = {
target: {
files: null,
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle?.(mockEvent)
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
})
})
describe('Remove File', () => {
it('should call onFileChange with null', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
act(() => {
result.current.removeFile?.()
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
})
it('should handle removeFile when fileUploader has a value', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// Create a mock input element with value property
const mockInput = {
value: 'test.difypkg',
}
// Override the fileUploader ref
Object.defineProperty(result.current.fileUploader, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.removeFile?.()
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
expect(mockInput.value).toBe('')
})
it('should handle removeFile when fileUploader is null', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
}),
)
// fileUploader.current is null by default
act(() => {
result.current.removeFile?.()
})
expect(mockOnFileChange).toHaveBeenCalledWith(null)
})
})
describe('Enabled/Disabled State', () => {
it('should not add event listeners when disabled', () => {
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: false,
}),
)
expect(addEventListenerSpy).not.toHaveBeenCalled()
})
it('should add event listeners when enabled', () => {
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: true,
}),
)
expect(addEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
expect(addEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
expect(addEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
expect(addEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
})
it('should remove event listeners on cleanup', () => {
const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener')
const { unmount } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: true,
}),
)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
})
it('should return false for dragging when disabled', () => {
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: mockContainerRef,
enabled: false,
}),
)
expect(result.current.dragging).toBe(false)
})
})
describe('Container Ref Edge Cases', () => {
it('should handle null containerRef.current', () => {
const nullRef: RefObject<HTMLDivElement | null> = { current: null }
const { result } = renderHook(() =>
useUploader({
onFileChange: mockOnFileChange,
containerRef: nullRef,
}),
)
expect(result.current.dragging).toBe(false)
})
})
})

View File

@@ -1,550 +0,0 @@
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { cleanup, render, screen } from '@testing-library/react'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
// Import real utility functions (pure functions, no side effects)
// Import mocked modules for manipulation
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { usePipelineInit } from './hooks'
import RagPipelineWrapper from './index'
import { processNodesWithoutDataSource } from './utils'
// Mock: Context - need to control return values
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn(),
}))
// Mock: Hook with API calls
vi.mock('./hooks', () => ({
usePipelineInit: vi.fn(),
}))
// Mock: Store creator
vi.mock('./store', () => ({
createRagPipelineSliceSlice: vi.fn(() => ({})),
}))
// Mock: Utility with complex workflow dependencies (generateNewNode, etc.)
vi.mock('./utils', () => ({
processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({
nodes,
viewport,
})),
}))
// Mock: Complex component with useParams, Toast, API calls
vi.mock('./components/conversion', () => ({
default: () => <div data-testid="conversion-component">Conversion Component</div>,
}))
// Mock: Complex component with many hooks and workflow dependencies
vi.mock('./components/rag-pipeline-main', () => ({
default: ({ nodes, edges, viewport }: any) => (
<div data-testid="rag-pipeline-main">
<span data-testid="nodes-count">{nodes?.length ?? 0}</span>
<span data-testid="edges-count">{edges?.length ?? 0}</span>
<span data-testid="viewport-zoom">{viewport?.zoom ?? 'none'}</span>
</div>
),
}))
// Mock: Complex component with ReactFlow and many providers
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
}))
// Mock: Context provider
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
// Type assertions for mocked functions
const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector)
const mockUsePipelineInit = vi.mocked(usePipelineInit)
const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource)
// Helper to mock selector with actual execution (increases function coverage)
// This executes the real selector function: s => s.dataset?.pipeline_id
const mockSelectorWithDataset = (pipelineId: string | null | undefined) => {
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => {
const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null }
return selector(mockState)
})
}
// Test data factory
const createMockWorkflowData = (overrides?: Partial<FetchWorkflowDraftResponse>): FetchWorkflowDraftResponse => ({
graph: {
nodes: [
{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start' }, position: { x: 100, y: 100 } },
{ id: 'node-2', type: 'custom', data: { type: BlockEnum.End, title: 'End' }, position: { x: 300, y: 100 } },
],
edges: [
{ id: 'edge-1', source: 'node-1', target: 'node-2', type: 'custom' },
],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'test-hash-123',
updated_at: 1234567890,
tool_published: false,
environment_variables: [],
...overrides,
} as FetchWorkflowDraftResponse)
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('RagPipelineWrapper', () => {
describe('Rendering', () => {
it('should render Conversion component when pipelineId is null', () => {
mockSelectorWithDataset(null)
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-context-provider')).not.toBeInTheDocument()
})
it('should render Conversion component when pipelineId is undefined', () => {
mockSelectorWithDataset(undefined)
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
})
it('should render Conversion component when pipelineId is empty string', () => {
mockSelectorWithDataset('')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
})
it('should render WorkflowContextProvider when pipelineId exists', () => {
mockSelectorWithDataset('pipeline-123')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
})
})
describe('Props Variations', () => {
it('should pass injectWorkflowStoreSliceFn to WorkflowContextProvider', () => {
mockSelectorWithDataset('pipeline-456')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
})
})
})
describe('RagPipeline', () => {
beforeEach(() => {
// Default setup for RagPipeline tests - execute real selector function
mockSelectorWithDataset('pipeline-123')
})
describe('Loading State', () => {
it('should render Loading component when isLoading is true', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render Loading component when data is undefined', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render Loading component when both data is undefined and isLoading is true', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
render(<RagPipelineWrapper />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('Data Loaded State', () => {
it('should render RagPipelineMain when data is loaded', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
})
it('should pass processed nodes to RagPipelineMain', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('nodes-count').textContent).toBe('2')
})
it('should pass edges to RagPipelineMain', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('edges-count').textContent).toBe('1')
})
it('should pass viewport to RagPipelineMain', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: 100, y: 200, zoom: 1.5 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('1.5')
})
})
describe('Memoization Logic', () => {
it('should process nodes through initialNodes when data is loaded', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
// initialNodes is a real function - verify nodes are rendered
// The real initialNodes processes nodes and adds position data
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should process edges through initialEdges when data is loaded', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
// initialEdges is a real function - verify component renders with edges
expect(screen.getByTestId('edges-count').textContent).toBe('1')
})
it('should call processNodesWithoutDataSource with nodesData and viewport', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled()
})
it('should not process nodes when data is undefined', () => {
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
render(<RagPipelineWrapper />)
// When data is undefined, Loading is shown, processNodesWithoutDataSource is not called
expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled()
})
it('should use memoized values when data reference is same', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
const { rerender } = render(<RagPipelineWrapper />)
// Clear mock call count after initial render
mockProcessNodesWithoutDataSource.mockClear()
// Rerender with same data reference (no change to mockUsePipelineInit)
rerender(<RagPipelineWrapper />)
// processNodesWithoutDataSource should not be called again due to useMemo
// Note: React strict mode may cause double render, so we check it's not excessive
expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1)
})
})
describe('Edge Cases', () => {
it('should handle empty nodes array', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('nodes-count').textContent).toBe('0')
})
it('should handle empty edges array', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start', desc: '' }, position: { x: 0, y: 0 } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('edges-count').textContent).toBe('0')
})
it('should handle undefined viewport', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: undefined as any,
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should handle null viewport', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: null as any,
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should handle large number of nodes', () => {
const largeNodesArray = Array.from({ length: 100 }, (_, i) => ({
id: `node-${i}`,
type: 'custom',
data: { type: BlockEnum.Start, title: `Node ${i}`, desc: '' },
position: { x: i * 100, y: 0 },
}))
const mockData = createMockWorkflowData({
graph: {
nodes: largeNodesArray,
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('nodes-count').textContent).toBe('100')
})
it('should handle viewport with edge case zoom values', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: -1000, y: -1000, zoom: 0.25 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.25')
})
it('should handle viewport with maximum zoom', () => {
const mockData = createMockWorkflowData({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 4 },
},
})
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('4')
})
})
describe('Component Integration', () => {
it('should render WorkflowWithDefaultContext as wrapper', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
})
it('should nest RagPipelineMain inside WorkflowWithDefaultContext', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
render(<RagPipelineWrapper />)
const workflowContext = screen.getByTestId('workflow-default-context')
const ragPipelineMain = screen.getByTestId('rag-pipeline-main')
expect(workflowContext).toContainElement(ragPipelineMain)
})
})
})
describe('processNodesWithoutDataSource utility integration', () => {
beforeEach(() => {
mockSelectorWithDataset('pipeline-123')
})
it('should process nodes through processNodesWithoutDataSource', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
mockProcessNodesWithoutDataSource.mockReturnValue({
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any,
viewport: { x: 0, y: 0, zoom: 2 },
})
render(<RagPipelineWrapper />)
expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled()
expect(screen.getByTestId('nodes-count').textContent).toBe('1')
expect(screen.getByTestId('viewport-zoom').textContent).toBe('2')
})
it('should handle processNodesWithoutDataSource returning modified viewport', () => {
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
mockProcessNodesWithoutDataSource.mockReturnValue({
nodes: [],
viewport: { x: 500, y: 500, zoom: 0.5 },
})
render(<RagPipelineWrapper />)
expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.5')
})
})
describe('Conditional Rendering Flow', () => {
it('should transition from loading to loaded state', () => {
mockSelectorWithDataset('pipeline-123')
// Start with loading state
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
const { rerender } = render(<RagPipelineWrapper />)
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
// Transition to loaded state
const mockData = createMockWorkflowData()
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
rerender(<RagPipelineWrapper />)
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
})
it('should switch from Conversion to Pipeline when pipelineId becomes available', () => {
// Start without pipelineId
mockSelectorWithDataset(null)
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
const { rerender } = render(<RagPipelineWrapper />)
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
// PipelineId becomes available
mockSelectorWithDataset('new-pipeline-id')
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
rerender(<RagPipelineWrapper />)
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
// Real Loading component has role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('Error Handling', () => {
beforeEach(() => {
mockSelectorWithDataset('pipeline-123')
})
it('should throw when graph nodes is null', () => {
const mockData = {
graph: {
nodes: null as any,
edges: null as any,
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'test',
updated_at: 123,
} as FetchWorkflowDraftResponse
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Real initialNodes will throw when nodes is null
// This documents the component's current behavior - it requires valid nodes array
expect(() => render(<RagPipelineWrapper />)).toThrow()
consoleSpy.mockRestore()
})
it('should throw when graph property is missing', () => {
const mockData = {
hash: 'test',
updated_at: 123,
} as unknown as FetchWorkflowDraftResponse
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// When graph is undefined, component throws because data.graph.nodes is accessed
// This documents the component's current behavior - it requires graph to be present
expect(() => render(<RagPipelineWrapper />)).toThrow()
consoleSpy.mockRestore()
})
})

View File

@@ -106,12 +106,12 @@ const ConfigPrompt: FC<Props> = ({
const handleAddPrompt = useCallback(() => {
const newPrompt = produce(payload as PromptItem[], (draft) => {
if (draft.length === 0) {
draft.push({ role: PromptRole.system, text: '' })
draft.push({ role: PromptRole.system, text: '', id: uuid4() })
return
}
const isLastItemUser = draft[draft.length - 1].role === PromptRole.user
draft.push({ role: isLastItemUser ? PromptRole.assistant : PromptRole.user, text: '' })
draft.push({ role: isLastItemUser ? PromptRole.assistant : PromptRole.user, text: '', id: uuid4() })
})
onChange(newPrompt)
}, [onChange, payload])

View File

@@ -19,6 +19,14 @@ vi.mock('@/service/common', () => ({
getSystemFeatures: vi.fn(),
}))
vi.mock('@/context/global-public-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/global-public-context')>()
return {
...actual,
useIsSystemFeaturesPending: () => false,
}
})
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
const mockSetup = vi.mocked(setup)

View File

@@ -2,42 +2,61 @@
import type { FC, PropsWithChildren } from 'react'
import type { SystemFeatures } from '@/types/feature'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { create } from 'zustand'
import Loading from '@/app/components/base/loading'
import { getSystemFeatures } from '@/service/common'
import { defaultSystemFeatures } from '@/types/feature'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
type GlobalPublicStore = {
isGlobalPending: boolean
setIsGlobalPending: (isPending: boolean) => void
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
isGlobalPending: true,
setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })),
systemFeatures: defaultSystemFeatures,
setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })),
}))
const systemFeaturesQueryKey = ['systemFeatures'] as const
const setupStatusQueryKey = ['setupStatus'] as const
async function fetchSystemFeatures() {
const data = await getSystemFeatures()
const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data })
return data
}
export function useSystemFeaturesQuery() {
return useQuery({
queryKey: systemFeaturesQueryKey,
queryFn: fetchSystemFeatures,
})
}
export function useIsSystemFeaturesPending() {
const { isPending } = useSystemFeaturesQuery()
return isPending
}
export function useSetupStatusQuery() {
return useQuery({
queryKey: setupStatusQueryKey,
queryFn: fetchSetupStatusWithCache,
staleTime: Infinity,
})
}
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
children,
}) => {
const { isPending, data } = useQuery({
queryKey: ['systemFeatures'],
queryFn: getSystemFeatures,
})
const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore()
useEffect(() => {
if (data)
setSystemFeatures({ ...defaultSystemFeatures, ...data })
}, [data, setSystemFeatures])
// Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
// setupStatus is prefetched here and cached in localStorage for AppInitializer.
const { isPending } = useSystemFeaturesQuery()
useEffect(() => {
setIsPending(isPending)
}, [isPending, setIsPending])
// Prefetch setupStatus for AppInitializer (result not needed here)
useSetupStatusQuery()
if (isPending)
return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>

View File

@@ -10,7 +10,7 @@ import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/
import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control'
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
import { useGlobalPublicStore } from './global-public-context'
import { useIsSystemFeaturesPending } from './global-public-context'
type WebAppStore = {
shareCode: string | null
@@ -65,7 +65,7 @@ const getShareCodeFromPathname = (pathname: string): string | null => {
}
const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending)
const isGlobalPending = useIsSystemFeaturesPending()
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
const updateShareCode = useWebAppStore(state => state.updateShareCode)
const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId)

View File

@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGlobalPublicStore, useIsSystemFeaturesPending } from '@/context/global-public-context'
/**
* Test suite for useDocumentTitle hook
*
@@ -15,6 +15,14 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { defaultSystemFeatures } from '@/types/feature'
import useDocumentTitle from './use-document-title'
vi.mock('@/context/global-public-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/global-public-context')>()
return {
...actual,
useIsSystemFeaturesPending: vi.fn(() => false),
}
})
vi.mock('@/service/common', () => ({
getSystemFeatures: vi.fn(() => ({ ...defaultSystemFeatures })),
}))
@@ -24,10 +32,12 @@ vi.mock('@/service/common', () => ({
* Title should remain empty to prevent flicker
*/
describe('title should be empty if systemFeatures is pending', () => {
act(() => {
useGlobalPublicStore.setState({
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
isGlobalPending: true,
beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(true)
act(() => {
useGlobalPublicStore.setState({
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
})
})
})
/**
@@ -52,9 +62,9 @@ describe('title should be empty if systemFeatures is pending', () => {
*/
describe('use default branding', () => {
beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(false)
act(() => {
useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
})
})
@@ -84,9 +94,9 @@ describe('use default branding', () => {
*/
describe('use specific branding', () => {
beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(false)
act(() => {
useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } },
})
})

View File

@@ -1,11 +1,11 @@
'use client'
import { useFavicon, useTitle } from 'ahooks'
import { useEffect } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGlobalPublicStore, useIsSystemFeaturesPending } from '@/context/global-public-context'
import { basePath } from '@/utils/var'
export default function useDocumentTitle(title: string) {
const isPending = useGlobalPublicStore(s => s.isGlobalPending)
const isPending = useIsSystemFeaturesPending()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const prefix = title ? `${title} - ` : ''
let titleStr = ''

View File

@@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.11.2",
"version": "1.11.3",
"private": true,
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"imports": {

231
web/service/base.spec.ts Normal file
View File

@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { handleStream } from './base'
describe('handleStream', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Invalid response data handling', () => {
it('should handle null bufferObj from JSON.parse gracefully', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
// Create a mock response that returns 'data: null'
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode('data: null\n'),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('', true, {
conversationId: undefined,
messageId: '',
errorMessage: 'Invalid response data',
errorCode: 'invalid_data',
})
expect(onCompleted).toHaveBeenCalledWith(true, 'Invalid response data')
})
it('should handle non-object bufferObj from JSON.parse gracefully', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
// Create a mock response that returns a primitive value
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode('data: "string"\n'),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('', true, {
conversationId: undefined,
messageId: '',
errorMessage: 'Invalid response data',
errorCode: 'invalid_data',
})
expect(onCompleted).toHaveBeenCalledWith(true, 'Invalid response data')
})
it('should handle valid message event correctly', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
const validMessage = {
event: 'message',
answer: 'Hello world',
conversation_id: 'conv-123',
task_id: 'task-456',
id: 'msg-789',
}
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode(`data: ${JSON.stringify(validMessage)}\n`),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('Hello world', true, {
conversationId: 'conv-123',
taskId: 'task-456',
messageId: 'msg-789',
})
expect(onCompleted).toHaveBeenCalled()
})
it('should handle error status 400 correctly', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
const errorMessage = {
status: 400,
message: 'Bad request',
code: 'bad_request',
}
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode(`data: ${JSON.stringify(errorMessage)}\n`),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert
expect(onData).toHaveBeenCalledWith('', false, {
conversationId: undefined,
messageId: '',
errorMessage: 'Bad request',
errorCode: 'bad_request',
})
expect(onCompleted).toHaveBeenCalledWith(true, 'Bad request')
})
it('should handle malformed JSON gracefully', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode('data: {invalid json}\n'),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// Act
handleStream(mockResponse, onData, onCompleted)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert - malformed JSON triggers the catch block which calls onData and returns
expect(onData).toHaveBeenCalled()
expect(onCompleted).toHaveBeenCalled()
})
it('should throw error when response is not ok', () => {
// Arrange
const onData = vi.fn()
const mockResponse = {
ok: false,
} as unknown as Response
// Act & Assert
expect(() => handleStream(mockResponse, onData)).toThrow('Network response was not ok')
})
})
})

View File

@@ -217,6 +217,17 @@ export const handleStream = (
})
return
}
if (!bufferObj || typeof bufferObj !== 'object') {
onData('', isFirstMessage, {
conversationId: undefined,
messageId: '',
errorMessage: 'Invalid response data',
errorCode: 'invalid_data',
})
hasError = true
onCompleted?.(true, 'Invalid response data')
return
}
if (bufferObj.status === 400 || !bufferObj.event) {
onData('', false, {
conversationId: undefined,

View File

@@ -0,0 +1,139 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
import { fetchSetupStatusWithCache } from './setup-status'
vi.mock('@/service/common', () => ({
fetchSetupStatus: vi.fn(),
}))
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
describe('setup-status utilities', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
describe('fetchSetupStatusWithCache', () => {
describe('when cache exists', () => {
it('should return cached finished status without API call', async () => {
localStorage.setItem('setup_status', 'finished')
const result = await fetchSetupStatusWithCache()
expect(result).toEqual({ step: 'finished' })
expect(mockFetchSetupStatus).not.toHaveBeenCalled()
})
it('should not modify localStorage when returning cached value', async () => {
localStorage.setItem('setup_status', 'finished')
await fetchSetupStatusWithCache()
expect(localStorage.getItem('setup_status')).toBe('finished')
})
})
describe('when cache does not exist', () => {
it('should call API and cache finished status', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBe('finished')
})
it('should call API and remove cache when not finished', async () => {
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
it('should clear stale cache when API returns not_started', async () => {
localStorage.setItem('setup_status', 'some_invalid_value')
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
describe('cache edge cases', () => {
it('should call API when cache value is empty string', async () => {
localStorage.setItem('setup_status', '')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when cache value is not "finished"', async () => {
localStorage.setItem('setup_status', 'not_started')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when localStorage key does not exist', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
})
describe('API response handling', () => {
it('should preserve setup_at from API response', async () => {
const setupDate = new Date('2024-01-01')
const apiResponse: SetupStatusResponse = {
step: 'finished',
setup_at: setupDate,
}
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(result.setup_at).toEqual(setupDate)
})
it('should propagate API errors', async () => {
const apiError = new Error('Network error')
mockFetchSetupStatus.mockRejectedValue(apiError)
await expect(fetchSetupStatusWithCache()).rejects.toThrow('Network error')
})
it('should not update cache when API call fails', async () => {
mockFetchSetupStatus.mockRejectedValue(new Error('API error'))
await expect(fetchSetupStatusWithCache()).rejects.toThrow()
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
})
})

21
web/utils/setup-status.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
const SETUP_STATUS_KEY = 'setup_status'
const isSetupStatusCached = (): boolean =>
localStorage.getItem(SETUP_STATUS_KEY) === 'finished'
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
if (isSetupStatusCached())
return { step: 'finished' }
const status = await fetchSetupStatus()
if (status.step === 'finished')
localStorage.setItem(SETUP_STATUS_KEY, 'finished')
else
localStorage.removeItem(SETUP_STATUS_KEY)
return status
}