diff --git a/client/packages/lowcoder-design/src/components/customSelect.tsx b/client/packages/lowcoder-design/src/components/customSelect.tsx index 2f13f0db8..72864178a 100644 --- a/client/packages/lowcoder-design/src/components/customSelect.tsx +++ b/client/packages/lowcoder-design/src/components/customSelect.tsx @@ -20,7 +20,8 @@ const SelectWrapper = styled.div<{ $border?: boolean }>` padding: ${(props) => (props.$border ? "0px" : "0 0 0 12px")}; height: 100%; align-items: center; - margin-right: 8px; + margin-right: 10px; + padding-right: 5px; background-color: #fff; .ant-select-selection-item { @@ -46,9 +47,9 @@ const SelectWrapper = styled.div<{ $border?: boolean }>` } .ant-select-arrow { - width: 20px; - height: 20px; - right: 8px; + width: 17px; + height: 17px; + right: 10px; top: 0; bottom: 0; margin: auto; diff --git a/client/packages/lowcoder/src/api/userApi.ts b/client/packages/lowcoder/src/api/userApi.ts index 5955071a8..a65a72338 100644 --- a/client/packages/lowcoder/src/api/userApi.ts +++ b/client/packages/lowcoder/src/api/userApi.ts @@ -63,10 +63,13 @@ export type GetCurrentUserResponse = GenericApiResponse; export interface GetMyOrgsResponse extends ApiResponse { data: { data: Array<{ - orgId: string; - orgName: string; - createdAt?: number; - updatedAt?: number; + isCurrentOrg: boolean; + orgView: { + orgId: string; + orgName: string; + createdAt?: number; + updatedAt?: number; + }; }>; pageNum: number; pageSize: number; diff --git a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx index 6425d3afc..adb9e9ffb 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx @@ -274,7 +274,7 @@ function PermissionTagRender(props: CustomTagProps) { color={value} closable={closable} onClose={onClose} - style={{ marginRight: 3 }} + style={{ marginRight: 3, display: "flex", alignItems: "center" }} > {label} diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actionConfigs.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/actionConfigs.ts index 02f0bbe73..10da15479 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actionConfigs.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/actionConfigs.ts @@ -5,11 +5,16 @@ import { renameComponentAction, deleteComponentAction, resizeComponentAction, - configureComponentAction, - changeLayoutAction, + configureAppMetaAction, addEventHandlerAction, applyStyleAction, - nestComponentAction + nestComponentAction, + updateDynamicLayoutAction, + publishAppAction, + shareAppAction, + testAllDatasourcesAction, + applyGlobalJSAction, + applyGlobalCSSAction } from "./actions"; export const actionCategories: ActionCategory[] = [ @@ -26,14 +31,21 @@ export const actionCategories: ActionCategory[] = [ ] }, { - key: 'component-configuration', - label: 'Component Configuration', - actions: [configureComponentAction] + key: 'app-configuration', + label: 'App Configuration', + actions: [ + configureAppMetaAction, + publishAppAction, + shareAppAction, + testAllDatasourcesAction, + applyGlobalJSAction, + applyGlobalCSSAction + ] }, { key: 'layout', label: 'Layout', - actions: [changeLayoutAction] + actions: [updateDynamicLayoutAction] }, { key: 'events', diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actionInputSection.tsx b/client/packages/lowcoder/src/comps/comps/preLoadComp/actionInputSection.tsx index a78ab6fb3..0b243219e 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actionInputSection.tsx +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/actionInputSection.tsx @@ -12,11 +12,16 @@ import { default as Space } from "antd/es/space"; import { default as Flex } from "antd/es/flex"; import type { InputRef } from 'antd'; import { default as DownOutlined } from "@ant-design/icons/DownOutlined"; -import { BaseSection } from "lowcoder-design"; +import { BaseSection, Dropdown } from "lowcoder-design"; import { EditorContext } from "comps/editorState"; import { message } from "antd"; import { CustomDropdown } from "./styled"; -import { generateComponentActionItems, getComponentCategories } from "./utils"; +import { + generateComponentActionItems, + getComponentCategories, + getEditorComponentInfo, + getLayoutItemsOrder +} from "./utils"; import { actionRegistry, getAllActionItems } from "./actionConfigs"; export function ActionInputSection() { @@ -31,6 +36,8 @@ export function ActionInputSection() { const [showStylingInput, setShowStylingInput] = useState(false); const [selectedEditorComponent, setSelectedEditorComponent] = useState(null); const [validationError, setValidationError] = useState(null); + const [showDynamicLayoutDropdown, setShowDynamicLayoutDropdown] = useState(false); + const [selectedDynamicLayoutIndex, setSelectedDynamicLayoutIndex] = useState(null); const inputRef = useRef(null); const editorState = useContext(EditorContext); @@ -56,6 +63,25 @@ export function ActionInputSection() { })); }, [editorState]); + const simpleLayoutItems = useMemo(() => { + if(!editorComponents) return []; + + const editorComponentInfo = getEditorComponentInfo(editorState); + if(!editorComponentInfo) return []; + + const currentLayout = editorComponentInfo.currentLayout; + const items = editorComponentInfo.items; + + return Object.keys(currentLayout).map((key) => { + const item = items ? items[key] : null; + const componentName = item ? (item as any).children.name.getView() : key; + return { + label: componentName, + key: componentName + }; + }); + }, [editorState]); + const currentAction = useMemo(() => { return selectedActionKey ? actionRegistry.get(selectedActionKey) : null; }, [selectedActionKey]); @@ -81,7 +107,9 @@ export function ActionInputSection() { setSelectedEditorComponent(null); setIsNestedComponent(false); setSelectedNestComponent(null); + setShowDynamicLayoutDropdown(false); setActionValue(""); + setSelectedDynamicLayoutIndex(null); if (action.requiresComponentSelection) { setShowComponentDropdown(true); @@ -103,6 +131,9 @@ export function ActionInputSection() { if (action.isNested) { setIsNestedComponent(true); } + if(action.dynamicLayout) { + setShowDynamicLayoutDropdown(true); + } }, []); const handleComponentSelection = useCallback((key: string) => { @@ -175,6 +206,7 @@ export function ActionInputSection() { selectedComponent, selectedEditorComponent, selectedNestComponent, + selectedDynamicLayoutIndex, editorState }); @@ -189,6 +221,8 @@ export function ActionInputSection() { setValidationError(null); setIsNestedComponent(false); setSelectedNestComponent(null); + setShowDynamicLayoutDropdown(false); + setSelectedDynamicLayoutIndex(null); } catch (error) { console.error('Error executing action:', error); @@ -200,6 +234,7 @@ export function ActionInputSection() { selectedComponent, selectedEditorComponent, selectedNestComponent, + selectedDynamicLayoutIndex, editorState, currentAction, validateInput @@ -299,7 +334,7 @@ export function ActionInputSection() { popupRender={() => ( { + onClick={({key}) => { handleEditorComponentSelection(key); }} /> @@ -314,6 +349,47 @@ export function ActionInputSection() { )} + {showDynamicLayoutDropdown && ( + ( + { + handleEditorComponentSelection(key); + }} + /> + )} + > + + + )} + + {showDynamicLayoutDropdown && ( + { + setSelectedDynamicLayoutIndex(value); + }} + > + + + )} + {shouldShowInput && ( showStylingInput ? ( { + const { editorState } = params; + const appSettingsComp = editorState.getAppSettingsComp(); + + try { + // TODO: Get config data from the user + let configData = { + title: "Test Title", + description: "Test Description", + category: "Test Category" + }; + + if (configData.title && appSettingsComp?.children?.title) { + appSettingsComp.children.title.dispatchChangeValueAction(configData.title); + } + + if (configData.description && appSettingsComp?.children?.description) { + appSettingsComp.children.description.dispatchChangeValueAction(configData.description); + } + + if (configData.category && appSettingsComp?.children?.category) { + appSettingsComp.children.category.dispatchChangeValueAction(configData.category); + } + + // Display error message if no valid configuration data is provided + const updatedFields = []; + if (configData.title) updatedFields.push('title'); + if (configData.description) updatedFields.push('description'); + if (configData.category) updatedFields.push('category'); + + !updatedFields.length && message.info('No valid configuration data provided'); + + } catch (error) { + console.error('Error updating app settings:', error); + message.error('Failed to update app configuration'); + } + } +}; + +export const publishAppAction: ActionConfig = { + key: 'publish-app', + label: 'Publish app', + category: 'app-configuration', + requiresInput: false, + execute: async (params: ActionExecuteParams) => { + const { editorState } = params; + const applicationIdEditor = editorState.rootComp.preloadId; + const applicationId = applicationIdEditor.replace('app-', ''); + + try { + if (!applicationId) { + message.error('Application ID not found'); + return; + } + + const response = await ApplicationApi.publishApplication({ applicationId }); + + if (response.data.success) { + message.success('Application published successfully'); + window.open(`/applications/${applicationId}/view`, '_blank'); + } else { + message.error('Failed to publish application'); + } + + } catch (error) { + console.error('Error publishing application:', error); + message.error('Failed to publish application'); + } + } +}; + +export const shareAppAction: ActionConfig = { + key: 'share-app', + label: 'Share app', + category: 'app-configuration', + requiresInput: false, + execute: async (params: ActionExecuteParams) => { + // TODO: Get app sharing from the user + const appSharing = { + public: true, + publishMarketplace: false + } + + const { editorState } = params; + const applicationIdEditor = editorState.rootComp.preloadId; + const applicationId = applicationIdEditor.replace('app-', ''); + + if (!applicationId) { + message.error('Application ID not found'); + return; + } + + try { + // Update Application Sharig Status + // Update Redux state to reflect the public change in UI + const publicResponse = await ApplicationApi.publicToAll(applicationId, appSharing.public); + + if (publicResponse.data.success) { + reduxStore.dispatch(updateAppPermissionInfo({ publicToAll: appSharing.public })); + message.success('Application is now public!'); + + // Update Application Marketplace Sharing Status + try { + const marketplaceResponse = await ApplicationApi.publicToMarketplace(applicationId, appSharing.publishMarketplace); + if (marketplaceResponse.data.success) { + reduxStore.dispatch(updateAppPermissionInfo({ publicToMarketplace: appSharing.publishMarketplace })); + message.success(`Application ${appSharing.publishMarketplace ? 'published to' : 'unpublished from'} marketplace successfully!`); + } + } catch (marketplaceError) { + console.error(`Error ${appSharing.publishMarketplace ? 'publishing to' : 'unpublishing from'} marketplace:`, marketplaceError); + message.warning(`Application is public but ${appSharing.publishMarketplace ? 'publishing to' : 'unpublishing from'} marketplace failed`); + } + + } else { + message.error('Failed to make application public'); + } + } catch (publicError) { + console.error('Error making application public:', publicError); + message.error('Failed to make application public'); + } + } +}; + +export const testAllDatasourcesAction: ActionConfig = { + key: 'test-all-datasources', + label: 'Test all datasources', + category: 'app-configuration', + requiresInput: false, + execute: async (params: ActionExecuteParams) => { + const { editorState } = params; + + try { + const allQueries = editorState.getQueriesComp().getView(); + + if (!allQueries || !allQueries.length) { + message.info('No queries found in the application'); + return; + } + + console.log(`Found ${allQueries.length} queries to test`); + + const results = { + total: allQueries.length, + successful: 0, + failed: 0, + errors: [] as Array<{ queryName: string; error: string }> + }; + + message.loading(`Testing ${allQueries.length} queries...`, 0); + + for (let i = 0; i < allQueries.length; i++) { + const query = allQueries[i]; + const queryName = query.children.name.getView(); + + try { + await getPromiseAfterDispatch( + query.dispatch, + executeQueryAction({ + // In some data queries, we need to pass args to the query + // Currently, we don't have a way to pass args to the query + // So we are passing an empty object + args: {}, + afterExecFunc: () => { + console.log(`Query ${queryName} executed successfully`); + } + }), + { + notHandledError: `Failed to execute query: ${queryName}` + } + ); + + results.successful++; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Query ${queryName} failed:`, error); + + results.failed++; + results.errors.push({ + queryName, + error: errorMessage + }); + } + + if (i < allQueries.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + message.destroy(); + + if (results.failed === 0) { + message.success(`All ${results.total} queries executed successfully!`); + } else if (results.successful === 0) { + message.error(`All ${results.total} queries failed. Check console for details.`); + } else { + message.warning( + `Query test completed: ${results.successful} successful, ${results.failed} failed` + ); + } + + console.group('Query Test Results'); + console.log(`Total queries: ${results.total}`); + console.log(`Successful: ${results.successful}`); + console.log(`Failed: ${results.failed}`); + + if (results.errors.length > 0) { + console.group('Failed Queries:'); + results.errors.forEach(({ queryName, error }) => { + console.error(`${queryName}: ${error}`); + }); + console.groupEnd(); + } + console.groupEnd(); + + } catch (error) { + message.destroy(); + console.error('Error during application testing:', error); + message.error('Failed to test application. Check console for details.'); + } + } +}; + +export const applyGlobalJSAction: ActionConfig = { + key: 'apply-global-js', + label: 'Apply global JS', + category: 'app-configuration', + requiresInput: true, + inputPlaceholder: 'Enter JavaScript code to apply globally...', + inputType: 'textarea', + validation: (value: string) => { + if (!value.trim()) { + return 'JavaScript code is required'; + } + try { + new Function(value); + return null; + } catch (error) { + return 'Invalid JavaScript syntax'; + } + }, + execute: async (params: ActionExecuteParams) => { + const { editorState, actionValue } = params; + + try { + const defaultJS = `console.log('Please provide a valid JavaScript code');`.trim(); + + const jsCode = actionValue.trim() || defaultJS; + + const preloadComp = editorState.rootComp.children.preload; + if (!preloadComp) { + message.error('Preload component not found'); + return; + } + + const scriptComp = preloadComp.children.script; + if (!scriptComp) { + message.error('Script component not found'); + return; + } + + scriptComp.dispatchChangeValueAction(jsCode); + runScript(jsCode, false); + + message.success('Global JavaScript applied successfully!'); + + } catch (error) { + console.error('Error applying global JavaScript:', error); + message.error('Failed to apply global JavaScript. Check console for details.'); + } + } +}; + +export const applyGlobalCSSAction: ActionConfig = { + key: 'apply-global-css', + label: 'Apply global CSS', + category: 'app-configuration', + requiresInput: true, + requiresStyle: true, + inputPlaceholder: 'Enter CSS code to apply globally...', + inputType: 'textarea', + validation: (value: string) => { + if (!value.trim()) { + return 'CSS code is required'; + } + const css = value.trim(); + if (!css.includes('{') || !css.includes('}')) { + return 'Invalid CSS syntax - missing braces'; + } + return null; + }, + execute: async (params: ActionExecuteParams) => { + const { editorState, actionValue } = params; + + try { + const defaultCSS = ` + body { + font-family: Arial, sans-serif; + } + `.trim(); + + const cssCode = actionValue.trim() || defaultCSS; + + const preloadComp = editorState.rootComp.children.preload; + if (!preloadComp) { + message.error('Preload component not found'); + return; + } + + const globalCSSComp = preloadComp.children.globalCSS; + if (!globalCSSComp) { + message.error('Global CSS component not found'); + return; + } + + globalCSSComp.dispatchChangeValueAction(cssCode); + + await globalCSSComp.run('global-css', cssCode); + + message.success('Global CSS applied successfully!'); + + } catch (error) { + console.error('Error applying global CSS:', error); + message.error('Failed to apply global CSS. Check console for details.'); + } + } +}; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentConfiguration.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentConfiguration.ts deleted file mode 100644 index 2106b1eda..000000000 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentConfiguration.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { message } from "antd"; -import { ActionConfig, ActionExecuteParams } from "../types"; - -export const configureComponentAction: ActionConfig = { - key: 'configure-components', - label: 'Configure a component', - category: 'component-configuration', - requiresEditorComponentSelection: true, - requiresInput: true, - inputPlaceholder: 'Enter configuration (JSON format)', - inputType: 'json', - validation: (value: string) => { - if (!value.trim()) return 'Configuration is required'; - try { - JSON.parse(value); - return null; - } catch { - return 'Invalid JSON format'; - } - }, - execute: async (params: ActionExecuteParams) => { - const { selectedEditorComponent, actionValue } = params; - - try { - const config = JSON.parse(actionValue); - console.log('Configuring component:', selectedEditorComponent, 'with config:', config); - message.info(`Configure action for component "${selectedEditorComponent}"`); - - // TODO: Implement actual configuration logic - } catch (error) { - message.error('Invalid configuration format'); - } - } -}; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentEvents.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentEvents.ts index cf007c5af..1d73c20b0 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentEvents.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentEvents.ts @@ -1,5 +1,29 @@ +/** + * Event Names: + * - click: Triggered when component is clicked + * - change: Triggered when component value changes + * - focus: Triggered when component gains focus + * - blur: Triggered when component loses focus + * - submit: Triggered when form is submitted + * - refresh: Triggered when component is refreshed + * + * Action Types: + * - executeQuery: Run a data query + * - message: Show a notification message + * - setTempState: Set a temporary state value + * - runScript: Execute JavaScript code + * - executeComp: Control another component + * - goToURL: Navigate to a URL + * - copyToClipboard: Copy data to clipboard + * - download: Download data as file + * - triggerModuleEvent: Trigger a module event + * - openAppPage: Navigate to another app page + */ + import { message } from "antd"; import { ActionConfig, ActionExecuteParams } from "../types"; +import { getEditorComponentInfo } from "../utils"; +import { pushAction } from "comps/generators/list"; export const addEventHandlerAction: ActionConfig = { key: 'add-event-handler', @@ -7,14 +31,152 @@ export const addEventHandlerAction: ActionConfig = { category: 'events', requiresEditorComponentSelection: true, requiresInput: true, - inputPlaceholder: 'Enter event handler code (JavaScript)', - inputType: 'textarea', + inputPlaceholder: 'Format: eventName: actionType (e.g., "click: message", "change: executeQuery", "focus: setTempState")', + inputType: 'text', + validation: (value: string) => { + const [eventName, actionType] = value.split(':').map(s => s.trim()); + if (!eventName || !actionType) { + return 'Please provide both event name and action type separated by colon (e.g., "click: message")'; + } + + const validActionTypes = [ + 'executeQuery', 'message', 'setTempState', 'runScript', + 'executeComp', 'goToURL', 'copyToClipboard', 'download', + 'triggerModuleEvent', 'openAppPage' + ]; + + if (!validActionTypes.includes(actionType)) { + return `Invalid action type. Valid types: ${validActionTypes.join(', ')}`; + } + + return null; + }, execute: async (params: ActionExecuteParams) => { - const { selectedEditorComponent, actionValue } = params; + const { selectedEditorComponent, actionValue, editorState } = params; + + const componentInfo = getEditorComponentInfo(editorState, selectedEditorComponent as string); + + if (!componentInfo) { + message.error(`Component "${selectedEditorComponent}" not found`); + return; + } + + const { allAppComponents } = componentInfo; + const targetComponent = allAppComponents.find(comp => comp.name === selectedEditorComponent); + + if (!targetComponent?.comp?.children?.onEvent) { + message.error(`Component "${selectedEditorComponent}" does not support event handlers`); + return; + } + + // ----- To be Removed after n8n integration ------ // + const [eventName, actionType] = actionValue.split(':').map(s => s.trim()); + + if (!eventName || !actionType) { + message.error('Please provide event name and action type in format: "eventName: actionType"'); + return; + } + const eventConfigs = targetComponent.comp.children.onEvent.getEventNames?.() || []; + const availableEvents = eventConfigs.map((config: any) => config.value); + + if (!availableEvents.includes(eventName)) { + const availableEventsList = availableEvents.length > 0 ? availableEvents.join(', ') : 'none'; + message.error(`Event "${eventName}" is not available for this component. Available events: ${availableEventsList}`); + return; + } + // ----- To be Removed after n8n integration ------ // + + + const eventHandler = { + name: eventName, + handler: { + compType: actionType, + comp: getActionConfig(actionType, editorState) + } + }; + + try { + targetComponent.comp.children.onEvent.dispatch(pushAction(eventHandler)); + message.success(`Event handler for "${eventName}" with action "${actionType}" added successfully!`); + } catch (error) { + console.error('Error adding event handler:', error); + message.error('Failed to add event handler. Please try again.'); + } + } +}; + +// A Hardcoded function to get action configuration based on action type +// This will be removed after n8n integration +function getActionConfig(actionType: string, editorState: any) { + switch (actionType) { + case 'executeQuery': + const queryVariables = editorState + ?.selectedOrFirstQueryComp() + ?.children.variables.toJsonValue(); + + return { + queryName: editorState + ?.selectedOrFirstQueryComp() + ?.children.name.getView(), + queryVariables: queryVariables?.map((variable: any) => ({...variable, value: ''})), + }; + + case 'message': + return { + text: "Event triggered!", + level: "info", + duration: 3000 + }; + + case 'setTempState': + return { + state: "tempState", + value: "{{eventData}}" + }; + + case 'runScript': + return { + script: "console.log('Event triggered:', eventData);" + }; + + case 'executeComp': + return { + compName: "", + methodName: "", + params: [] + }; + + case 'goToURL': + return { + url: "https://example.com", + openInNewTab: false + }; + + case 'copyToClipboard': + return { + value: "{{eventData}}" + }; + + case 'download': + return { + data: "{{eventData}}", + fileName: "download.txt", + fileType: "text/plain" + }; + + case 'triggerModuleEvent': + return { + name: "moduleEvent" + }; - console.log('Adding event handler to component:', selectedEditorComponent, 'with code:', actionValue); - message.info(`Event handler added to component "${selectedEditorComponent}"`); + case 'openAppPage': + return { + appId: "", + queryParams: [], + hashParams: [] + }; - // TODO: Implement actual event handler logic + default: + return {}; } -}; \ No newline at end of file +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentLayout.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentLayout.ts index e4afa15ca..b0f995be9 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentLayout.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentLayout.ts @@ -1,5 +1,7 @@ import { message } from "antd"; import { ActionConfig, ActionExecuteParams } from "../types"; +import { getEditorComponentInfo } from "../utils"; +import { changeValueAction, multiChangeAction, wrapActionExtraInfo } from "lowcoder-core"; export const changeLayoutAction: ActionConfig = { key: 'change-layout', @@ -16,4 +18,101 @@ export const changeLayoutAction: ActionConfig = { // TODO: Implement actual layout change logic } +}; + +export const updateDynamicLayoutAction: ActionConfig = { + key: 'update-dynamic-layout', + label: 'Update Dynamic Layout', + category: 'layout', + requiresInput: false, + dynamicLayout: true, + execute: async (params: ActionExecuteParams) => { + const { selectedDynamicLayoutIndex, selectedEditorComponent, editorState } = params; + + if (!selectedEditorComponent || !editorState || !selectedDynamicLayoutIndex) { + message.error('Component, editor state, and layout index are required'); + return; + } + + try { + const componentInfo = getEditorComponentInfo(editorState, selectedEditorComponent); + + if (!componentInfo) { + message.error(`Component "${selectedEditorComponent}" not found`); + return; + } + + const { componentKey, currentLayout, simpleContainer, items } = componentInfo; + + if (!componentKey || !currentLayout[componentKey]) { + message.error(`Component "${selectedEditorComponent}" not found in layout`); + return; + } + + const currentLayoutItem = currentLayout[componentKey]; + const newPos = parseInt(selectedDynamicLayoutIndex); + + if (isNaN(newPos)) { + message.error('Invalid layout index provided'); + return; + } + + const currentPos = currentLayoutItem.pos || 0; + const layoutItems = Object.entries(currentLayout).map(([key, item]) => ({ + key, + item: item as any, + pos: (item as any).pos || 0 + })).sort((a, b) => a.pos - b.pos); + + const otherItems = layoutItems.filter(item => item.key !== componentKey); + const newLayout: any = {}; + + newLayout[componentKey] = { + ...currentLayoutItem, + pos: newPos + }; + + // Update other components with shifted positions + otherItems.forEach((item) => { + let adjustedPos = item.pos; + + // If moving to a position before the current position, shift items in between + if (newPos < currentPos && item.pos >= newPos && item.pos < currentPos) { + adjustedPos = item.pos + 1; + } + // If moving to a position after the current position, shift items in between + else if (newPos > currentPos && item.pos > currentPos && item.pos <= newPos) { + adjustedPos = item.pos - 1; + } + + newLayout[item.key] = { + ...item.item, + pos: adjustedPos + }; + }); + + simpleContainer.dispatch( + wrapActionExtraInfo( + multiChangeAction({ + layout: changeValueAction(newLayout, true), + }), + { + compInfos: [{ + compName: selectedEditorComponent, + compType: (items[componentKey] as any).children.compType.getView(), + type: "layout" + }] + } + ) + ); + + editorState.setSelectedCompNames(new Set([selectedEditorComponent]), "layoutComp"); + + message.success(`Component "${selectedEditorComponent}" moved to position ${newPos}`); + + } catch (error) { + console.error('Error updating dynamic layout:', error); + message.error('Failed to update dynamic layout. Please try again.'); + } + } }; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentManagement.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentManagement.ts index 669c77524..d21e16efe 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentManagement.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/componentManagement.ts @@ -12,7 +12,7 @@ import { wrapChildAction, deleteCompAction } from "lowcoder-core"; -import { getEditorComponentInfo } from "../utils"; +import { getEditorComponentInfo, findTargetComponent } from "../utils"; export const addComponentAction: ActionConfig = { key: 'add-components', @@ -249,17 +249,22 @@ export const deleteComponentAction: ActionConfig = { return; } - const { componentKey, currentLayout, simpleContainer, componentType } = componentInfo; - - if (!componentKey || !currentLayout[componentKey]) { - message.error(`Component "${selectedEditorComponent}" not found in layout`); + const { allAppComponents } = componentInfo; + const targetComponent = allAppComponents.find(comp => comp.name === selectedEditorComponent); + + if (!targetComponent) { + message.error(`Component "${selectedEditorComponent}" not found in application`); return; } - const newLayout = { ...currentLayout }; - delete newLayout[componentKey]; + const targetInfo = findTargetComponent(editorState, selectedEditorComponent); - simpleContainer.dispatch( + if (targetInfo) { + const { container, layout, componentKey } = targetInfo; + const newLayout = { ...layout }; + delete newLayout[componentKey]; + + container.dispatch( wrapActionExtraInfo( multiChangeAction({ layout: changeValueAction(newLayout, true), @@ -268,7 +273,7 @@ export const deleteComponentAction: ActionConfig = { { compInfos: [{ compName: selectedEditorComponent, - compType: componentType || 'unknown', + compType: targetComponent.compType || 'unknown', type: "delete" }] } @@ -278,6 +283,9 @@ export const deleteComponentAction: ActionConfig = { editorState.setSelectedCompNames(new Set(), "deleteComp"); message.success(`Component "${selectedEditorComponent}" deleted successfully`); + } else { + message.error(`Component "${selectedEditorComponent}" not found in any container`); + } } catch (error) { console.error('Error deleting component:', error); message.error('Failed to delete component. Please try again.'); @@ -336,6 +344,15 @@ export const moveComponentAction: ActionConfig = { return; } + const targetInfo = findTargetComponent(editorState, selectedEditorComponent); + + if (!targetInfo) { + message.error(`Component "${selectedEditorComponent}" not found in any container`); + return; + } + + const { container, layout, componentKey } = targetInfo; + const componentInfo = getEditorComponentInfo(editorState, selectedEditorComponent); if (!componentInfo) { @@ -343,15 +360,13 @@ export const moveComponentAction: ActionConfig = { return; } - const { componentKey, currentLayout, simpleContainer } = componentInfo; - - if (!componentKey || !currentLayout[componentKey]) { + if (!componentKey || !layout[componentKey]) { message.error(`Component "${selectedEditorComponent}" not found in layout`); return; } - const currentLayoutItem = currentLayout[componentKey]; - const items = simpleContainer.children.items.children; + const currentLayoutItem = layout[componentKey]; + const items = container.children.items.children; const newLayoutItem = { ...currentLayoutItem, @@ -360,11 +375,11 @@ export const moveComponentAction: ActionConfig = { }; const newLayout = { - ...currentLayout, + ...layout, [componentKey]: newLayoutItem, }; - simpleContainer.dispatch( + container.dispatch( wrapActionExtraInfo( multiChangeAction({ layout: changeValueAction(newLayout, true), @@ -503,21 +518,22 @@ export const resizeComponentAction: ActionConfig = { return; } - const componentInfo = getEditorComponentInfo(editorState, selectedEditorComponent); - - if (!componentInfo) { - message.error(`Component "${selectedEditorComponent}" not found`); + const targetInfo = findTargetComponent(editorState, selectedEditorComponent); + + if (!targetInfo) { + message.error(`Component "${selectedEditorComponent}" not found in any container`); return; } - const { componentKey, currentLayout, simpleContainer, items } = componentInfo; + const { container, layout, componentKey } = targetInfo; - if (!componentKey || !currentLayout[componentKey]) { + if (!componentKey || !layout[componentKey]) { message.error(`Component "${selectedEditorComponent}" not found in layout`); return; } - const currentLayoutItem = currentLayout[componentKey]; + const currentLayoutItem = layout[componentKey]; + const items = container.children.items.children; const newLayoutItem = { ...currentLayoutItem, @@ -526,11 +542,11 @@ export const resizeComponentAction: ActionConfig = { }; const newLayout = { - ...currentLayout, + ...layout, [componentKey]: newLayoutItem, }; - simpleContainer.dispatch( + container.dispatch( wrapActionExtraInfo( multiChangeAction({ layout: changeValueAction(newLayout, true), diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/index.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/index.ts index 840000050..c9c6efa43 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/index.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/index.ts @@ -2,10 +2,10 @@ export * from './componentManagement'; // Component Configuration Actions -export { configureComponentAction } from './componentConfiguration'; +export * from './appConfiguration'; // Layout Actions -export { changeLayoutAction } from './componentLayout'; +export { changeLayoutAction, updateDynamicLayoutAction } from './componentLayout'; // Event Actions export { addEventHandlerAction } from './componentEvents'; diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/tabPanes.tsx b/client/packages/lowcoder/src/comps/comps/preLoadComp/tabPanes.tsx index a1229a96d..ad5831bad 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/tabPanes.tsx +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/tabPanes.tsx @@ -3,10 +3,15 @@ import React, { useEffect } from "react"; import { trans } from "i18n"; import { ConstructorToComp } from "lowcoder-core"; import { ScriptComp, CSSComp } from "./components"; +import { runScript } from "./utils"; export function JavaScriptTabPane(props: { comp: ConstructorToComp }) { useEffect(() => { - props.comp.runPreloadScript(); + // Use the imported runScript function instead of the component's method to avoid require() issues + const code = props.comp.getView(); + if (code) { + runScript(code, false); + } }, [props.comp]); const codePlaceholder = `window.name = 'Tom';\nwindow.greet = () => "hello world";`; diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/types.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/types.ts index a33f0f8f1..e344625fe 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/types.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/types.ts @@ -35,6 +35,7 @@ export interface ActionConfig { requiresInput?: boolean; requiresStyle?: boolean; isNested?: boolean; + dynamicLayout?: boolean; inputPlaceholder?: string; inputType?: 'text' | 'number' | 'textarea' | 'json'; validation?: (value: string) => string | null; @@ -47,6 +48,7 @@ export interface ActionExecuteParams { selectedComponent: string | null; selectedEditorComponent: string | null; selectedNestComponent: string | null; + selectedDynamicLayoutIndex: string | null; editorState: any; } diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/utils.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/utils.ts index b30f02fb2..72919356c 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/utils.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/utils.ts @@ -5,6 +5,7 @@ import { UICompCategory, UICompManifest, uiCompCategoryNames, uiCompRegistry } f import { MenuProps } from "antd/es/menu"; import React from "react"; import { EditorState } from "@lowcoder-ee/comps/editorState"; +import { getAllCompItems } from "comps/comps/containerBase/utils"; export function runScript(code: string, inHost?: boolean) { if (inHost) { @@ -54,16 +55,17 @@ export function getComponentCategories() { }); return cats; } -export function getEditorComponentInfo(editorState: EditorState, componentName: string): { +export function getEditorComponentInfo(editorState: EditorState, componentName?: string): { componentKey: string | null; currentLayout: any; simpleContainer: any; componentType?: string | null; items: any; + allAppComponents: any[]; } | null { try { // Get the UI component container - if (!editorState || !componentName) { + if (!editorState) { return null; } @@ -83,7 +85,20 @@ export function getEditorComponentInfo(editorState: EditorState, componentName: // Get current layout and items const currentLayout = simpleContainer.children.layout.getView(); + const items = getCombinedItems(uiCompTree); + const allAppComponents = getAllLayoutComponentsFromTree(uiCompTree); + + // If no componentName is provided, return all items + if (!componentName) { + return { + componentKey: null, + currentLayout, + simpleContainer, + items, + allAppComponents, + }; + } // Find the component by name and get its key let componentKey: string | null = null; @@ -93,7 +108,7 @@ export function getEditorComponentInfo(editorState: EditorState, componentName: if ((item as any).children.name.getView() === componentName) { componentKey = key; componentType = (item as any).children.compType.getView(); - break + break; } } @@ -103,6 +118,7 @@ export function getEditorComponentInfo(editorState: EditorState, componentName: simpleContainer, componentType, items, + allAppComponents, }; } catch(error) { console.error('Error getting editor component key:', error); @@ -110,30 +126,110 @@ export function getEditorComponentInfo(editorState: EditorState, componentName: } } -interface Container { - items?: Record; -} - -function getCombinedItems(uiCompTree: any) { +function getCombinedItems(uiCompTree: any, parentPath: string[] = []): Record { const combined: Record = {}; - if (uiCompTree.items) { - Object.entries(uiCompTree.items).forEach(([itemKey, itemValue]) => { - combined[itemKey] = itemValue; - }); + function processContainer(container: any, currentPath: string[]) { + if (container.items) { + Object.entries(container.items).forEach(([itemKey, itemValue]) => { + (itemValue as any).parentPath = [...currentPath]; + combined[itemKey] = itemValue; + }); + } + + if (container.children) { + Object.entries(container.children).forEach(([childKey, childContainer]) => { + const newPath = [...currentPath, childKey]; + processContainer(childContainer, newPath); + }); + } } - if (uiCompTree.children) { - Object.entries(uiCompTree.children).forEach(([parentKey, container]) => { - const typedContainer = container as Container; - if (typedContainer.items) { - Object.entries(typedContainer.items).forEach(([itemKey, itemValue]) => { - itemValue.parentContainer = parentKey; - combined[itemKey] = itemValue; - }); + processContainer(uiCompTree, parentPath); + + return combined; +} + +export function getLayoutItemsOrder(layoutItems: any[]){ + const maxIndex = layoutItems.length; + return Array.from({ length: maxIndex }, (_, index) => ({ + key: index, + label: `Position ${index}`, + value: index.toString() + })); +} + +function getAllLayoutComponentsFromTree(compTree: any): any[] { + try { + const allCompItems = getAllCompItems(compTree); + + return Object.entries(allCompItems).map(([itemKey, item]) => { + const compItem = item as any; + if (compItem && compItem.children) { + return { + id: itemKey, + compType: compItem.children.compType?.getView(), + name: compItem.children.name?.getView(), + key: itemKey, + comp: compItem.children.comp, + autoHeight: compItem.autoHeight?.(), + hidden: compItem.children.comp?.children?.hidden?.getView(), + parentPath: compItem.parentPath || [] + }; } }); + } catch (error) { + console.error('Error getting all app components from tree:', error); + return []; } +} - return combined; +export function getAllContainers(editorState: any) { + const containers: Array<{container: any, path: string[]}> = []; + + function findContainers(comp: any, path: string[] = []) { + if (!comp) return; + + if (comp.realSimpleContainer && typeof comp.realSimpleContainer === 'function') { + const simpleContainer = comp.realSimpleContainer(); + if (simpleContainer) { + containers.push({ container: simpleContainer, path }); + } + } + + if (comp.children) { + Object.entries(comp.children).forEach(([key, child]) => { + findContainers(child, [...path, key]); + }); + } + } + + const uiComp = editorState.getUIComp(); + const container = uiComp.getComp(); + if (container) { + findContainers(container); + } + + return containers; } + +export function findTargetComponent(editorState: any, selectedEditorComponent: string) { + const allContainers = getAllContainers(editorState); + + for (const containerInfo of allContainers) { + const containerLayout = containerInfo.container.children.layout.getView(); + const containerItems = containerInfo.container.children.items.children; + + for (const [key, item] of Object.entries(containerItems)) { + if ((item as any).children.name.getView() === selectedEditorComponent) { + return { + container: containerInfo.container, + layout: containerLayout, + componentKey: key + }; + } + } + } + + return null; +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx index 290f3628d..d7b5648e1 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx @@ -128,7 +128,8 @@ export const SelectInputValidationSection = (children: ValidationComp) => ( label: trans("prop.showEmptyValidation"), })} {children.allowCustomTags.propertyView({ - label: trans("prop.customTags") + label: trans("prop.customTags"), + tooltip: trans("prop.customTagsTooltip") })} {children.customRule.propertyView({})} diff --git a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx index bacb892bd..97af00711 100644 --- a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx @@ -179,15 +179,10 @@ export const useTextInputProps = (props: RecordConstructorToView { + setLocalInputValue(defaultValue); props.value.onChange(defaultValue) }, [defaultValue]); - useEffect(() => { - if (inputValue !== localInputValue) { - setLocalInputValue(inputValue); - } - }, [inputValue]); - useEffect(() => { if (!changeRef.current) return; @@ -220,8 +215,7 @@ export const useTextInputProps = (props: RecordConstructorToView) => { const value = e.target.value; diff --git a/client/packages/lowcoder/src/comps/comps/timerComp.tsx b/client/packages/lowcoder/src/comps/comps/timerComp.tsx index a749cb068..e9fc26ad0 100644 --- a/client/packages/lowcoder/src/comps/comps/timerComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/timerComp.tsx @@ -299,6 +299,42 @@ let TimerCompBasic = (function () { comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('reset')) }, }, + { + method: { + name: "start", + description: trans("timer.start"), + params: [], + }, + execute: async (comp, params) => { + if (comp.children.timerState.value === 'stoped') { + comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('start')) + } + }, + }, + { + method: { + name: "pause", + description: trans("timer.pause"), + params: [], + }, + execute: async (comp, params) => { + if (comp.children.timerState.value === 'started') { + comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('pause')) + } + }, + }, + { + method: { + name: "resume", + description: trans("timer.resume"), + params: [], + }, + execute: async (comp, params) => { + if (comp.children.timerState.value === 'paused') { + comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('resume')) + } + } + } ]) .build(); })(); diff --git a/client/packages/lowcoder/src/constants/orgConstants.ts b/client/packages/lowcoder/src/constants/orgConstants.ts index d46d9957b..e2afb5c5f 100644 --- a/client/packages/lowcoder/src/constants/orgConstants.ts +++ b/client/packages/lowcoder/src/constants/orgConstants.ts @@ -56,6 +56,7 @@ export type Org = { createTime?: string; createdAt?: number; updatedAt?: number; + isCurrentOrg?: boolean; }; export type OrgAndRole = { diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 43bcb3986..f644df665 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -235,7 +235,8 @@ export const en = { "verticalGridCells": "Vertical Grid Cells", "timeZone": "TimeZone", "pickerMode": "Picker Mode", - "customTags": "Custom Tags" + "customTags": "Allow Custom Tags", + "customTagsTooltip": "Allow users to enter custom tags that are not in the options list." }, "autoHeightProp": { "auto": "Auto", diff --git a/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx index f1cb0709f..0bd8a4c54 100644 --- a/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx +++ b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx @@ -242,11 +242,11 @@ export default function WorkspaceSectionComponent({ displayWorkspaces.map((org: Org) => ( handleOrgSwitch(org.id)} > {org.name} - {user.currentOrgId === org.id && } + {org.isCurrentOrg && } )) ) : ( diff --git a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx index c60f492ea..0e9c8a01c 100644 --- a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx +++ b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx @@ -211,6 +211,7 @@ function OrganizationSetting() { logoUrl: org.logoUrl || "", createdAt: org.createdAt, updatedAt: org.updatedAt, + isCurrentOrg: org.isCurrentOrg, })); @@ -262,7 +263,7 @@ function OrganizationSetting() { dataIndex: "orgName", ellipsis: true, render: (_, record: any) => { - const isActiveOrg = record.id === user.currentOrgId; + const isActiveOrg = record.isCurrentOrg; return ( @@ -307,7 +308,7 @@ function OrganizationSetting() { key: i, operation: ( - {item.id !== user.currentOrgId && ( + {!item.isCurrentOrg && ( ({ - id: item.orgId, - name: item.orgName, - createdAt: item.createdAt, - updatedAt: item.updatedAt, + id: item.orgView.orgId, + name: item.orgView.orgName, + createdAt: item.orgView.createdAt, + updatedAt: item.orgView.updatedAt, + isCurrentOrg: item.isCurrentOrg, })); yield put({ diff --git a/client/packages/lowcoder/src/util/useWorkspaceManager.ts b/client/packages/lowcoder/src/util/useWorkspaceManager.ts index 59732ac53..5c5cafee0 100644 --- a/client/packages/lowcoder/src/util/useWorkspaceManager.ts +++ b/client/packages/lowcoder/src/util/useWorkspaceManager.ts @@ -91,10 +91,11 @@ export function useWorkspaceManager({ if (response.data.success) { const apiData = response.data.data; const transformedItems = apiData.data.map(item => ({ - id: item.orgId, - name: item.orgName, - createdAt: item.createdAt, - updatedAt: item.updatedAt, + id: item.orgView.orgId, + name: item.orgView.orgName, + createdAt: item.orgView.createdAt, + updatedAt: item.orgView.updatedAt, + isCurrentOrg: item.isCurrentOrg, })); dispatch({ diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java index 106944777..0a4fdd3f6 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java @@ -21,9 +21,12 @@ import org.lowcoder.domain.application.model.ApplicationStatus; import org.lowcoder.domain.application.model.ApplicationType; import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.solutions.TemplateSolutionService; import org.lowcoder.domain.permission.model.ResourceHolder; import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourcePermission; import org.lowcoder.sdk.constants.FieldName; import org.lowcoder.sdk.exception.BizError; import org.lowcoder.sdk.exception.BizException; @@ -53,13 +56,507 @@ public class ApplicationApiServiceTest { @Autowired private DatasourceApiService datasourceApiService; @Autowired - private InitData initData; + private InitData initData = new InitData(); + @Autowired + private TemplateSolutionService templateSolutionService; @BeforeAll public void beforeAll() { initData.init(); } + @Test + @WithMockUser + public void testCreateApplication() { + CreateApplicationRequest request = new CreateApplicationRequest( + "org01", + null, + "test-app", + ApplicationType.APPLICATION.getValue(), + Map.of("comp", "list"), + null, + null, + null + ); + + Mono result = applicationApiService.create(request); + + StepVerifier.create(result) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("test-app", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetRecycledApplications() { + String appName = "recycled-app"; + Mono recycledAppIdMono = createApplication(appName, null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.recycle(appId)) + .cache(); + + String normalAppName = "normal-app"; + createApplication(normalAppName, null).block(); + + StepVerifier.create( + recycledAppIdMono.thenMany(applicationApiService.getRecycledApplications(null, null).collectList()) + ) + .assertNext(apps -> { + Assertions.assertTrue( + apps.stream().anyMatch(app -> appName.equals(app.getName()) && app.getApplicationStatus() == ApplicationStatus.RECYCLED), + "Expected recycled application not found" + ); + // Optionally, assert that normal-app is not in the recycled list + Assertions.assertTrue( + apps.stream().noneMatch(app -> normalAppName.equals(app.getName())), + "Normal app should not be in recycled list" + ); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testDeleteApplication() { + // Step 1: Create application + Mono appIdMono = createApplication("delete-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + // Step 2: Recycle the application + .delayUntil(appId -> applicationApiService.recycle(appId)) + .cache(); + + // Step 3: Delete the application and verify + StepVerifier.create( + appIdMono + .delayUntil(appId -> applicationApiService.delete(appId)) + .flatMap(appId -> applicationService.findById(appId)) + ) + .assertNext(app -> Assertions.assertEquals(ApplicationStatus.DELETED, app.getApplicationStatus())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testRecycleApplication() { + Mono appIdMono = createApplication("recycle-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono + .delayUntil(appId -> applicationApiService.recycle(appId)) + .flatMap(appId -> applicationService.findById(appId)) + ) + .assertNext(app -> Assertions.assertEquals(ApplicationStatus.RECYCLED, app.getApplicationStatus())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testRestoreApplication() { + // Create application and recycle it + Mono appIdMono = createApplication("restore-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.recycle(appId)) + .cache(); + + // Restore the application and verify status + StepVerifier.create( + appIdMono + .delayUntil(appId -> applicationApiService.restore(appId)) + .flatMap(appId -> applicationService.findById(appId)) + ) + .assertNext(app -> Assertions.assertNotEquals(ApplicationStatus.RECYCLED, app.getApplicationStatus())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetEditingApplication() { + // Create a new application + Mono appIdMono = createApplication("editing-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Retrieve the editing application and verify its properties + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getEditingApplication(appId, false)) + ) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("editing-app-test", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetPublishedApplication() { + // Create a new application + Mono appIdMono = createApplication("published-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Publish the application + Mono publishedAppIdMono = appIdMono + .delayUntil(appId -> applicationApiService.publish(appId, new ApplicationPublishRequest("Initial Publish", "1.0.0"))) + .cache(); + + // Retrieve the published application and verify its properties + StepVerifier.create( + publishedAppIdMono.flatMap(appId -> + applicationApiService.getPublishedApplication(appId, ApplicationRequestType.PUBLIC_TO_ALL, false) + ) + ) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("published-app-test", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateUserApplicationLastViewTime() { + Mono appIdMono = createApplication("last-view-time-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.updateUserApplicationLastViewTime(appId)) + ) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateApplication() { + // Create a new application + Mono appIdMono = createApplication("update-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Update the application's name + Mono updatedAppMono = appIdMono + .flatMap(appId -> applicationApiService.update( + appId, + Application.builder().name("updated-app-name").build(), + false + )); + + // Verify the application's name is updated + StepVerifier.create(updatedAppMono) + .assertNext(applicationView -> + Assertions.assertEquals("updated-app-name", applicationView.getApplicationInfoView().getName()) + ) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testPublishFunction() { + // Step 1: Create a new application + Mono appIdMono = createApplication("publish-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Step 2: Publish the application + ApplicationPublishRequest publishRequest = new ApplicationPublishRequest("Initial Publish", "1.0.0"); + Mono publishedAppMono = appIdMono + .delayUntil(appId -> applicationApiService.publish(appId, publishRequest)) + .flatMap(appId -> applicationApiService.getPublishedApplication(appId, ApplicationRequestType.PUBLIC_TO_ALL, false)); + + // Step 3: Assert the result + StepVerifier.create(publishedAppMono) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("publish-app-test", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateEditState() { + Mono appIdMono = createApplication("edit-state-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + ApplicationEndpoints.UpdateEditStateRequest request = + new ApplicationEndpoints.UpdateEditStateRequest(true); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.updateEditState(appId, request)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGrantPermission() { + // Create a new application + Mono appIdMono = createApplication("grant-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Grant permissions to user and group, then verify + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.grantPermission( + appId, + Set.of("user02"), + Set.of("group01"), + ResourceRole.EDITOR + ).then(applicationApiService.getApplicationPermissions(appId))) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(permissionItemView -> + permissionItemView.getType() == ResourceHolder.USER && + "user02".equals(permissionItemView.getId()) && + ResourceRole.EDITOR.getValue().equals(permissionItemView.getRole()) + )); + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(permissionItemView -> + permissionItemView.getType() == ResourceHolder.GROUP && + "group01".equals(permissionItemView.getId()) && + ResourceRole.EDITOR.getValue().equals(permissionItemView.getRole()) + )); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdatePermission() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("update-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Update the permission role for user02 to VIEWER and verify + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getApplicationPermissions(appId) + .map(applicationPermissionView -> applicationPermissionView.getPermissions().stream() + .filter(p -> p.getType() == ResourceHolder.USER && "user02".equals(p.getId())) + .findFirst() + .orElseThrow()) + .flatMap(permissionItemView -> applicationApiService.updatePermission( + appId, permissionItemView.getPermissionId(), ResourceRole.VIEWER)) + .then(applicationApiService.getApplicationPermissions(appId))) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(p -> p.getType() == ResourceHolder.USER + && "user02".equals(p.getId()) + && ResourceRole.VIEWER.getValue().equals(p.getRole()))); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testRemovePermission() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("remove-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Remove the permission for user02 and verify + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getApplicationPermissions(appId) + .map(applicationPermissionView -> applicationPermissionView.getPermissions().stream() + .filter(p -> p.getType() == ResourceHolder.USER && "user02".equals(p.getId())) + .findFirst() + .orElseThrow()) + .flatMap(permissionItemView -> applicationApiService.removePermission( + appId, permissionItemView.getPermissionId())) + .then(applicationApiService.getApplicationPermissions(appId))) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .noneMatch(p -> p.getType() == ResourceHolder.USER && "user02".equals(p.getId()))); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetApplicationPermissions() { + // Create a new application and grant permissions to user and group + Mono appIdMono = createApplication("get-permissions-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of("group01"), ResourceRole.EDITOR)) + .cache(); + + // Retrieve and verify permissions + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getApplicationPermissions(appId)) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(p -> p.getType() == ResourceHolder.USER + && "user02".equals(p.getId()) + && ResourceRole.EDITOR.getValue().equals(p.getRole()))); + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(p -> p.getType() == ResourceHolder.GROUP + && "group01".equals(p.getId()) + && ResourceRole.EDITOR.getValue().equals(p.getRole()))); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testCreateFromTemplate() { + String templateId = "test-template-id"; + Mono result = applicationApiService.createFromTemplate(templateId); + + StepVerifier.create(result) + .expectErrorMatches(throwable -> + throwable instanceof BizException && + throwable.getMessage().contains("template does not exist") + ) + .verify(); + } + + @Test + @WithMockUser + public void testCheckPermissionWithReadableErrorMsg() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("check-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Check permission for an EDIT_APPLICATIONS action + StepVerifier.create( + appIdMono.flatMap(appId -> + applicationApiService.checkPermissionWithReadableErrorMsg(appId, ResourceAction.EDIT_APPLICATIONS) + ) + ) + .assertNext(resourcePermission -> { + Assertions.assertNotNull(resourcePermission); + Assertions.assertTrue(resourcePermission.getResourceRole().canDo(ResourceAction.EDIT_APPLICATIONS)); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testCheckApplicationPermissionWithReadableErrorMsg() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("check-app-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Check permission for an EDIT_APPLICATIONS action with PUBLIC_TO_ALL request type + StepVerifier.create( + appIdMono.flatMap(appId -> + applicationApiService.checkApplicationPermissionWithReadableErrorMsg( + appId, ResourceAction.EDIT_APPLICATIONS, ApplicationRequestType.PUBLIC_TO_ALL) + ) + ) + .assertNext(resourcePermission -> { + Assertions.assertNotNull(resourcePermission); + Assertions.assertTrue(resourcePermission.getResourceRole().canDo(ResourceAction.EDIT_APPLICATIONS)); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testSetApplicationPublicToAll() { + Mono appIdMono = createApplication("public-to-all-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.setApplicationPublicToAll(appId, true)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testSetApplicationPublicToMarketplace() { + Mono appIdMono = createApplication("public-to-marketplace-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + ApplicationEndpoints.ApplicationPublicToMarketplaceRequest request = + new ApplicationEndpoints.ApplicationPublicToMarketplaceRequest(true); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.setApplicationPublicToMarketplace(appId, request)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testSetApplicationAsAgencyProfile() { + Mono appIdMono = createApplication("agency-profile-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.setApplicationAsAgencyProfile(appId, true)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateSlug() { + String uniqueAppName = "SlugTestApp-" + System.currentTimeMillis(); + String uniqueSlug = "new-slug-" + System.currentTimeMillis(); + + createApplication(uniqueAppName, null) + .map(applicationView -> applicationView.getApplicationInfoView().getApplicationId()) + .flatMap(applicationId -> applicationApiService.updateSlug(applicationId, uniqueSlug)) + .as(StepVerifier::create) + .expectComplete() // Expect no value, just completion + .verify(); + } + + @Test + @WithMockUser + public void testGetGroupsOrMembersWithoutPermissions() { + // Create a new application + Mono appIdMono = createApplication("no-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Grant permission to user02 and group01 + Mono> resultMono = appIdMono + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of("group01"), ResourceRole.EDITOR)) + .flatMap(appId -> applicationApiService.getGroupsOrMembersWithoutPermissions(appId)); + + StepVerifier.create(resultMono) + .assertNext(list -> { + // Should contain users/groups except user02 and group01 + Assertions.assertTrue(list.stream().noneMatch(obj -> obj.toString().contains("user02"))); + Assertions.assertTrue(list.stream().noneMatch(obj -> obj.toString().contains("group01"))); + }) + .verifyComplete(); + } + @Test @WithMockUser public void testAutoInheritFoldersPermissionsOnAppCreate() { @@ -334,25 +831,4 @@ public void testAppCreateAndRetrievalByGID() { }) .verifyComplete(); } - - // Skipping this test as it requires a database setup that's not available in the test environment - @Test - @WithMockUser - @Disabled("This test requires a database setup that's not available in the test environment") - public void testUpdateSlug() { - // Create a dummy application with a unique name to avoid conflicts - String uniqueAppName = "SlugTestApp-" + System.currentTimeMillis(); - String uniqueSlug = "new-slug-" + System.currentTimeMillis(); - - // Create the application and then update its slug - createApplication(uniqueAppName, null) - .map(applicationView -> applicationView.getApplicationInfoView().getApplicationId()) - .flatMap(applicationId -> applicationApiService.updateSlug(applicationId, uniqueSlug)) - .as(StepVerifier::create) - .assertNext(application -> { - Assertions.assertNotNull(application.getSlug(), "Slug should not be null"); - Assertions.assertEquals(uniqueSlug, application.getSlug(), "Slug should be updated to the new value"); - }) - .verifyComplete(); - } } \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java new file mode 100644 index 000000000..c09bc9d63 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java @@ -0,0 +1,1442 @@ +package org.lowcoder.api.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.application.ApplicationEndpoints.*; +import org.lowcoder.api.application.view.*; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.home.UserHomeApiService; +import org.lowcoder.api.home.UserHomepageView; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.api.util.GidService; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationRequestType; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.core.publisher.Flux; + +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +class ApplicationEndpointsTest { + + private UserHomeApiService userHomeApiService; + private ApplicationApiService applicationApiService; + private BusinessEventPublisher businessEventPublisher; + private GidService gidService; + private ApplicationRecordService applicationRecordService; + private ApplicationController controller; + + private static final String TEST_APPLICATION_ID = "test-app-id"; + private static final String TEST_ORGANIZATION_ID = "test-org-id"; + private static final String TEST_TEMPLATE_ID = "template-123"; + + @BeforeEach + void setUp() { + // Create mocks manually + userHomeApiService = Mockito.mock(UserHomeApiService.class); + applicationApiService = Mockito.mock(ApplicationApiService.class); + businessEventPublisher = Mockito.mock(BusinessEventPublisher.class); + gidService = Mockito.mock(GidService.class); + applicationRecordService = Mockito.mock(ApplicationRecordService.class); + + // Setup common mocks + when(businessEventPublisher.publishApplicationCommonEvent(any(), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationCommonEvent(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationPublishEvent(any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationVersionChangeEvent(any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationPermissionEvent(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationSharingEvent(any(), any(), any())).thenReturn(Mono.empty()); + + // Mock gidService to return the same ID that was passed to it + when(gidService.convertApplicationIdToObjectId(any())).thenAnswer(invocation -> { + String appId = invocation.getArgument(0); + return Mono.just(appId); + }); + when(gidService.convertLibraryQueryIdToObjectId(any())).thenAnswer(invocation -> { + String appId = invocation.getArgument(0); + return Mono.just(appId); + }); + + // Mock getApplicationPermissions to prevent null pointer exceptions + ApplicationPermissionView mockPermissionView = Mockito.mock(ApplicationPermissionView.class); + when(applicationApiService.getApplicationPermissions(any())).thenReturn(Mono.just(mockPermissionView)); + + // Mock setApplicationPublicToMarketplace to return a proper Mono + when(applicationApiService.setApplicationPublicToMarketplace(any(), any())).thenReturn(Mono.just(true)); + + // Mock setApplicationAsAgencyProfile to return a proper Mono + when(applicationApiService.setApplicationAsAgencyProfile(any(), anyBoolean())).thenReturn(Mono.just(true)); + + // Mock setApplicationPublicToAll to return a proper Mono + when(applicationApiService.setApplicationPublicToAll(any(), anyBoolean())).thenReturn(Mono.just(true)); + + // Mock getGroupsOrMembersWithoutPermissions to return a proper Mono + when(applicationApiService.getGroupsOrMembersWithoutPermissions(any())).thenReturn(Mono.just(List.of())); + + // Create controller with all required dependencies + controller = new ApplicationController( + userHomeApiService, + applicationApiService, + businessEventPublisher, + gidService, + applicationRecordService + ); + } + + @Test + void testCreateApplication_success() { + // Prepare request data + CreateApplicationRequest request = new CreateApplicationRequest( + TEST_ORGANIZATION_ID, + null, + "Test App", + 1, + new HashMap<>(), + null, + null, + null + ); + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.create(any(CreateApplicationRequest.class))) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + assert TEST_APPLICATION_ID.equals(response.getData().getApplicationInfoView().getApplicationId()); + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateApplication_withAllFields() { + // Prepare request data with all fields populated + HashMap dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + CreateApplicationRequest request = new CreateApplicationRequest( + TEST_ORGANIZATION_ID, + "test-gid", + "Test Application with All Fields", + 1, + dsl, + "folder-123", + true, + false + ); + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.create(any(CreateApplicationRequest.class))) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateApplication_serviceError() { + // Prepare request data + CreateApplicationRequest request = new CreateApplicationRequest( + TEST_ORGANIZATION_ID, + null, + "Error App", + 1, + new HashMap<>(), + null, + false, + false + ); + + when(applicationApiService.create(any(CreateApplicationRequest.class))) + .thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testCreateFromTemplate_success() { + // Mock the service response + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.createFromTemplate(TEST_TEMPLATE_ID)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(TEST_TEMPLATE_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + assert TEST_APPLICATION_ID.equals(response.getData().getApplicationInfoView().getApplicationId()); + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateFromTemplate_withDifferentTemplateId() { + // Test with a different template ID + String differentTemplateId = "template-456"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.createFromTemplate(differentTemplateId)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(differentTemplateId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateFromTemplate_serviceError() { + // Mock service error + when(applicationApiService.createFromTemplate(TEST_TEMPLATE_ID)) + .thenReturn(Mono.error(new RuntimeException("Template not found"))); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(TEST_TEMPLATE_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testCreateFromTemplate_withEmptyTemplateId() { + // Test with empty template ID + String emptyTemplateId = ""; + + when(applicationApiService.createFromTemplate(emptyTemplateId)) + .thenReturn(Mono.error(new IllegalArgumentException("Template ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(emptyTemplateId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + void testCreateFromTemplate_withNullTemplateId() { + // Test with null template ID + when(applicationApiService.createFromTemplate(null)) + .thenReturn(Mono.error(new IllegalArgumentException("Template ID cannot be null"))); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + void testRecycle_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.recycle(TEST_APPLICATION_ID)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.recycle(TEST_APPLICATION_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRecycle_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-456"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.recycle(differentAppId)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.recycle(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRecycle_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.recycle(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRecycle_recycleServiceError() { + // Mock successful get but failed recycle + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.recycle(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Recycle operation failed"))); + + // Test the controller method directly + Mono> result = controller.recycle(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRestore_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.restore(TEST_APPLICATION_ID)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.restore(TEST_APPLICATION_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRestore_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-789"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.restore(differentAppId)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.restore(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRestore_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.restore(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRestore_restoreServiceError() { + // Mock successful get but failed restore + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.restore(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Restore operation failed"))); + + // Test the controller method directly + Mono> result = controller.restore(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRecycle_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, true)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.recycle(emptyAppId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRestore_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, true)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.restore(emptyAppId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetRecycledApplications_success() { + // Mock the service response + List mockRecycledApps = List.of( + createMockApplicationInfoView(), + createMockApplicationInfoView() + ); + when(applicationApiService.getRecycledApplications(null, null)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, null); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 2; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_withNameFilter() { + // Mock the service response with name filter + String nameFilter = "test-app"; + List mockRecycledApps = List.of(createMockApplicationInfoView()); + when(applicationApiService.getRecycledApplications(nameFilter, null)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(nameFilter, null); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 1; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_withCategoryFilter() { + // Mock the service response with category filter + String categoryFilter = "business"; + List mockRecycledApps = List.of(createMockApplicationInfoView()); + when(applicationApiService.getRecycledApplications(null, categoryFilter)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, categoryFilter); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 1; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_withNameAndCategoryFilter() { + // Mock the service response with both filters + String nameFilter = "test-app"; + String categoryFilter = "business"; + List mockRecycledApps = List.of(createMockApplicationInfoView()); + when(applicationApiService.getRecycledApplications(nameFilter, categoryFilter)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(nameFilter, categoryFilter); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 1; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_emptyResult() { + // Mock empty service response + when(applicationApiService.getRecycledApplications(null, null)) + .thenReturn(Flux.empty()); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, null); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().isEmpty(); + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_serviceError() { + // Mock service error + when(applicationApiService.getRecycledApplications(null, null)) + .thenReturn(Flux.error(new RuntimeException("Database error"))); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testDelete_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.delete(TEST_APPLICATION_ID)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.delete(TEST_APPLICATION_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testDelete_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-999"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.delete(differentAppId)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.delete(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testDelete_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.delete(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testDelete_deleteServiceError() { + // Mock successful get but failed delete + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.delete(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Delete operation failed"))); + + // Test the controller method directly + Mono> result = controller.delete(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testDelete_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, true)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.delete(emptyAppId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetEditingApplication_success() { + // Mock the service response + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(TEST_APPLICATION_ID, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetEditingApplication_withDeleted() { + // Mock the service response with withDeleted=true + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(TEST_APPLICATION_ID, true); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetEditingApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-123"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(differentAppId, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetEditingApplication_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(TEST_APPLICATION_ID, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetEditingApplication_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, false)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(emptyAppId, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetPublishedApplication_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(TEST_APPLICATION_ID, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedApplication_withDeleted() { + // Mock the service responses with withDeleted=true + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_ALL, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(TEST_APPLICATION_ID, true); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-456"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(differentAppId, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(differentAppId, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedApplication_serviceError() { + // Mock service error + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(TEST_APPLICATION_ID, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetPublishedApplication_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getPublishedApplication(emptyAppId, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(emptyAppId, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetPublishedMarketPlaceApplication_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_MARKETPLACE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + Mono> result = controller.getPublishedMarketPlaceApplication(TEST_APPLICATION_ID); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedMarketPlaceApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-789"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(differentAppId, ApplicationRequestType.PUBLIC_TO_MARKETPLACE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedMarketPlaceApplication(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedMarketPlaceApplication_serviceError() { + // Mock service error + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_MARKETPLACE, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getPublishedMarketPlaceApplication(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetAgencyProfileApplication_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.AGENCY_PROFILE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + Mono> result = controller.getAgencyProfileApplication(TEST_APPLICATION_ID); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetAgencyProfileApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-999"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(differentAppId, ApplicationRequestType.AGENCY_PROFILE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getAgencyProfileApplication(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetAgencyProfileApplication_serviceError() { + // Mock service error + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.AGENCY_PROFILE, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getAgencyProfileApplication(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testUpdate_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.update(TEST_APPLICATION_ID, mockApplication, null)) + .thenReturn(Mono.just(mockApplicationView)); + + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdate_withUpdateStatus() { + // Mock the service responses with updateStatus=true + ApplicationView mockApplicationView = createMockApplicationView(); + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.update(TEST_APPLICATION_ID, mockApplication, true)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, true); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdate_serviceError() { + // Mock service error + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testUpdate_updateServiceError() { + // Mock successful get but failed update + ApplicationView mockApplicationView = createMockApplicationView(); + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.update(TEST_APPLICATION_ID, mockApplication, null)) + .thenReturn(Mono.error(new RuntimeException("Update operation failed"))); + + // Test the controller method directly + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testPublish_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationRecordService.getLatestRecordByApplicationId(any())) + .thenReturn(Mono.empty()); + when(applicationApiService.publish(any(), any(ApplicationPublishRequest.class))) + .thenReturn(Mono.just(mockApplicationView)); + + Mono> result = controller.publish(TEST_APPLICATION_ID, null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testPublish_withPublishRequest() { + // Mock the service responses with publish request + ApplicationView mockApplicationView = createMockApplicationView(); + ApplicationPublishRequest publishRequest = new ApplicationPublishRequest("test-tag", "1.0.0"); + when(applicationRecordService.getLatestRecordByApplicationId(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + when(applicationApiService.publish(TEST_APPLICATION_ID, publishRequest)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.publish(TEST_APPLICATION_ID, publishRequest); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testPublish_serviceError() { + // Mock service error + when(applicationRecordService.getLatestRecordByApplicationId(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Application record not found"))); + + // Test the controller method directly + Mono> result = controller.publish(TEST_APPLICATION_ID, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testUpdateEditState_success() { + UpdateEditStateRequest updateRequest = new UpdateEditStateRequest(true); + when(applicationApiService.updateEditState(TEST_APPLICATION_ID, updateRequest)) + .thenReturn(Mono.just(true)); + + Mono> result = controller.updateEditState(TEST_APPLICATION_ID, updateRequest); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdateSlug_success() { + String newSlug = "new-app-slug"; + Application mockApplication = createMockApplication(); + when(applicationApiService.updateSlug(TEST_APPLICATION_ID, newSlug)) + .thenReturn(Mono.just(mockApplication)); + + Mono> result = controller.updateSlug(TEST_APPLICATION_ID, newSlug); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetUserHomePage_success() { + UserHomepageView mockHomepageView = Mockito.mock(UserHomepageView.class); + when(userHomeApiService.getUserHomePageView(any())) + .thenReturn(Mono.just(mockHomepageView)); + + Mono> result = controller.getUserHomePage(0); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetApplications_success() { + List mockApps = List.of(createMockApplicationInfoView()); + when(userHomeApiService.getAllAuthorisedApplications4CurrentOrgMember(any(), any(), anyBoolean(), any(), any())) + .thenReturn(Flux.fromIterable(mockApps)); + + Mono>> result = controller.getApplications(null, null, true, null, null, 1, 10); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetMarketplaceApplications_success() { + List mockApps = List.of(Mockito.mock(MarketplaceApplicationInfoView.class)); + when(userHomeApiService.getAllMarketplaceApplications(any())) + .thenReturn(Flux.fromIterable(mockApps)); + + Mono>> result = controller.getMarketplaceApplications(null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetAgencyProfileApplications_success() { + List mockApps = List.of(Mockito.mock(MarketplaceApplicationInfoView.class)); + when(userHomeApiService.getAllAgencyProfileApplications(any())) + .thenReturn(Flux.fromIterable(mockApps)); + + Mono>> result = controller.getAgencyProfileApplications(null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdatePermission_success() { + UpdatePermissionRequest updateRequest = new UpdatePermissionRequest("editor"); + when(applicationApiService.updatePermission(eq(TEST_APPLICATION_ID), eq("permission-123"), any())) + .thenReturn(Mono.just(true)); + + Mono> result = controller.updatePermission(TEST_APPLICATION_ID, "permission-123", updateRequest); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRemovePermission_success() { + when(applicationApiService.removePermission(TEST_APPLICATION_ID, "permission-123")) + .thenReturn(Mono.just(true)); + + Mono> result = controller.removePermission(TEST_APPLICATION_ID, "permission-123"); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testGrantPermission_success() { + BatchAddPermissionRequest grantRequest = new BatchAddPermissionRequest("editor", Set.of("user1"), Set.of("group1")); + when(applicationApiService.grantPermission(TEST_APPLICATION_ID, Set.of("user1"), Set.of("group1"), ResourceRole.EDITOR)) + .thenReturn(Mono.just(true)); + + Mono> result = controller.grantPermission(TEST_APPLICATION_ID, grantRequest); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetApplicationPermissions_success() { + Mono> result = controller.getApplicationPermissions(TEST_APPLICATION_ID); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetGroupsOrMembersWithoutPermissions_success() { + Mono>> result = controller.getGroupsOrMembersWithoutPermissions(TEST_APPLICATION_ID, null, 1, 1000); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testSetApplicationPublicToAll_success() { + ApplicationPublicToAllRequest request = new ApplicationPublicToAllRequest(true); + + Mono> result = controller.setApplicationPublicToAll(TEST_APPLICATION_ID, request); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testSetApplicationPublicToMarketplace_success() { + ApplicationPublicToMarketplaceRequest request = new ApplicationPublicToMarketplaceRequest(true); + + Mono> result = controller.setApplicationPublicToMarketplace(TEST_APPLICATION_ID, request); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testSetApplicationAsAgencyProfile_success() { + ApplicationAsAgencyProfileRequest request = new ApplicationAsAgencyProfileRequest(true); + + Mono> result = controller.setApplicationAsAgencyProfile(TEST_APPLICATION_ID, request); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + // Helper methods to create mock objects + private ApplicationView createMockApplicationView() { + ApplicationView view = Mockito.mock(ApplicationView.class); + ApplicationInfoView infoView = createMockApplicationInfoView(); + when(view.getApplicationInfoView()).thenReturn(infoView); + return view; + } + + private ApplicationInfoView createMockApplicationInfoView() { + ApplicationInfoView view = Mockito.mock(ApplicationInfoView.class); + when(view.getApplicationId()).thenReturn(TEST_APPLICATION_ID); + when(view.getName()).thenReturn("Test Application"); + when(view.getApplicationType()).thenReturn(1); // ApplicationType.APPLICATION.getValue() + when(view.getApplicationStatus()).thenReturn(ApplicationStatus.NORMAL); + return view; + } + + private Application createMockApplication() { + Application application = Mockito.mock(Application.class); + when(application.getId()).thenReturn(TEST_APPLICATION_ID); + when(application.getName()).thenReturn("Test Application"); + when(application.getApplicationType()).thenReturn(1); // ApplicationType.APPLICATION.getValue() + when(application.getApplicationStatus()).thenReturn(ApplicationStatus.NORMAL); + return application; + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java new file mode 100644 index 000000000..373945d34 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java @@ -0,0 +1,470 @@ +package org.lowcoder.api.application; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints.ApplicationHistorySnapshotRequest; +import org.lowcoder.api.application.view.HistorySnapshotDslView; +import org.lowcoder.api.common.InitData; +import org.lowcoder.api.common.mockuser.WithMockUser; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import static org.lowcoder.sdk.constants.GlobalContext.VISITOR_TOKEN; +import org.lowcoder.api.application.view.ApplicationView; + +@SpringBootTest +@ActiveProfiles("test") // Uses embedded MongoDB +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ApplicationHistorySnapshotEndpointsIntegrationTest { + + @Autowired + private ApplicationHistorySnapshotController controller; + + @Autowired + private ApplicationController applicationController; + + @Autowired + private InitData initData; + + @BeforeAll + public void beforeAll() { + initData.init(); // Initialize test database with data + } + + @Test + @WithMockUser(id = "user01") + public void testCreateHistorySnapshotWithExistingApplication() { + // Use an existing application from test data instead of creating a new one + String existingAppId = "app01"; // This exists in the test data + + // Create history snapshot request for existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext() + ); + + System.out.println("Creating history snapshot for existing app: " + existingAppId); + + // Create history snapshot + Mono> result = controller.create(snapshotRequest) + .doOnNext(response -> { + System.out.println("History snapshot creation response: " + response); + }) + .doOnError(error -> { + System.err.println("History snapshot creation error: " + error.getMessage()); + error.printStackTrace(); + }) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testListHistorySnapshotsWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // First create a history snapshot for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext() + ); + + // Create snapshot and then list snapshots + Mono>> result = controller.create(snapshotRequest) + .then(controller.listAllHistorySnapshotBriefInfo( + existingAppId, + 1, + 10, + null, + null, + null, + null + )) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + Assertions.assertTrue((Long) response.getData().get("count") >= 1L); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testGetHistorySnapshotDslWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // First create a history snapshot for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext() + ); + + // Create snapshot and then get snapshot DSL + Mono> result = controller.create(snapshotRequest) + .then(controller.listAllHistorySnapshotBriefInfo( + existingAppId, + 1, + 10, + null, + null, + null, + null + )) + .flatMap(listResponse -> { + @SuppressWarnings("unchecked") + java.util.List snapshots = + (java.util.List) listResponse.getData().get("list"); + + if (!snapshots.isEmpty()) { + String snapshotId = snapshots.get(0).snapshotId(); + return controller.getHistorySnapshotDsl(existingAppId, snapshotId); + } else { + return Mono.error(new RuntimeException("No snapshots found")); + } + }) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertNotNull(response.getData().getApplicationsDsl()); + Assertions.assertNotNull(response.getData().getModuleDSL()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testListArchivedHistorySnapshotsWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // First create a history snapshot for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext() + ); + + // Create snapshot and then list archived snapshots + Mono>> result = controller.create(snapshotRequest) + .then(controller.listAllHistorySnapshotBriefInfoArchived( + existingAppId, + 1, + 10, + null, + null, + null, + null + )) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + // Archived snapshots might be empty in test environment + Assertions.assertNotNull(response.getData().get("count")); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testListArchivedHistorySnapshotsEmptyList() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Test the archived endpoint structure - in test environment, there are no archived snapshots + // so we test that the endpoint responds correctly with an empty list + Mono>> listResult = controller.listAllHistorySnapshotBriefInfoArchived( + existingAppId, + 1, + 10, + null, + null, + null, + null + ) + .contextWrite(setupTestContext()); + + // Verify that the archived list endpoint works correctly + StepVerifier.create(listResult) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + // In test environment, count should be 0 since no snapshots are archived + Assertions.assertEquals(0L, response.getData().get("count")); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testCreateMultipleSnapshotsWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Create multiple history snapshots for the existing application + ApplicationHistorySnapshotRequest snapshotRequest1 = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext("snapshot1") + ); + + ApplicationHistorySnapshotRequest snapshotRequest2 = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext("snapshot2") + ); + + // Create multiple snapshots and then list them + Mono>> result = controller.create(snapshotRequest1) + .then(controller.create(snapshotRequest2)) + .then(controller.listAllHistorySnapshotBriefInfo( + existingAppId, + 1, + 10, + null, + null, + null, + null + )) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + Assertions.assertTrue((Long) response.getData().get("count") >= 2L); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testCreateSnapshotWithEmptyDslWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Create history snapshot with empty DSL for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + new HashMap<>(), + createTestContext() + ); + + // Create snapshot + Mono> result = controller.create(snapshotRequest) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testCreateSnapshotWithComplexDslWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Create complex DSL + Map complexDsl = createComplexTestDsl(); + + // Create history snapshot with complex DSL for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + complexDsl, + createTestContext("complex-snapshot") + ); + + // Create snapshot + Mono> result = controller.create(snapshotRequest) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testApplicationCreationWorks() { + // Test that application creation works independently + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for Creation", + 1, + createTestDsl(), + null, + null, + null + ); + + System.out.println("Creating application with request: " + createRequest); + + Mono> result = applicationController.create(createRequest) + .doOnNext(response -> { + System.out.println("Application creation response: " + response); + if (response.isSuccess() && response.getData() != null) { + System.out.println("Application created successfully with ID: " + response.getData().getApplicationInfoView().getApplicationId()); + } else { + System.out.println("Application creation failed: " + response.getMessage()); + } + }) + .doOnError(error -> { + System.err.println("Application creation error: " + error.getMessage()); + error.printStackTrace(); + }) + .contextWrite(setupTestContext()); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertNotNull(response.getData().getApplicationInfoView()); + Assertions.assertNotNull(response.getData().getApplicationInfoView().getApplicationId()); + System.out.println("Successfully created application with ID: " + response.getData().getApplicationInfoView().getApplicationId()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testGetHistorySnapshotDslArchivedWithNonExistentSnapshot() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + String nonExistentSnapshotId = "non-existent-snapshot-id"; + + // Test that trying to get a non-existent archived snapshot returns an appropriate error + Mono> result = controller.getHistorySnapshotDslArchived( + existingAppId, + nonExistentSnapshotId + ) + .contextWrite(setupTestContext()); + + // Verify that the endpoint handles non-existent snapshots appropriately + StepVerifier.create(result) + .expectError() + .verify(); + } + + // Helper method to set up Reactor context for tests + private reactor.util.context.Context setupTestContext() { + return reactor.util.context.Context.of( + VISITOR_TOKEN, "test-token-" + System.currentTimeMillis(), + "headers", new HashMap() + ); + } + + // Helper methods + private Map createTestDsl() { + Map dsl = new HashMap<>(); + Map components = new HashMap<>(); + Map layout = new HashMap<>(); + + components.put("test-component", new HashMap<>()); + layout.put("type", "grid"); + + dsl.put("components", components); + dsl.put("layout", layout); + + return dsl; + } + + private Map createComplexTestDsl() { + Map dsl = new HashMap<>(); + Map components = new HashMap<>(); + Map layout = new HashMap<>(); + + // Create complex component structure + Map component1 = new HashMap<>(); + component1.put("type", "button"); + component1.put("text", "Click me"); + component1.put("style", Map.of("backgroundColor", "#007bff")); + + Map component2 = new HashMap<>(); + component2.put("type", "input"); + component2.put("placeholder", "Enter text"); + component2.put("style", Map.of("border", "1px solid #ccc")); + + components.put("button-1", component1); + components.put("input-1", component2); + + layout.put("type", "flex"); + layout.put("direction", "column"); + layout.put("items", java.util.List.of("button-1", "input-1")); + + dsl.put("components", components); + dsl.put("layout", layout); + + return dsl; + } + + private Map createTestContext() { + return createTestContext("test-snapshot"); + } + + private Map createTestContext(String snapshotName) { + Map context = new HashMap<>(); + context.put("action", "save"); + context.put("timestamp", Instant.now().toEpochMilli()); + context.put("name", snapshotName); + context.put("description", "Test snapshot created during integration test"); + return context; + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsTest.java new file mode 100644 index 000000000..7e1190e4e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsTest.java @@ -0,0 +1,570 @@ +package org.lowcoder.api.application; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints.ApplicationHistorySnapshotRequest; +import org.lowcoder.api.application.view.HistorySnapshotDslView; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.util.Pagination; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationHistorySnapshot; +import org.lowcoder.domain.application.model.ApplicationHistorySnapshotTS; +import org.lowcoder.domain.application.service.ApplicationHistorySnapshotService; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +class ApplicationHistorySnapshotEndpointsTest { + + private ResourcePermissionService resourcePermissionService; + private ApplicationHistorySnapshotService applicationHistorySnapshotService; + private SessionUserService sessionUserService; + private UserService userService; + private ApplicationService applicationService; + private ApplicationRecordService applicationRecordService; + private ApplicationHistorySnapshotController controller; + + private static final String TEST_APPLICATION_ID = "test-app-id"; + private static final String TEST_SNAPSHOT_ID = "test-snapshot-id"; + private static final String TEST_USER_ID = "test-user-id"; + private static final String TEST_USER_NAME = "Test User"; + private static final String TEST_USER_AVATAR = "https://example.com/avatar.jpg"; + + @BeforeEach + void setUp() { + // Create mocks manually + resourcePermissionService = Mockito.mock(ResourcePermissionService.class); + applicationHistorySnapshotService = Mockito.mock(ApplicationHistorySnapshotService.class); + sessionUserService = Mockito.mock(SessionUserService.class); + userService = Mockito.mock(UserService.class); + applicationService = Mockito.mock(ApplicationService.class); + applicationRecordService = Mockito.mock(ApplicationRecordService.class); + + // Setup common mocks + when(sessionUserService.getVisitorId()).thenReturn(Mono.just(TEST_USER_ID)); + when(resourcePermissionService.checkResourcePermissionWithError(anyString(), anyString(), any(ResourceAction.class))) + .thenReturn(Mono.empty()); + + // Create controller with all required dependencies + controller = new ApplicationHistorySnapshotController( + resourcePermissionService, + applicationHistorySnapshotService, + sessionUserService, + userService, + applicationService, + applicationRecordService + ); + } + + @Test + void testCreate_success() { + // Prepare request data + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + Map context = new HashMap<>(); + context.put("action", "save"); + context.put("timestamp", Instant.now().toEpochMilli()); + + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + dsl, + context + ); + + when(applicationHistorySnapshotService.createHistorySnapshot( + eq(TEST_APPLICATION_ID), + eq(dsl), + eq(context), + eq(TEST_USER_ID) + )).thenReturn(Mono.just(true)); + + when(applicationService.updateLastEditedAt(eq(TEST_APPLICATION_ID), any(Instant.class), eq(TEST_USER_ID))) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreate_withEmptyDsl() { + // Prepare request data with empty DSL + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + new HashMap<>(), + new HashMap<>() + ); + + when(applicationHistorySnapshotService.createHistorySnapshot( + eq(TEST_APPLICATION_ID), + any(Map.class), + any(Map.class), + eq(TEST_USER_ID) + )).thenReturn(Mono.just(true)); + + when(applicationService.updateLastEditedAt(eq(TEST_APPLICATION_ID), any(Instant.class), eq(TEST_USER_ID))) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreate_serviceError() { + // Prepare request data + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + new HashMap<>(), + new HashMap<>() + ); + + when(applicationHistorySnapshotService.createHistorySnapshot( + anyString(), + any(Map.class), + any(Map.class), + anyString() + )).thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testListAllHistorySnapshotBriefInfo_success() { + // Prepare test data + ApplicationHistorySnapshot snapshot1 = createMockApplicationHistorySnapshot("snapshot-1", "user1"); + ApplicationHistorySnapshot snapshot2 = createMockApplicationHistorySnapshot("snapshot-2", "user2"); + List snapshotList = List.of(snapshot1, snapshot2); + + User user1 = createMockUser("user1", "User One", "avatar1.jpg"); + User user2 = createMockUser("user2", "User Two", "avatar2.jpg"); + + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfo( + eq(TEST_APPLICATION_ID), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.just(snapshotList)); + + when(userService.getByIds(anyList())).thenReturn(Mono.just(Map.of("user1", user1, "user2", user2))); + when(applicationHistorySnapshotService.countByApplicationId(eq(TEST_APPLICATION_ID))) + .thenReturn(Mono.just(2L)); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfo( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().containsKey("list"); + assert response.getData().containsKey("count"); + assert (Long) response.getData().get("count") == 2L; + return true; + }) + .verifyComplete(); + } + + @Test + void testListAllHistorySnapshotBriefInfo_withNullFilters() { + // Prepare test data + List snapshotList = List.of(); + User user = createMockUser("user1", "User One", "avatar1.jpg"); + + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfo( + eq(TEST_APPLICATION_ID), + isNull(), + isNull(), + isNull(), + isNull(), + any() + )).thenReturn(Mono.just(snapshotList)); + + when(userService.getByIds(anyList())).thenReturn(Mono.just(Map.of())); + when(applicationHistorySnapshotService.countByApplicationId(eq(TEST_APPLICATION_ID))) + .thenReturn(Mono.just(0L)); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfo( + TEST_APPLICATION_ID, + 1, + 10, + null, + null, + null, + null + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().containsKey("list"); + assert response.getData().containsKey("count"); + assert (Long) response.getData().get("count") == 0L; + return true; + }) + .verifyComplete(); + } + + @Test + void testListAllHistorySnapshotBriefInfo_serviceError() { + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfo( + anyString(), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfo( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testListAllHistorySnapshotBriefInfoArchived_success() { + // Prepare test data + ApplicationHistorySnapshotTS snapshot1 = createMockApplicationHistorySnapshotTS("snapshot-1", "user1"); + ApplicationHistorySnapshotTS snapshot2 = createMockApplicationHistorySnapshotTS("snapshot-2", "user2"); + List snapshotList = List.of(snapshot1, snapshot2); + + User user1 = createMockUser("user1", "User One", "avatar1.jpg"); + User user2 = createMockUser("user2", "User Two", "avatar2.jpg"); + + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfoArchived( + eq(TEST_APPLICATION_ID), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.just(snapshotList)); + + when(userService.getByIds(anyList())).thenReturn(Mono.just(Map.of("user1", user1, "user2", user2))); + when(applicationHistorySnapshotService.countByApplicationIdArchived(eq(TEST_APPLICATION_ID))) + .thenReturn(Mono.just(2L)); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfoArchived( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().containsKey("list"); + assert response.getData().containsKey("count"); + assert (Long) response.getData().get("count") == 2L; + return true; + }) + .verifyComplete(); + } + + @Test + void testListAllHistorySnapshotBriefInfoArchived_serviceError() { + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfoArchived( + anyString(), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfoArchived( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetHistorySnapshotDsl_success() { + // Prepare test data + ApplicationHistorySnapshot snapshot = createMockApplicationHistorySnapshot(TEST_SNAPSHOT_ID, TEST_USER_ID); + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + List dependentModules = List.of(); + + when(applicationHistorySnapshotService.getHistorySnapshotDetail(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.just(snapshot)); + when(applicationService.getAllDependentModulesFromDsl(any(Map.class))) + .thenReturn(Mono.just(dependentModules)); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDsl( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationsDsl() != null; + assert response.getData().getModuleDSL() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetHistorySnapshotDsl_withDependentModules() { + // Prepare test data + ApplicationHistorySnapshot snapshot = createMockApplicationHistorySnapshot(TEST_SNAPSHOT_ID, TEST_USER_ID); + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + Application dependentApp = createMockApplication("dependent-app-id"); + List dependentModules = List.of(dependentApp); + + when(applicationHistorySnapshotService.getHistorySnapshotDetail(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.just(snapshot)); + when(applicationService.getAllDependentModulesFromDsl(any(Map.class))) + .thenReturn(Mono.just(dependentModules)); + when(dependentApp.getLiveApplicationDsl(applicationRecordService)) + .thenReturn(Mono.just(new HashMap<>())); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDsl( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationsDsl() != null; + assert response.getData().getModuleDSL() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetHistorySnapshotDsl_serviceError() { + when(applicationHistorySnapshotService.getHistorySnapshotDetail(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDsl( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetHistorySnapshotDslArchived_success() { + // Prepare test data + ApplicationHistorySnapshotTS snapshot = createMockApplicationHistorySnapshotTS(TEST_SNAPSHOT_ID, TEST_USER_ID); + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + List dependentModules = List.of(); + + when(applicationHistorySnapshotService.getHistorySnapshotDetailArchived(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.just(snapshot)); + when(applicationService.getAllDependentModulesFromDsl(any(Map.class))) + .thenReturn(Mono.just(dependentModules)); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDslArchived( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationsDsl() != null; + assert response.getData().getModuleDSL() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetHistorySnapshotDslArchived_serviceError() { + when(applicationHistorySnapshotService.getHistorySnapshotDetailArchived(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDslArchived( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testPermissionCheck_failure() { + // Prepare request data + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + new HashMap<>(), + new HashMap<>() + ); + + when(resourcePermissionService.checkResourcePermissionWithError( + eq(TEST_USER_ID), + eq(TEST_APPLICATION_ID), + eq(ResourceAction.EDIT_APPLICATIONS) + )).thenReturn(Mono.error(new RuntimeException("Permission denied"))); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + // Helper methods to create mock objects + private ApplicationHistorySnapshot createMockApplicationHistorySnapshot(String snapshotId, String userId) { + ApplicationHistorySnapshot snapshot = Mockito.mock(ApplicationHistorySnapshot.class); + when(snapshot.getId()).thenReturn(snapshotId); + when(snapshot.getApplicationId()).thenReturn(TEST_APPLICATION_ID); + when(snapshot.getCreatedBy()).thenReturn(userId); + when(snapshot.getCreatedAt()).thenReturn(Instant.now()); + when(snapshot.getDsl()).thenReturn(new HashMap<>()); + when(snapshot.getContext()).thenReturn(new HashMap<>()); + return snapshot; + } + + private ApplicationHistorySnapshotTS createMockApplicationHistorySnapshotTS(String snapshotId, String userId) { + ApplicationHistorySnapshotTS snapshot = Mockito.mock(ApplicationHistorySnapshotTS.class); + when(snapshot.getId()).thenReturn(snapshotId); + when(snapshot.getApplicationId()).thenReturn(TEST_APPLICATION_ID); + when(snapshot.getCreatedBy()).thenReturn(userId); + when(snapshot.getCreatedAt()).thenReturn(Instant.now()); + when(snapshot.getDsl()).thenReturn(new HashMap<>()); + when(snapshot.getContext()).thenReturn(new HashMap<>()); + return snapshot; + } + + private User createMockUser(String userId, String userName, String avatarUrl) { + User user = Mockito.mock(User.class); + when(user.getId()).thenReturn(userId); + when(user.getName()).thenReturn(userName); + when(user.getAvatarUrl()).thenReturn(avatarUrl); + return user; + } + + private Application createMockApplication(String appId) { + Application app = Mockito.mock(Application.class); + when(app.getId()).thenReturn(appId); + return app; + } +} \ No newline at end of file