mirror of
https://github.com/langgenius/dify.git
synced 2026-08-30 17:11:50 +08:00
refactor(api): extract workspace list into layered application service (#39822)
Co-authored-by: WH-2099 <wh2099@pm.me>
This commit is contained in:
@@ -8,7 +8,36 @@ root_packages =
|
||||
extensions
|
||||
factories
|
||||
libs
|
||||
machinery
|
||||
models
|
||||
repositories
|
||||
tasks
|
||||
services
|
||||
include_external_packages = True
|
||||
|
||||
[importlinter:contract:machinery-framework-boundary]
|
||||
name = API machinery is framework neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
machinery
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-query-service-boundary]
|
||||
name = Workspace query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.workspace_query_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -172,6 +172,7 @@ def initialize_extensions(app: DifyApp):
|
||||
from context.flask_app_context import init_flask_context
|
||||
from extensions import (
|
||||
ext_app_metrics,
|
||||
ext_application_services,
|
||||
ext_blueprints,
|
||||
ext_celery,
|
||||
ext_code_based_extension,
|
||||
@@ -233,6 +234,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_enterprise_telemetry,
|
||||
ext_request_logging,
|
||||
ext_session_factory,
|
||||
ext_application_services,
|
||||
ext_oauth_bearer,
|
||||
]
|
||||
for ext in extensions:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Flask adapter for Console API admission."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Concatenate
|
||||
|
||||
from flask import Response, abort, request
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from machinery.context import RequestContext
|
||||
|
||||
_REQUEST_CONTEXT_KEY = "request_context"
|
||||
|
||||
|
||||
def console_account_admission[T, **P, R](
|
||||
*,
|
||||
editions: frozenset[DeploymentEdition] | None = None,
|
||||
require_valid_enterprise_license: bool = False,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate[T, RequestContext, P], R]],
|
||||
Callable[Concatenate[T, P], R | Response],
|
||||
]:
|
||||
"""Declare Console account admission and inject a stable RequestContext.
|
||||
|
||||
All combinations use this decorator factory. Requirements are data, while
|
||||
the execution order stays fixed: edition, setup, login/CSRF, account
|
||||
initialization, optional enterprise license, then context construction.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
view: Callable[Concatenate[T, RequestContext, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
@wraps(view)
|
||||
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if _REQUEST_CONTEXT_KEY in kwargs:
|
||||
raise RuntimeError(f"{_REQUEST_CONTEXT_KEY} is reserved for Console admission")
|
||||
|
||||
account_with_tenant = current_account_with_tenant()
|
||||
request_context = RequestContext(
|
||||
account_id=account_with_tenant.account.id,
|
||||
active_workspace_id=account_with_tenant.tenant_id,
|
||||
request_id=get_request_id(),
|
||||
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
|
||||
)
|
||||
return view(self, request_context, *args, **kwargs)
|
||||
|
||||
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
|
||||
if require_valid_enterprise_license:
|
||||
admitted = enterprise_license_required(admitted)
|
||||
admitted = account_initialization_required(admitted)
|
||||
admitted = login_required(admitted)
|
||||
admitted = setup_required(admitted)
|
||||
|
||||
if editions is None:
|
||||
return admitted
|
||||
|
||||
@wraps(view)
|
||||
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
if dify_config.DEPLOYMENT_EDITION not in editions:
|
||||
abort(404)
|
||||
return admitted(self, *args, **kwargs)
|
||||
|
||||
return enforce_edition
|
||||
|
||||
return decorator
|
||||
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.errors import (
|
||||
FilenameNotExistsError,
|
||||
FileTooLargeError,
|
||||
@@ -28,6 +27,7 @@ from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.admin import admin_required
|
||||
from controllers.console.error import AccountNotLinkTenantError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_resource_check,
|
||||
@@ -36,18 +36,16 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from libs.pagination import paginate_query
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
|
||||
from services.account_service import TenantService
|
||||
from services.billing_service import BillingService, SubscriptionPlan
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
@@ -219,58 +217,10 @@ register_response_schema_models(
|
||||
@console_ns.route("/workspaces")
|
||||
class TenantListApi(Resource):
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account):
|
||||
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
|
||||
(tenant, membership)
|
||||
for tenant, membership in TenantService.get_workspaces_for_account(current_user.id, session=session)
|
||||
if tenant.status == TenantStatus.NORMAL
|
||||
]
|
||||
tenants = [tenant for tenant, _ in tenant_rows]
|
||||
tenant_dicts = []
|
||||
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
|
||||
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED
|
||||
tenant_plans: dict[str, SubscriptionPlan] = {}
|
||||
|
||||
if is_saas:
|
||||
tenant_ids = [tenant.id for tenant in tenants]
|
||||
if tenant_ids:
|
||||
tenant_plans = BillingService.get_plan_bulk(tenant_ids)
|
||||
if not tenant_plans:
|
||||
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
|
||||
|
||||
for tenant, membership in tenant_rows:
|
||||
plan: str = CloudPlan.SANDBOX
|
||||
if is_saas:
|
||||
tenant_plan = tenant_plans.get(tenant.id)
|
||||
if tenant_plan:
|
||||
plan = tenant_plan["plan"] or CloudPlan.SANDBOX
|
||||
else:
|
||||
features = FeatureService.get_features(tenant.id, exclude_vector_space=True)
|
||||
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
|
||||
elif not is_enterprise_only:
|
||||
features = FeatureService.get_features(tenant.id, exclude_vector_space=True)
|
||||
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
|
||||
|
||||
# Create a dictionary with tenant attributes
|
||||
tenant_dict = {
|
||||
"id": tenant.id,
|
||||
"name": tenant.name,
|
||||
"status": tenant.status,
|
||||
"created_at": tenant.created_at,
|
||||
"last_opened_at": membership.last_opened_at,
|
||||
"plan": plan,
|
||||
"current": tenant.id == current_tenant_id if current_tenant_id else False,
|
||||
}
|
||||
|
||||
tenant_dicts.append(tenant_dict)
|
||||
|
||||
return dump_response(TenantListResponse, {"workspaces": tenant_dicts}), HTTPStatus.OK
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
workspaces = application_services().workspace_queries.list_for_account(request_context)
|
||||
return dump_response(TenantListResponse, {"workspaces": workspaces}), HTTPStatus.OK
|
||||
|
||||
|
||||
@console_ns.route("/all-workspaces")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Composition root for application services used by transport adapters."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.db.session_factory import get_session_maker
|
||||
from repositories.workspace_query_repository import WorkspaceQueryRepository
|
||||
from services.workspace_query_compat import LegacyWorkspacePlanGateway
|
||||
from services.workspace_query_service import WorkspaceQueryService
|
||||
|
||||
_EXTENSION_KEY = "application_services"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationServices:
|
||||
workspace_queries: WorkspaceQueryService
|
||||
|
||||
|
||||
def build_application_services(
|
||||
*,
|
||||
database_client: sessionmaker[Session],
|
||||
) -> ApplicationServices:
|
||||
return ApplicationServices(
|
||||
workspace_queries=WorkspaceQueryService(
|
||||
workspaces=WorkspaceQueryRepository(
|
||||
client=database_client,
|
||||
),
|
||||
plans=LegacyWorkspacePlanGateway(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
app.extensions[_EXTENSION_KEY] = build_application_services(
|
||||
database_client=get_session_maker(),
|
||||
)
|
||||
|
||||
|
||||
def application_services() -> ApplicationServices:
|
||||
"""Return the application services bound to the current Flask app."""
|
||||
return cast(ApplicationServices, current_app.extensions[_EXTENSION_KEY])
|
||||
+13
-7
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast, overload
|
||||
|
||||
from flask import Response, current_app, g, has_request_context, request
|
||||
from flask_login.config import EXEMPT_METHODS
|
||||
@@ -19,6 +19,13 @@ if TYPE_CHECKING:
|
||||
from models.model import EndUser
|
||||
|
||||
|
||||
class AccountWithTenant(NamedTuple):
|
||||
"""Authenticated account and its active tenant."""
|
||||
|
||||
account: Account
|
||||
tenant_id: str
|
||||
|
||||
|
||||
def _resolve_current_user() -> EndUser | Account | None:
|
||||
"""
|
||||
Resolve the current user proxy to its underlying user object.
|
||||
@@ -36,7 +43,7 @@ def _get_login_manager() -> DifyLoginManager:
|
||||
return app.login_manager
|
||||
|
||||
|
||||
def current_account_with_tenant() -> tuple[Account, str]:
|
||||
def current_account_with_tenant() -> AccountWithTenant:
|
||||
"""
|
||||
Resolve the underlying account for the current user proxy and ensure tenant context exists.
|
||||
Allows tests to supply plain Account mocks without the LocalProxy helper.
|
||||
@@ -46,7 +53,7 @@ def current_account_with_tenant() -> tuple[Account, str]:
|
||||
if not isinstance(user, Account):
|
||||
raise ValueError("current_user must be an Account instance")
|
||||
assert user.current_tenant_id is not None, "The tenant information should be loaded."
|
||||
return user, user.current_tenant_id
|
||||
return AccountWithTenant(account=user, tenant_id=user.current_tenant_id)
|
||||
|
||||
|
||||
def current_account_with_tenant_optional() -> tuple[Account | None, str | None]:
|
||||
@@ -67,7 +74,7 @@ def resolve_account_fallback(
|
||||
current_tenant_id: str | None = None,
|
||||
*,
|
||||
fallback_tenant_id: str | None = None,
|
||||
) -> tuple[Account, str]:
|
||||
) -> AccountWithTenant:
|
||||
"""
|
||||
If the provided current user and tenant ID is None, fallback to current_account_with_tenant.
|
||||
This is useful for those service layers whose controllers are not migrated to use DI for
|
||||
@@ -79,7 +86,7 @@ def resolve_account_fallback(
|
||||
tenant_id = current_tenant_id or fallback_tenant_id
|
||||
if tenant_id is None:
|
||||
raise ValueError("current_tenant_id is required when current_user is provided.")
|
||||
return current_user, tenant_id
|
||||
return AccountWithTenant(account=current_user, tenant_id=tenant_id)
|
||||
return current_account_with_tenant()
|
||||
|
||||
|
||||
@@ -92,8 +99,7 @@ def resolve_tenant_id_fallback(current_tenant_id: str | None = None) -> str:
|
||||
"""
|
||||
if current_tenant_id is not None:
|
||||
return current_tenant_id
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return tenant_id
|
||||
return current_account_with_tenant().tenant_id
|
||||
|
||||
|
||||
@overload
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Framework-neutral API machinery."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Stable values passed from API admission into application services."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class RequestContext(NamedTuple):
|
||||
request_id: str
|
||||
trace_id: str | None
|
||||
account_id: str
|
||||
active_workspace_id: str | None
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Framework-neutral errors raised by API machinery.
|
||||
|
||||
They deliberately do not carry Flask responses, Werkzeug exceptions, HTTP
|
||||
status codes, or surface-specific wire models.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class ErrorDetail(NamedTuple):
|
||||
type: str
|
||||
location: tuple[str | int, ...]
|
||||
message: str
|
||||
|
||||
|
||||
class MachineryError(Exception):
|
||||
"""Base class for failures owned by API machinery."""
|
||||
|
||||
code: str = "machinery_error"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Database repository for the workspace-list read model."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Tenant, TenantAccountJoin, TenantStatus
|
||||
from services.workspace_query_service import WorkspaceQuery, WorkspaceRecord
|
||||
|
||||
|
||||
class WorkspaceQueryRepository(WorkspaceQuery):
|
||||
def __init__(self, client: sessionmaker[Session]) -> None:
|
||||
self._client = client
|
||||
|
||||
@override
|
||||
def list_for_account(self, account_id: str) -> tuple[WorkspaceRecord, ...]:
|
||||
stmt = (
|
||||
select(
|
||||
Tenant.id,
|
||||
Tenant.name,
|
||||
Tenant.status,
|
||||
Tenant.created_at,
|
||||
TenantAccountJoin.last_opened_at,
|
||||
)
|
||||
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
|
||||
.where(
|
||||
TenantAccountJoin.account_id == account_id,
|
||||
Tenant.status == TenantStatus.NORMAL,
|
||||
)
|
||||
.order_by(Tenant.created_at.asc())
|
||||
)
|
||||
|
||||
with self._client() as session:
|
||||
rows = session.execute(stmt).all()
|
||||
return tuple(
|
||||
WorkspaceRecord(
|
||||
id=workspace_id,
|
||||
name=name,
|
||||
status=status.value,
|
||||
created_at=created_at,
|
||||
last_opened_at=last_opened_at,
|
||||
)
|
||||
for workspace_id, name, status, created_at, last_opened_at in rows
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Compatibility adapters for the workspace-list application service."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import override
|
||||
|
||||
from configs import dify_config
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from services.billing_service import BillingService
|
||||
from services.feature_service import FeatureService
|
||||
from services.workspace_query_service import WorkspacePlanGateway
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LegacyWorkspacePlanGateway(WorkspacePlanGateway):
|
||||
"""Preserve the current deployment-specific Billing/Feature behavior."""
|
||||
|
||||
@override
|
||||
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]:
|
||||
ids = tuple(workspace_ids)
|
||||
if not ids:
|
||||
return {}
|
||||
|
||||
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
|
||||
if is_enterprise_only:
|
||||
return dict.fromkeys(ids, str(CloudPlan.SANDBOX))
|
||||
|
||||
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED
|
||||
bulk_plans = BillingService.get_plan_bulk(ids) if is_saas else {}
|
||||
if is_saas and not bulk_plans:
|
||||
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
for workspace_id in ids:
|
||||
tenant_plan = bulk_plans.get(workspace_id)
|
||||
if tenant_plan:
|
||||
resolved[workspace_id] = tenant_plan["plan"] or CloudPlan.SANDBOX
|
||||
continue
|
||||
|
||||
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
|
||||
resolved[workspace_id] = features.billing.subscription.plan or CloudPlan.SANDBOX
|
||||
|
||||
return resolved
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Application service for listing workspaces visible to a Console account."""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import NamedTuple, Protocol
|
||||
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from machinery.context import RequestContext
|
||||
|
||||
|
||||
class WorkspacePlanGateway(Protocol):
|
||||
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]: ...
|
||||
|
||||
|
||||
class WorkspaceRecord(NamedTuple):
|
||||
id: str
|
||||
name: str | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_opened_at: datetime | None
|
||||
|
||||
|
||||
class WorkspaceQuery(Protocol):
|
||||
def list_for_account(self, account_id: str) -> Sequence[WorkspaceRecord]: ...
|
||||
|
||||
|
||||
class WorkspaceSummary(NamedTuple):
|
||||
id: str
|
||||
name: str | None
|
||||
plan: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_opened_at: datetime | None
|
||||
current: bool
|
||||
|
||||
|
||||
class WorkspaceQueryService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspaces: WorkspaceQuery,
|
||||
plans: WorkspacePlanGateway,
|
||||
) -> None:
|
||||
self._workspaces = workspaces
|
||||
self._plans = plans
|
||||
|
||||
def list_for_account(self, context: RequestContext) -> tuple[WorkspaceSummary, ...]:
|
||||
records = tuple(self._workspaces.list_for_account(context.account_id))
|
||||
|
||||
# The repository closes its read Session before plan resolution
|
||||
# performs Billing/Feature I/O.
|
||||
plans = self._plans.resolve_many([record.id for record in records])
|
||||
|
||||
return tuple(
|
||||
WorkspaceSummary(
|
||||
id=record.id,
|
||||
name=record.name,
|
||||
plan=plans.get(record.id, CloudPlan.SANDBOX),
|
||||
status=record.status,
|
||||
created_at=record.created_at,
|
||||
last_opened_at=record.last_opened_at,
|
||||
current=record.id == context.active_workspace_id,
|
||||
)
|
||||
for record in records
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from controllers.common.wraps import _extract_resource_id
|
||||
from controllers.console import flask_admission
|
||||
from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout
|
||||
from controllers.console.workspace.error import AccountNotInitializedError
|
||||
from controllers.console.wraps import (
|
||||
@@ -35,6 +36,8 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
with_current_user_id,
|
||||
)
|
||||
from libs.login import AccountWithTenant
|
||||
from machinery.context import RequestContext
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.dataset import RateLimitLog
|
||||
@@ -124,6 +127,45 @@ class TestAccountInitialization:
|
||||
class TestCurrentContextInjection:
|
||||
"""Test request context injection decorators."""
|
||||
|
||||
def test_console_account_admission_injects_request_context(self):
|
||||
current_user = make_account()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.flask_admission.setup_required", side_effect=lambda view: view
|
||||
) as setup_required,
|
||||
patch(
|
||||
"controllers.console.flask_admission.login_required", side_effect=lambda view: view
|
||||
) as login_required,
|
||||
patch(
|
||||
"controllers.console.flask_admission.account_initialization_required", side_effect=lambda view: view
|
||||
) as account_initialization_required,
|
||||
patch(
|
||||
"controllers.console.flask_admission.current_account_with_tenant",
|
||||
return_value=AccountWithTenant(account=current_user, tenant_id="tenant-123"),
|
||||
),
|
||||
patch("controllers.console.flask_admission.get_request_id", return_value="request-1"),
|
||||
patch("controllers.console.flask_admission.get_trace_id", return_value="trace-1"),
|
||||
):
|
||||
|
||||
class Handler:
|
||||
@flask_admission.console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
return request_context
|
||||
|
||||
with Flask(__name__).test_request_context():
|
||||
result = Handler().get()
|
||||
|
||||
assert result == RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id=current_user.id,
|
||||
active_workspace_id="tenant-123",
|
||||
)
|
||||
setup_required.assert_called_once()
|
||||
login_required.assert_called_once()
|
||||
account_initialization_required.assert_called_once()
|
||||
|
||||
def test_with_current_tenant_id_injects_tenant_id(self):
|
||||
class Handler:
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from inspect import unwrap
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -35,14 +37,19 @@ from controllers.console.workspace.workspace import (
|
||||
WorkspacePermissionResponse,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
|
||||
from repositories.workspace_query_repository import WorkspaceQueryRepository
|
||||
from services import workspace_query_compat
|
||||
from services.workspace_query_service import WorkspaceQueryService, WorkspaceRecord
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_session(sqlite_engine: Engine) -> Iterator[scoped_session[Session]]:
|
||||
"""Provide the callable scoped session expected by Flask-SQLAlchemy controllers."""
|
||||
Tenant.metadata.create_all(sqlite_engine, tables=[Tenant.__table__])
|
||||
Tenant.metadata.create_all(sqlite_engine, tables=[Tenant.__table__, TenantAccountJoin.__table__])
|
||||
session = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
|
||||
try:
|
||||
yield session
|
||||
@@ -50,6 +57,37 @@ def workspace_session(sqlite_engine: Engine) -> Iterator[scoped_session[Session]
|
||||
session.remove()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_plan_dependencies(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicMock, MagicMock]:
|
||||
get_plan_bulk = MagicMock()
|
||||
get_features = MagicMock()
|
||||
monkeypatch.setattr(workspace_query_compat.BillingService, "get_plan_bulk", get_plan_bulk)
|
||||
monkeypatch.setattr(workspace_query_compat.FeatureService, "get_features", get_features)
|
||||
return get_plan_bulk, get_features
|
||||
|
||||
|
||||
def configure_workspace_plans(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
enterprise_enabled: bool = False,
|
||||
billing_enabled: bool = True,
|
||||
edition: DeploymentEdition = DeploymentEdition.CLOUD,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
workspace_query_compat,
|
||||
"dify_config",
|
||||
SimpleNamespace(
|
||||
ENTERPRISE_ENABLED=enterprise_enabled,
|
||||
BILLING_ENABLED=billing_enabled,
|
||||
DEPLOYMENT_EDITION=edition,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def features_with_plan(plan: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(billing=SimpleNamespace(subscription=SimpleNamespace(plan=plan)))
|
||||
|
||||
|
||||
def make_account(account_id: str = "u1") -> Account:
|
||||
account = Account(name="Test User", email=f"{account_id}@example.com")
|
||||
account.id = account_id
|
||||
@@ -71,12 +109,6 @@ def make_tenant(
|
||||
return tenant
|
||||
|
||||
|
||||
def make_membership(*, last_opened_at=None) -> MagicMock:
|
||||
membership = MagicMock()
|
||||
membership.last_opened_at = last_opened_at
|
||||
return membership
|
||||
|
||||
|
||||
def make_account_with_tenant(tenant: Tenant) -> Account:
|
||||
account = make_account()
|
||||
account._current_tenant = tenant
|
||||
@@ -84,188 +116,191 @@ def make_account_with_tenant(tenant: Tenant) -> Account:
|
||||
|
||||
|
||||
class TestTenantListApi:
|
||||
def test_get_success_saas_path(self, app: Flask):
|
||||
def test_get_passes_context_and_serializes_workspaces(self):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
tenant1 = make_tenant("t1", name="Tenant 1")
|
||||
tenant2 = make_tenant("t2", name="Tenant 2")
|
||||
request_context = RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
created_at = naive_utc_now()
|
||||
last_opened_at = naive_utc_now()
|
||||
user = make_account()
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
|
||||
return_value=[(tenant1, make_membership(last_opened_at=last_opened_at)), (tenant2, make_membership())],
|
||||
workspaces = MagicMock()
|
||||
workspaces.list_for_account.return_value = (
|
||||
WorkspaceRecord(
|
||||
id="workspace-1",
|
||||
name="Workspace 1",
|
||||
status=TenantStatus.NORMAL.value,
|
||||
created_at=created_at,
|
||||
last_opened_at=last_opened_at,
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
|
||||
return_value={
|
||||
"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0},
|
||||
"t2": {"plan": CloudPlan.PROFESSIONAL, "expiration_date": 0},
|
||||
WorkspaceRecord(
|
||||
id="workspace-2",
|
||||
name=None,
|
||||
status=TenantStatus.NORMAL.value,
|
||||
created_at=created_at,
|
||||
last_opened_at=None,
|
||||
),
|
||||
)
|
||||
plans = MagicMock()
|
||||
plans.resolve_many.return_value = {"workspace-1": CloudPlan.TEAM}
|
||||
workspace_queries = WorkspaceQueryService(workspaces=workspaces, plans=plans)
|
||||
application_services_mock = SimpleNamespace(workspace_queries=workspace_queries)
|
||||
|
||||
with patch(
|
||||
"controllers.console.workspace.workspace.application_services", return_value=application_services_mock
|
||||
):
|
||||
result, status = method(api, request_context=request_context)
|
||||
|
||||
assert status == HTTPStatus.OK
|
||||
assert result == {
|
||||
"workspaces": [
|
||||
{
|
||||
"id": "workspace-1",
|
||||
"name": "Workspace 1",
|
||||
"plan": "team",
|
||||
"status": "normal",
|
||||
"created_at": int(created_at.timestamp()),
|
||||
"last_opened_at": int(last_opened_at.timestamp()),
|
||||
"current": True,
|
||||
},
|
||||
) as get_plan_bulk_mock,
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
|
||||
):
|
||||
result, status = method(api, MagicMock(), "t1", user)
|
||||
assert status == HTTPStatus.OK
|
||||
assert len(result["workspaces"]) == 2
|
||||
assert result["workspaces"][0]["current"] is True
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
|
||||
assert result["workspaces"][0]["last_opened_at"] == int(last_opened_at.timestamp())
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
|
||||
assert result["workspaces"][1]["last_opened_at"] is None
|
||||
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
|
||||
get_features_mock.assert_not_called()
|
||||
{
|
||||
"id": "workspace-2",
|
||||
"name": None,
|
||||
"plan": "sandbox",
|
||||
"status": "normal",
|
||||
"created_at": int(created_at.timestamp()),
|
||||
"last_opened_at": None,
|
||||
"current": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
workspaces.list_for_account.assert_called_once_with("account-1")
|
||||
plans.resolve_many.assert_called_once_with(["workspace-1", "workspace-2"])
|
||||
|
||||
def test_get_saas_path_partial_fallback_does_not_gate_plan_on_billing_enabled(self, app: Flask):
|
||||
"""Bulk omits a tenant: resolve plan via subscription.plan only; billing.enabled is not used.
|
||||
|
||||
billing.enabled is mocked False to prove the endpoint does not gate on it for this path
|
||||
(SaaS contract treats enabled as on; display follows subscription.plan).
|
||||
"""
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
tenant1 = make_tenant("t1", name="Tenant 1")
|
||||
tenant2 = make_tenant("t2", name="Tenant 2")
|
||||
features_t2 = MagicMock()
|
||||
features_t2.billing.enabled = False
|
||||
features_t2.billing.subscription.plan = CloudPlan.PROFESSIONAL
|
||||
user = make_account()
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
|
||||
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
|
||||
class TestWorkspaceQueryRepository:
|
||||
def test_list_for_account_filters_orders_and_maps(self, workspace_session: scoped_session[Session]):
|
||||
now = naive_utc_now()
|
||||
earlier = make_tenant("workspace-1")
|
||||
earlier.created_at = now - timedelta(days=1)
|
||||
later = make_tenant("workspace-2")
|
||||
later.created_at = now
|
||||
archived = make_tenant("workspace-3", status=TenantStatus.ARCHIVE)
|
||||
other_account = make_tenant("workspace-4")
|
||||
last_opened_at = now - timedelta(hours=1)
|
||||
workspace_session.add_all(
|
||||
[
|
||||
earlier,
|
||||
later,
|
||||
archived,
|
||||
other_account,
|
||||
TenantAccountJoin(
|
||||
tenant_id=earlier.id,
|
||||
account_id="account-1",
|
||||
last_opened_at=last_opened_at,
|
||||
),
|
||||
TenantAccountJoin(tenant_id=later.id, account_id="account-1"),
|
||||
TenantAccountJoin(tenant_id=archived.id, account_id="account-1"),
|
||||
TenantAccountJoin(tenant_id=other_account.id, account_id="account-2"),
|
||||
]
|
||||
)
|
||||
workspace_session.commit()
|
||||
|
||||
result = WorkspaceQueryRepository(workspace_session.session_factory).list_for_account("account-1")
|
||||
|
||||
assert result == (
|
||||
WorkspaceRecord(
|
||||
id=earlier.id,
|
||||
name=earlier.name,
|
||||
status=TenantStatus.NORMAL.value,
|
||||
created_at=earlier.created_at,
|
||||
last_opened_at=last_opened_at,
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
|
||||
return_value={"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0}},
|
||||
) as get_plan_bulk_mock,
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.FeatureService.get_features", return_value=features_t2
|
||||
) as get_features_mock,
|
||||
):
|
||||
result, status = method(api, MagicMock(), "t1", user)
|
||||
assert status == HTTPStatus.OK
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
|
||||
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
|
||||
get_features_mock.assert_called_once_with("t2", exclude_vector_space=True)
|
||||
|
||||
def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(
|
||||
self, app: Flask, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test fallback to FeatureService when bulk billing returns empty result.
|
||||
|
||||
BillingService.get_plan_bulk catches exceptions internally and returns empty dict,
|
||||
so we simulate the real failure mode by returning empty dict for non-empty input.
|
||||
"""
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
tenant1 = make_tenant("t1", name="Tenant 1")
|
||||
tenant2 = make_tenant("t2", name="Tenant 2")
|
||||
features = MagicMock()
|
||||
features.billing.enabled = False
|
||||
features.billing.subscription.plan = CloudPlan.TEAM
|
||||
user = make_account()
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
caplog.at_level(logging.WARNING, logger="controllers.console.workspace.workspace"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
|
||||
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
|
||||
WorkspaceRecord(
|
||||
id=later.id,
|
||||
name=later.name,
|
||||
status=TenantStatus.NORMAL.value,
|
||||
created_at=later.created_at,
|
||||
last_opened_at=None,
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.BillingService.get_plan_bulk", return_value={}
|
||||
) as get_plan_bulk_mock,
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.FeatureService.get_features", return_value=features
|
||||
) as get_features_mock,
|
||||
):
|
||||
result, status = method(api, MagicMock(), "t2", user)
|
||||
assert status == HTTPStatus.OK
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.TEAM
|
||||
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
|
||||
assert get_features_mock.call_count == 2
|
||||
)
|
||||
|
||||
|
||||
class TestLegacyWorkspacePlanGateway:
|
||||
def test_saas_uses_bulk_plans_and_feature_fallback(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
|
||||
) -> None:
|
||||
configure_workspace_plans(monkeypatch)
|
||||
get_plan_bulk, get_features = workspace_plan_dependencies
|
||||
get_plan_bulk.return_value = {"workspace-1": {"plan": CloudPlan.TEAM, "expiration_date": 0}}
|
||||
get_features.return_value = features_with_plan(CloudPlan.PROFESSIONAL)
|
||||
|
||||
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
|
||||
|
||||
assert result == {"workspace-1": CloudPlan.TEAM, "workspace-2": CloudPlan.PROFESSIONAL}
|
||||
get_plan_bulk.assert_called_once()
|
||||
assert list(get_plan_bulk.call_args.args[0]) == ["workspace-1", "workspace-2"]
|
||||
get_features.assert_called_once_with("workspace-2", exclude_vector_space=True)
|
||||
|
||||
def test_saas_empty_bulk_result_falls_back_to_features(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
configure_workspace_plans(monkeypatch)
|
||||
get_plan_bulk, get_features = workspace_plan_dependencies
|
||||
get_plan_bulk.return_value = {}
|
||||
get_features.return_value = features_with_plan(CloudPlan.TEAM)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=workspace_query_compat.__name__):
|
||||
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
|
||||
|
||||
assert result == {"workspace-1": CloudPlan.TEAM, "workspace-2": CloudPlan.TEAM}
|
||||
assert "get_plan_bulk returned empty result, falling back to legacy feature path" in caplog.messages
|
||||
|
||||
def test_get_billing_disabled_community_path(self, app: Flask):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
tenant = make_tenant("t1", name="Tenant")
|
||||
features = MagicMock()
|
||||
features.billing.enabled = False
|
||||
features.billing.subscription.plan = CloudPlan.SANDBOX
|
||||
user = make_account()
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
|
||||
return_value=[(tenant, make_membership())],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.FeatureService.get_features", return_value=features
|
||||
) as get_features_mock,
|
||||
):
|
||||
result, status = method(api, MagicMock(), "t1", user)
|
||||
assert status == HTTPStatus.OK
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
|
||||
get_features_mock.assert_called_once_with("t1", exclude_vector_space=True)
|
||||
def test_non_saas_uses_features(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
|
||||
) -> None:
|
||||
configure_workspace_plans(
|
||||
monkeypatch,
|
||||
billing_enabled=False,
|
||||
edition=DeploymentEdition.COMMUNITY,
|
||||
)
|
||||
get_plan_bulk, get_features = workspace_plan_dependencies
|
||||
get_features.return_value = features_with_plan(CloudPlan.SANDBOX)
|
||||
|
||||
def test_get_enterprise_only_skips_feature_service(self, app: Flask):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
tenant1 = make_tenant("t1", name="Tenant 1")
|
||||
tenant2 = make_tenant("t2", name="Tenant 2")
|
||||
user = make_account()
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
|
||||
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
|
||||
),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
|
||||
):
|
||||
result, status = method(api, MagicMock(), "t2", user)
|
||||
assert status == HTTPStatus.OK
|
||||
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
|
||||
assert result["workspaces"][1]["plan"] == CloudPlan.SANDBOX
|
||||
assert result["workspaces"][0]["current"] is False
|
||||
assert result["workspaces"][1]["current"] is True
|
||||
get_features_mock.assert_not_called()
|
||||
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1"])
|
||||
|
||||
def test_get_enterprise_only_with_empty_tenants(self, app: Flask):
|
||||
api = TenantListApi()
|
||||
method = unwrap(api.get)
|
||||
user = make_account()
|
||||
with (
|
||||
app.test_request_context("/workspaces"),
|
||||
patch("controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", return_value=[]),
|
||||
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
|
||||
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
|
||||
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
|
||||
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
|
||||
):
|
||||
result, status = method(api, MagicMock(), None, user)
|
||||
assert status == HTTPStatus.OK
|
||||
assert result["workspaces"] == []
|
||||
get_features_mock.assert_not_called()
|
||||
assert result == {"workspace-1": CloudPlan.SANDBOX}
|
||||
get_plan_bulk.assert_not_called()
|
||||
get_features.assert_called_once_with("workspace-1", exclude_vector_space=True)
|
||||
|
||||
def test_enterprise_only_skips_external_lookups(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
|
||||
) -> None:
|
||||
configure_workspace_plans(
|
||||
monkeypatch,
|
||||
enterprise_enabled=True,
|
||||
billing_enabled=False,
|
||||
edition=DeploymentEdition.ENTERPRISE,
|
||||
)
|
||||
get_plan_bulk, get_features = workspace_plan_dependencies
|
||||
|
||||
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
|
||||
|
||||
assert result == {"workspace-1": CloudPlan.SANDBOX, "workspace-2": CloudPlan.SANDBOX}
|
||||
get_plan_bulk.assert_not_called()
|
||||
get_features.assert_not_called()
|
||||
|
||||
|
||||
class TestWorkspaceListApi:
|
||||
|
||||
@@ -266,10 +266,10 @@ class TestCurrentAccountWithTenant:
|
||||
current_user_proxy._get_current_object.return_value = account
|
||||
mocker.patch.object(login_module, "current_user", new=current_user_proxy)
|
||||
|
||||
user, tenant_id = login_module.current_account_with_tenant()
|
||||
account_with_tenant = login_module.current_account_with_tenant()
|
||||
|
||||
assert user is account
|
||||
assert tenant_id == "tenant-123"
|
||||
assert account_with_tenant.account is account
|
||||
assert account_with_tenant.tenant_id == "tenant-123"
|
||||
current_user_proxy._get_current_object.assert_called_once_with()
|
||||
|
||||
def test_raises_when_current_user_is_not_account(self, mocker: MockerFixture):
|
||||
@@ -334,7 +334,11 @@ class TestResolveTenantIdFallback:
|
||||
tenant = Tenant(name="Test Tenant")
|
||||
tenant.id = "tenant-123"
|
||||
account._current_tenant = tenant
|
||||
mocker.patch.object(login_module, "current_account_with_tenant", return_value=(account, tenant.id))
|
||||
mocker.patch.object(
|
||||
login_module,
|
||||
"current_account_with_tenant",
|
||||
return_value=login_module.AccountWithTenant(account=account, tenant_id=tenant.id),
|
||||
)
|
||||
|
||||
tenant_id = login_module.resolve_tenant_id_fallback()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user