mirror of
https://github.com/langgenius/dify.git
synced 2026-02-10 18:34:08 +00:00
Compare commits
4 Commits
test/billi
...
inject-con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b41aa525 | ||
|
|
1819bd72ef | ||
|
|
c2a11ffa97 | ||
|
|
5eaf535a0d |
@@ -114,7 +114,6 @@ ignore_imports =
|
||||
core.workflow.nodes.datasource.datasource_node -> models.model
|
||||
core.workflow.nodes.datasource.datasource_node -> models.tools
|
||||
core.workflow.nodes.datasource.datasource_node -> services.datasource_provider_service
|
||||
core.workflow.nodes.document_extractor.node -> configs
|
||||
core.workflow.nodes.document_extractor.node -> core.file.file_manager
|
||||
core.workflow.nodes.document_extractor.node -> core.helper.ssrf_proxy
|
||||
core.workflow.nodes.http_request.entities -> configs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import TYPE_CHECKING, final
|
||||
from typing import TYPE_CHECKING, Any, cast, final
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
@@ -16,6 +16,7 @@ from core.workflow.graph.graph import NodeFactory
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from core.workflow.nodes.code.code_node import CodeNode
|
||||
from core.workflow.nodes.code.limits import CodeNodeLimits
|
||||
from core.workflow.nodes.document_extractor import DocumentExtractorNode, UnstructuredApiConfig
|
||||
from core.workflow.nodes.http_request.node import HttpRequestNode
|
||||
from core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node import KnowledgeRetrievalNode
|
||||
from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
|
||||
@@ -53,6 +54,7 @@ class DifyNodeFactory(NodeFactory):
|
||||
http_request_http_client: HttpClientProtocol | None = None,
|
||||
http_request_tool_file_manager_factory: Callable[[], ToolFileManager] = ToolFileManager,
|
||||
http_request_file_manager: FileManagerProtocol | None = None,
|
||||
document_extractor_unstructured_api_config: UnstructuredApiConfig | None = None,
|
||||
) -> None:
|
||||
self.graph_init_params = graph_init_params
|
||||
self.graph_runtime_state = graph_runtime_state
|
||||
@@ -78,6 +80,13 @@ class DifyNodeFactory(NodeFactory):
|
||||
self._http_request_tool_file_manager_factory = http_request_tool_file_manager_factory
|
||||
self._http_request_file_manager = http_request_file_manager or file_manager
|
||||
self._rag_retrieval = DatasetRetrieval()
|
||||
self._document_extractor_unstructured_api_config = (
|
||||
document_extractor_unstructured_api_config
|
||||
or UnstructuredApiConfig(
|
||||
api_url=dify_config.UNSTRUCTURED_API_URL,
|
||||
api_key=dify_config.UNSTRUCTURED_API_KEY or "",
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def create_node(self, node_config: NodeConfigDict) -> Node:
|
||||
@@ -110,13 +119,17 @@ class DifyNodeFactory(NodeFactory):
|
||||
if not node_class:
|
||||
raise ValueError(f"No latest version class found for node type: {node_type}")
|
||||
|
||||
common_kwargs: dict[str, Any] = {
|
||||
"id": node_id,
|
||||
"config": node_config,
|
||||
"graph_init_params": self.graph_init_params,
|
||||
"graph_runtime_state": self.graph_runtime_state,
|
||||
}
|
||||
|
||||
# Create node instance
|
||||
if node_type == NodeType.CODE:
|
||||
return CodeNode(
|
||||
id=node_id,
|
||||
config=node_config,
|
||||
graph_init_params=self.graph_init_params,
|
||||
graph_runtime_state=self.graph_runtime_state,
|
||||
**common_kwargs,
|
||||
code_executor=self._code_executor,
|
||||
code_providers=self._code_providers,
|
||||
code_limits=self._code_limits,
|
||||
@@ -124,20 +137,14 @@ class DifyNodeFactory(NodeFactory):
|
||||
|
||||
if node_type == NodeType.TEMPLATE_TRANSFORM:
|
||||
return TemplateTransformNode(
|
||||
id=node_id,
|
||||
config=node_config,
|
||||
graph_init_params=self.graph_init_params,
|
||||
graph_runtime_state=self.graph_runtime_state,
|
||||
**common_kwargs,
|
||||
template_renderer=self._template_renderer,
|
||||
max_output_length=self._template_transform_max_output_length,
|
||||
)
|
||||
|
||||
if node_type == NodeType.HTTP_REQUEST:
|
||||
return HttpRequestNode(
|
||||
id=node_id,
|
||||
config=node_config,
|
||||
graph_init_params=self.graph_init_params,
|
||||
graph_runtime_state=self.graph_runtime_state,
|
||||
**common_kwargs,
|
||||
http_client=self._http_request_http_client,
|
||||
tool_file_manager_factory=self._http_request_tool_file_manager_factory,
|
||||
file_manager=self._http_request_file_manager,
|
||||
@@ -145,16 +152,15 @@ class DifyNodeFactory(NodeFactory):
|
||||
|
||||
if node_type == NodeType.KNOWLEDGE_RETRIEVAL:
|
||||
return KnowledgeRetrievalNode(
|
||||
id=node_id,
|
||||
config=node_config,
|
||||
graph_init_params=self.graph_init_params,
|
||||
graph_runtime_state=self.graph_runtime_state,
|
||||
**common_kwargs,
|
||||
rag_retrieval=self._rag_retrieval,
|
||||
)
|
||||
|
||||
return node_class(
|
||||
id=node_id,
|
||||
config=node_config,
|
||||
graph_init_params=self.graph_init_params,
|
||||
graph_runtime_state=self.graph_runtime_state,
|
||||
)
|
||||
if node_type == NodeType.DOCUMENT_EXTRACTOR:
|
||||
document_extractor_class = cast(type[DocumentExtractorNode], node_class)
|
||||
return document_extractor_class(
|
||||
**common_kwargs,
|
||||
unstructured_api_config=self._document_extractor_unstructured_api_config,
|
||||
)
|
||||
|
||||
return node_class(**common_kwargs)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .entities import DocumentExtractorNodeData
|
||||
from .entities import DocumentExtractorNodeData, UnstructuredApiConfig
|
||||
from .node import DocumentExtractorNode
|
||||
|
||||
__all__ = ["DocumentExtractorNode", "DocumentExtractorNodeData"]
|
||||
__all__ = ["DocumentExtractorNode", "DocumentExtractorNodeData", "UnstructuredApiConfig"]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.workflow.nodes.base import BaseNodeData
|
||||
|
||||
|
||||
class DocumentExtractorNodeData(BaseNodeData):
|
||||
variable_selector: Sequence[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnstructuredApiConfig:
|
||||
api_url: str | None = None
|
||||
api_key: str = ""
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import charset_normalizer
|
||||
import docx
|
||||
@@ -20,7 +20,6 @@ from docx.oxml.text.paragraph import CT_P
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
from configs import dify_config
|
||||
from core.file import File, FileTransferMethod, file_manager
|
||||
from core.helper import ssrf_proxy
|
||||
from core.variables import ArrayFileSegment
|
||||
@@ -29,11 +28,15 @@ from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus
|
||||
from core.workflow.node_events import NodeRunResult
|
||||
from core.workflow.nodes.base.node import Node
|
||||
|
||||
from .entities import DocumentExtractorNodeData
|
||||
from .entities import DocumentExtractorNodeData, UnstructuredApiConfig
|
||||
from .exc import DocumentExtractorError, FileDownloadError, TextExtractionError, UnsupportedFileTypeError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.workflow.entities import GraphInitParams
|
||||
from core.workflow.runtime import GraphRuntimeState
|
||||
|
||||
|
||||
class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
"""
|
||||
@@ -47,6 +50,23 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
config: Mapping[str, Any],
|
||||
graph_init_params: "GraphInitParams",
|
||||
graph_runtime_state: "GraphRuntimeState",
|
||||
*,
|
||||
unstructured_api_config: UnstructuredApiConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
id=id,
|
||||
config=config,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
)
|
||||
self._unstructured_api_config = unstructured_api_config or UnstructuredApiConfig()
|
||||
|
||||
def _run(self):
|
||||
variable_selector = self.node_data.variable_selector
|
||||
variable = self.graph_runtime_state.variable_pool.get(variable_selector)
|
||||
@@ -64,7 +84,10 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
|
||||
try:
|
||||
if isinstance(value, list):
|
||||
extracted_text_list = list(map(_extract_text_from_file, value))
|
||||
extracted_text_list = [
|
||||
_extract_text_from_file(file, unstructured_api_config=self._unstructured_api_config)
|
||||
for file in value
|
||||
]
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs=inputs,
|
||||
@@ -72,7 +95,7 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
outputs={"text": ArrayStringSegment(value=extracted_text_list)},
|
||||
)
|
||||
elif isinstance(value, File):
|
||||
extracted_text = _extract_text_from_file(value)
|
||||
extracted_text = _extract_text_from_file(value, unstructured_api_config=self._unstructured_api_config)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs=inputs,
|
||||
@@ -103,7 +126,12 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
return {node_id + ".files": typed_node_data.variable_selector}
|
||||
|
||||
|
||||
def _extract_text_by_mime_type(*, file_content: bytes, mime_type: str) -> str:
|
||||
def _extract_text_by_mime_type(
|
||||
*,
|
||||
file_content: bytes,
|
||||
mime_type: str,
|
||||
unstructured_api_config: UnstructuredApiConfig,
|
||||
) -> str:
|
||||
"""Extract text from a file based on its MIME type."""
|
||||
match mime_type:
|
||||
case "text/plain" | "text/html" | "text/htm" | "text/markdown" | "text/xml":
|
||||
@@ -111,7 +139,7 @@ def _extract_text_by_mime_type(*, file_content: bytes, mime_type: str) -> str:
|
||||
case "application/pdf":
|
||||
return _extract_text_from_pdf(file_content)
|
||||
case "application/msword":
|
||||
return _extract_text_from_doc(file_content)
|
||||
return _extract_text_from_doc(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
return _extract_text_from_docx(file_content)
|
||||
case "text/csv":
|
||||
@@ -119,11 +147,11 @@ def _extract_text_by_mime_type(*, file_content: bytes, mime_type: str) -> str:
|
||||
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | "application/vnd.ms-excel":
|
||||
return _extract_text_from_excel(file_content)
|
||||
case "application/vnd.ms-powerpoint":
|
||||
return _extract_text_from_ppt(file_content)
|
||||
return _extract_text_from_ppt(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
return _extract_text_from_pptx(file_content)
|
||||
return _extract_text_from_pptx(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case "application/epub+zip":
|
||||
return _extract_text_from_epub(file_content)
|
||||
return _extract_text_from_epub(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case "message/rfc822":
|
||||
return _extract_text_from_eml(file_content)
|
||||
case "application/vnd.ms-outlook":
|
||||
@@ -140,7 +168,12 @@ def _extract_text_by_mime_type(*, file_content: bytes, mime_type: str) -> str:
|
||||
raise UnsupportedFileTypeError(f"Unsupported MIME type: {mime_type}")
|
||||
|
||||
|
||||
def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str) -> str:
|
||||
def _extract_text_by_file_extension(
|
||||
*,
|
||||
file_content: bytes,
|
||||
file_extension: str,
|
||||
unstructured_api_config: UnstructuredApiConfig,
|
||||
) -> str:
|
||||
"""Extract text from a file based on its file extension."""
|
||||
match file_extension:
|
||||
case (
|
||||
@@ -203,7 +236,7 @@ def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str)
|
||||
case ".pdf":
|
||||
return _extract_text_from_pdf(file_content)
|
||||
case ".doc":
|
||||
return _extract_text_from_doc(file_content)
|
||||
return _extract_text_from_doc(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case ".docx":
|
||||
return _extract_text_from_docx(file_content)
|
||||
case ".csv":
|
||||
@@ -211,11 +244,11 @@ def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str)
|
||||
case ".xls" | ".xlsx":
|
||||
return _extract_text_from_excel(file_content)
|
||||
case ".ppt":
|
||||
return _extract_text_from_ppt(file_content)
|
||||
return _extract_text_from_ppt(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case ".pptx":
|
||||
return _extract_text_from_pptx(file_content)
|
||||
return _extract_text_from_pptx(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case ".epub":
|
||||
return _extract_text_from_epub(file_content)
|
||||
return _extract_text_from_epub(file_content, unstructured_api_config=unstructured_api_config)
|
||||
case ".eml":
|
||||
return _extract_text_from_eml(file_content)
|
||||
case ".msg":
|
||||
@@ -312,14 +345,14 @@ def _extract_text_from_pdf(file_content: bytes) -> str:
|
||||
raise TextExtractionError(f"Failed to extract text from PDF: {str(e)}") from e
|
||||
|
||||
|
||||
def _extract_text_from_doc(file_content: bytes) -> str:
|
||||
def _extract_text_from_doc(file_content: bytes, *, unstructured_api_config: UnstructuredApiConfig) -> str:
|
||||
"""
|
||||
Extract text from a DOC file.
|
||||
"""
|
||||
from unstructured.partition.api import partition_via_api
|
||||
|
||||
if not dify_config.UNSTRUCTURED_API_URL:
|
||||
raise TextExtractionError("UNSTRUCTURED_API_URL must be set")
|
||||
if not unstructured_api_config.api_url:
|
||||
raise TextExtractionError("Unstructured API URL is not configured for DOC file processing.")
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as temp_file:
|
||||
@@ -329,8 +362,8 @@ def _extract_text_from_doc(file_content: bytes) -> str:
|
||||
elements = partition_via_api(
|
||||
file=file,
|
||||
metadata_filename=temp_file.name,
|
||||
api_url=dify_config.UNSTRUCTURED_API_URL,
|
||||
api_key=dify_config.UNSTRUCTURED_API_KEY, # type: ignore
|
||||
api_url=unstructured_api_config.api_url,
|
||||
api_key=unstructured_api_config.api_key,
|
||||
)
|
||||
os.unlink(temp_file.name)
|
||||
return "\n".join([getattr(element, "text", "") for element in elements])
|
||||
@@ -420,12 +453,20 @@ def _download_file_content(file: File) -> bytes:
|
||||
raise FileDownloadError(f"Error downloading file: {str(e)}") from e
|
||||
|
||||
|
||||
def _extract_text_from_file(file: File):
|
||||
def _extract_text_from_file(file: File, *, unstructured_api_config: UnstructuredApiConfig) -> str:
|
||||
file_content = _download_file_content(file)
|
||||
if file.extension:
|
||||
extracted_text = _extract_text_by_file_extension(file_content=file_content, file_extension=file.extension)
|
||||
extracted_text = _extract_text_by_file_extension(
|
||||
file_content=file_content,
|
||||
file_extension=file.extension,
|
||||
unstructured_api_config=unstructured_api_config,
|
||||
)
|
||||
elif file.mime_type:
|
||||
extracted_text = _extract_text_by_mime_type(file_content=file_content, mime_type=file.mime_type)
|
||||
extracted_text = _extract_text_by_mime_type(
|
||||
file_content=file_content,
|
||||
mime_type=file.mime_type,
|
||||
unstructured_api_config=unstructured_api_config,
|
||||
)
|
||||
else:
|
||||
raise UnsupportedFileTypeError("Unable to determine file type: MIME type or file extension is missing")
|
||||
return extracted_text
|
||||
@@ -517,12 +558,12 @@ def _extract_text_from_excel(file_content: bytes) -> str:
|
||||
raise TextExtractionError(f"Failed to extract text from Excel file: {str(e)}") from e
|
||||
|
||||
|
||||
def _extract_text_from_ppt(file_content: bytes) -> str:
|
||||
def _extract_text_from_ppt(file_content: bytes, *, unstructured_api_config: UnstructuredApiConfig) -> str:
|
||||
from unstructured.partition.api import partition_via_api
|
||||
from unstructured.partition.ppt import partition_ppt
|
||||
|
||||
try:
|
||||
if dify_config.UNSTRUCTURED_API_URL:
|
||||
if unstructured_api_config.api_url:
|
||||
with tempfile.NamedTemporaryFile(suffix=".ppt", delete=False) as temp_file:
|
||||
temp_file.write(file_content)
|
||||
temp_file.flush()
|
||||
@@ -530,8 +571,8 @@ def _extract_text_from_ppt(file_content: bytes) -> str:
|
||||
elements = partition_via_api(
|
||||
file=file,
|
||||
metadata_filename=temp_file.name,
|
||||
api_url=dify_config.UNSTRUCTURED_API_URL,
|
||||
api_key=dify_config.UNSTRUCTURED_API_KEY, # type: ignore
|
||||
api_url=unstructured_api_config.api_url,
|
||||
api_key=unstructured_api_config.api_key,
|
||||
)
|
||||
os.unlink(temp_file.name)
|
||||
else:
|
||||
@@ -543,12 +584,12 @@ def _extract_text_from_ppt(file_content: bytes) -> str:
|
||||
raise TextExtractionError(f"Failed to extract text from PPTX: {str(e)}") from e
|
||||
|
||||
|
||||
def _extract_text_from_pptx(file_content: bytes) -> str:
|
||||
def _extract_text_from_pptx(file_content: bytes, *, unstructured_api_config: UnstructuredApiConfig) -> str:
|
||||
from unstructured.partition.api import partition_via_api
|
||||
from unstructured.partition.pptx import partition_pptx
|
||||
|
||||
try:
|
||||
if dify_config.UNSTRUCTURED_API_URL:
|
||||
if unstructured_api_config.api_url:
|
||||
with tempfile.NamedTemporaryFile(suffix=".pptx", delete=False) as temp_file:
|
||||
temp_file.write(file_content)
|
||||
temp_file.flush()
|
||||
@@ -556,8 +597,8 @@ def _extract_text_from_pptx(file_content: bytes) -> str:
|
||||
elements = partition_via_api(
|
||||
file=file,
|
||||
metadata_filename=temp_file.name,
|
||||
api_url=dify_config.UNSTRUCTURED_API_URL,
|
||||
api_key=dify_config.UNSTRUCTURED_API_KEY, # type: ignore
|
||||
api_url=unstructured_api_config.api_url,
|
||||
api_key=unstructured_api_config.api_key,
|
||||
)
|
||||
os.unlink(temp_file.name)
|
||||
else:
|
||||
@@ -568,12 +609,12 @@ def _extract_text_from_pptx(file_content: bytes) -> str:
|
||||
raise TextExtractionError(f"Failed to extract text from PPTX: {str(e)}") from e
|
||||
|
||||
|
||||
def _extract_text_from_epub(file_content: bytes) -> str:
|
||||
def _extract_text_from_epub(file_content: bytes, *, unstructured_api_config: UnstructuredApiConfig) -> str:
|
||||
from unstructured.partition.api import partition_via_api
|
||||
from unstructured.partition.epub import partition_epub
|
||||
|
||||
try:
|
||||
if dify_config.UNSTRUCTURED_API_URL:
|
||||
if unstructured_api_config.api_url:
|
||||
with tempfile.NamedTemporaryFile(suffix=".epub", delete=False) as temp_file:
|
||||
temp_file.write(file_content)
|
||||
temp_file.flush()
|
||||
@@ -581,8 +622,8 @@ def _extract_text_from_epub(file_content: bytes) -> str:
|
||||
elements = partition_via_api(
|
||||
file=file,
|
||||
metadata_filename=temp_file.name,
|
||||
api_url=dify_config.UNSTRUCTURED_API_URL,
|
||||
api_key=dify_config.UNSTRUCTURED_API_KEY, # type: ignore
|
||||
api_url=unstructured_api_config.api_url,
|
||||
api_key=unstructured_api_config.api_key,
|
||||
)
|
||||
os.unlink(temp_file.name)
|
||||
else:
|
||||
|
||||
@@ -1,996 +0,0 @@
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Module-level mock state ────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
// ─── Context mocks ──────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ──────────────────────────────────────────────────────────
|
||||
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
const mockRouterPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
isShow ? <div data-testid="verify-state-modal" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
mailToSupport: () => 'mailto:support@test.com',
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Imports (after mocks) ──────────────────────────────────────────────────
|
||||
// These must be imported after all vi.mock() calls
|
||||
/* eslint-disable import/first */
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import Billing from '@/app/components/billing/billing-page'
|
||||
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
|
||||
import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
/* eslint-enable import/first */
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Billing Page + Plan Component Integration
|
||||
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Billing Page + Plan Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Verify that the billing page renders PlanComp with all 7 usage items
|
||||
describe('Rendering complete plan information', () => {
|
||||
it('should display all 7 usage metrics for sandbox plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 3,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 10,
|
||||
vectorSpace: 20,
|
||||
annotatedResponse: 5,
|
||||
triggerEvents: 1000,
|
||||
apiRateLimit: 2000,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
// Plan name
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
|
||||
// All 7 usage items should be visible
|
||||
expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display usage values as "usage / total" format', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 3, teamMembers: 1 },
|
||||
total: { buildApps: 5, teamMembers: 1 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Check that the buildApps usage fraction "3 / 5" is rendered
|
||||
const usageContainers = screen.getAllByText('3')
|
||||
expect(usageContainers.length).toBeGreaterThan(0)
|
||||
const totalContainers = screen.getAllByText('5')
|
||||
expect(totalContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { apiRateLimit: NUM_INFINITE },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display reset days for trigger events when applicable', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 7 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Reset text should be visible
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: true })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when user is not workspace manager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. Plan Type Display Integration
|
||||
// Tests that different plan types render correct visual elements
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Plan Type Display Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render sandbox plan with upgrade button (premium badge)', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument()
|
||||
// Sandbox shows premium badge upgrade button (not plain)
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render professional plan with plain upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
// Professional shows plain button because it's not team
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render team plan with plain-style upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
// Team plan has isPlain=true, so shows "upgradeBtn.plain" text
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render upgrade button for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. Upgrade Flow Integration
|
||||
// Tests the flow: UpgradeBtn click → setShowPricingModal
|
||||
// and PlanUpgradeModal → close + trigger pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Upgrade Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
})
|
||||
|
||||
// UpgradeBtn triggers pricing modal
|
||||
describe('UpgradeBtn triggers pricing modal', () => {
|
||||
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
|
||||
const customOnClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn onClick={customOnClick} />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(customOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire gtag event with loc parameter when clicked', async () => {
|
||||
const mockGtag = vi.fn()
|
||||
;(window as unknown as Record<string, unknown>).gtag = mockGtag
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn loc="billing-page" />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' })
|
||||
delete (window as unknown as Record<string, unknown>).gtag
|
||||
})
|
||||
})
|
||||
|
||||
// PlanUpgradeModal integration: close modal and trigger pricing
|
||||
describe('PlanUpgradeModal upgrade flow', () => {
|
||||
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Upgrade Required"
|
||||
description="You need a better plan"
|
||||
/>,
|
||||
)
|
||||
|
||||
// The modal should show title and description
|
||||
expect(screen.getByText('Upgrade Required')).toBeInTheDocument()
|
||||
expect(screen.getByText('You need a better plan')).toBeInTheDocument()
|
||||
|
||||
// Click the upgrade button inside the modal
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
// Should close the current modal first
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// Then open pricing modal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose and custom onUpgrade when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
// Custom onUpgrade replaces default setShowPricingModal
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when clicking dismiss button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i)
|
||||
await user.click(dismissBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing
|
||||
describe('PlanComp upgrade button triggers pricing', () => {
|
||||
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test-loc" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Capacity Full Components Integration
|
||||
// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal
|
||||
// with real child components (UsageInfo, ProgressBar, UpgradeBtn)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Capacity Full Components Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// AppsFull renders with correct messaging and components
|
||||
describe('AppsFull integration', () => {
|
||||
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Should show "full" tip
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have a progress bar rendered
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { buildApps: 48 },
|
||||
total: { buildApps: 50 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display contact tip and contact button for team plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.team,
|
||||
usage: { buildApps: 200 },
|
||||
total: { buildApps: 200 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Team plan shows different tip
|
||||
expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument()
|
||||
// Team plan shows "Contact Us" instead of upgrade
|
||||
expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render progress bar with correct color based on usage percentage', () => {
|
||||
// 100% usage should show error color
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
|
||||
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
|
||||
describe('VectorSpaceFull integration', () => {
|
||||
it('should display full tip, upgrade button, and vector space usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
// Should show full tip
|
||||
expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Should show vector space usage info
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFull renders with Usage component and UpgradeBtn
|
||||
describe('AnnotationFull integration', () => {
|
||||
it('should display annotation full tip, upgrade button, and usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument()
|
||||
// UpgradeBtn rendered
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Usage component should show annotation quota
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFullModal shows modal with usage and upgrade button
|
||||
describe('AnnotationFullModal integration', () => {
|
||||
it('should render modal with annotation info and upgrade button when show is true', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when show is false', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
|
||||
describe('TriggerEventsLimitModal integration', () => {
|
||||
it('should display trigger limit title, usage info, and upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={vi.fn()}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={18000}
|
||||
total={20000}
|
||||
resetInDays={5}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Modal title and description
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
// Embedded UsageInfo with trigger events data
|
||||
expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('18000')).toBeInTheDocument()
|
||||
expect(screen.getByText('20000')).toBeInTheDocument()
|
||||
// Reset info
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
// Upgrade and dismiss buttons
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose and onUpgrade when clicking upgrade', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Header Billing Button Integration
|
||||
// Tests HeaderBillingBtn behavior for different plan states
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Header Billing Button Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "pro" badge for professional plan', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('pro')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "team" badge for team plan', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when plan is not fetched yet', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClick when isDisplayOnly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 6. PriorityLabel Integration
|
||||
// Tests priority badge display for different plan types
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('PriorityLabel Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should display "standard" priority for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "priority" for professional plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument()
|
||||
// Professional plan should show the priority icon
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for team plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 7. Usage Display Edge Cases
|
||||
// Tests storage mode, threshold logic, and progress bar color integration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Usage Display Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Vector space storage mode behavior
|
||||
describe('VectorSpace storage mode in PlanComp', () => {
|
||||
it('should show "< 50" for sandbox plan with low vector space usage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Storage mode: usage below threshold shows "< 50"
|
||||
expect(screen.getByText(/</)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indeterminate progress bar for usage below threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Should have an indeterminate progress bar
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show actual usage for pro plan above threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { vectorSpace: 1024 },
|
||||
total: { vectorSpace: 5120 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Pro plan above threshold shows actual value
|
||||
expect(screen.getByText('1024')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Progress bar color logic through real components
|
||||
describe('Progress bar color reflects usage severity', () => {
|
||||
it('should show normal color for low usage percentage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 1 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// 20% usage - normal color
|
||||
const progressBars = screen.getAllByTestId('billing-progress-bar')
|
||||
// At least one should have the normal progress color
|
||||
const hasNormalColor = progressBars.some(bar =>
|
||||
bar.classList.contains('bg-components-progress-bar-progress-solid'),
|
||||
)
|
||||
expect(hasNormalColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Reset days calculation in PlanComp
|
||||
describe('Reset days integration', () => {
|
||||
it('should not show reset for sandbox trigger events (no reset_date)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
total: { triggerEvents: 3000 },
|
||||
reset: { triggerEvents: null },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Find the trigger events section - should not have reset text
|
||||
const triggerSection = screen.getByText(/usagePage\.triggerEvents/i)
|
||||
const parent = triggerSection.closest('[class*="flex flex-col"]')
|
||||
// No reset text should appear (sandbox doesn't show reset for triggerEvents)
|
||||
expect(parent?.textContent).not.toContain('usagePage.resetsIn')
|
||||
})
|
||||
|
||||
it('should show reset for professional trigger events with reset date', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 14 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Professional plan with finite triggerEvents should show reset
|
||||
const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i)
|
||||
expect(resetTexts.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 8. Cross-Component Upgrade Flow (End-to-End)
|
||||
// Tests the complete chain: capacity alert → upgrade button → pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Cross-Component Upgrade Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should trigger pricing from AppsFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="app-create" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
|
||||
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
enum BadgeState {
|
||||
Warning = 'warning',
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { Highlight } from '@/app/components/base/icons/src/public/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
const PremiumBadgeVariants = cva(
|
||||
'premium-badge',
|
||||
|
||||
@@ -193,107 +193,4 @@ describe('usePSInfo', () => {
|
||||
domain: '.dify.ai',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie parse failure: covers catch block (L14-16)
|
||||
it('should fall back to empty object when cookie contains invalid JSON', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('not-valid-json{{{')
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams({
|
||||
ps_partner_key: 'from-url',
|
||||
ps_xid: 'click-url',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse partner stack info from cookie:',
|
||||
expect.any(SyntaxError),
|
||||
)
|
||||
// Should still pick up values from search params
|
||||
expect(result.current.psPartnerKey).toBe('from-url')
|
||||
expect(result.current.psClickId).toBe('click-url')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
// No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch)
|
||||
it('should not save or bind when neither search params nor cookie have keys', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call mutateAsync when keys are missing during bind', async () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Non-400 error: covers L55 false branch (shouldRemoveCookie stays false)
|
||||
it('should not remove cookie when bind fails with non-400 error', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 500 })
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
const { remove } = ensureCookieMocks()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Fallback to cookie values: covers L19-20 right side of || operator
|
||||
it('should use cookie values when search params are absent', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'cookie-partner',
|
||||
clickId: 'cookie-click',
|
||||
}))
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
// Partial key missing: only partnerKey present, no clickId
|
||||
it('should not save when only one key is available', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({ ps_partner_key: 'partial-key' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,13 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
@@ -75,13 +82,6 @@ beforeEach(() => {
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloudPlanItem', () => {
|
||||
// Static content for each plan
|
||||
describe('Rendering', () => {
|
||||
@@ -192,128 +192,5 @@ describe('CloudPlanItem', () => {
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L92-93: isFreePlan guard inside handleGetPayUrl
|
||||
it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.sandbox}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockBillingInvoices).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L95: yearly subscription URL ('year' parameter)
|
||||
it('should fetch yearly subscription url when planRange is yearly', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.yearly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L62-63: loading guard prevents double click
|
||||
it('should ignore second click while loading', async () => {
|
||||
// Make the first fetch hang until we resolve it
|
||||
let resolveFirst!: (v: { url: string }) => void
|
||||
mockFetchSubscriptionUrls.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveFirst = resolve }),
|
||||
)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
|
||||
|
||||
// First click starts loading
|
||||
fireEvent.click(button)
|
||||
// Second click while loading should be ignored
|
||||
fireEvent.click(button)
|
||||
|
||||
// Resolve first request
|
||||
resolveFirst({ url: 'https://first.example' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url
|
||||
it('should invoke onError when billing invoices returns empty url', async () => {
|
||||
mockBillingInvoices.mockResolvedValue({ url: '' })
|
||||
const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => {
|
||||
try {
|
||||
await cb()
|
||||
}
|
||||
catch (e) {
|
||||
opts.onError?.(e as Error)
|
||||
}
|
||||
})
|
||||
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openWindow).toHaveBeenCalledTimes(1)
|
||||
// The onError callback should have been passed to openAsyncWindow
|
||||
const callArgs = openWindow.mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('onError')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers monthly price display (L139 !isYear branch for price)
|
||||
it('should display monthly pricing without discount', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const teamPlan = ALL_PLANS[Plan.team]
|
||||
expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument()
|
||||
// Should NOT show crossed-out yearly price
|
||||
expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
@import "preflight.css";
|
||||
|
||||
|
||||
@import '../../themes/light.css';
|
||||
@import '../../themes/dark.css';
|
||||
@import "../../themes/manual-light.css";
|
||||
@import "../../themes/manual-dark.css";
|
||||
@import "./monaco-sticky-fix.css";
|
||||
|
||||
@import "../components/base/button/index.css";
|
||||
@import "../components/base/action-button/index.css";
|
||||
@import "../components/base/badge/index.css";
|
||||
@import "../components/base/button/index.css";
|
||||
@import "../components/base/modal/index.css";
|
||||
@import "../components/base/premium-badge/index.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -3002,6 +3002,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx": {
|
||||
"test/prefer-hooks-in-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/billing/pricing/plans/cloud-plan-item/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 6
|
||||
|
||||
@@ -7,6 +7,10 @@ import sonar from 'eslint-plugin-sonarjs'
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
import dify from './eslint-rules/index.js'
|
||||
|
||||
// Enable Tailwind CSS IntelliSense mode for ESLint runs
|
||||
// See: tailwind-css-plugin.ts
|
||||
process.env.TAILWIND_MODE ??= 'ESLINT'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
react: {
|
||||
|
||||
@@ -182,6 +182,11 @@ const config = {
|
||||
}),
|
||||
cssAsPlugin([
|
||||
path.resolve(_dirname, './app/styles/globals.css'),
|
||||
path.resolve(_dirname, './app/components/base/action-button/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/badge/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/button/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/modal/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/premium-badge/index.css'),
|
||||
]),
|
||||
],
|
||||
// https://github.com/tailwindlabs/tailwindcss/discussions/5969
|
||||
|
||||
@@ -7,9 +7,11 @@ import { parse } from 'postcss'
|
||||
import { objectify } from 'postcss-js'
|
||||
|
||||
export const cssAsPlugin: (cssPath: string[]) => PluginCreator = (cssPath: string[]) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const isTailwindCSSIntelliSenseMode = 'TAILWIND_MODE' in process.env
|
||||
if (!isTailwindCSSIntelliSenseMode) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
return ({ addUtilities, addComponents, addBase }) => {
|
||||
const jssList = cssPath.map(p => objectify(parse(readFileSync(p, 'utf8'))))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user