11import type { AppSiteUpdatePayload } from '@dify/contracts/api/console/apps/types.gen'
22import type { ReactElement , ReactNode } from 'react'
33import type { SettingsAppInfo } from '../index'
4- import { act , fireEvent , screen , waitFor } from '@testing-library/react'
4+ import { act , fireEvent , screen , waitFor , within } from '@testing-library/react'
55import userEvent from '@testing-library/user-event'
66import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
77import { consoleQuery } from '@/service/console'
@@ -39,32 +39,6 @@ vi.mock('react-i18next', async () => {
3939 }
4040} )
4141
42- const toastMocks = vi . hoisted ( ( ) => ( {
43- call : vi . fn ( ) ,
44- dismiss : vi . fn ( ) ,
45- update : vi . fn ( ) ,
46- promise : vi . fn ( ) ,
47- } ) )
48-
49- vi . mock ( '@/app/notifications' , ( ) => ( {
50- toast : Object . assign ( toastMocks . call , {
51- success : vi . fn ( ( message : string , options ?: Record < string , unknown > ) =>
52- toastMocks . call ( { type : 'success' , message, ...options } ) ,
53- ) ,
54- error : vi . fn ( ( message : string , options ?: Record < string , unknown > ) =>
55- toastMocks . call ( { type : 'error' , message, ...options } ) ,
56- ) ,
57- warning : vi . fn ( ( message : string , options ?: Record < string , unknown > ) =>
58- toastMocks . call ( { type : 'warning' , message, ...options } ) ,
59- ) ,
60- info : vi . fn ( ( message : string , options ?: Record < string , unknown > ) =>
61- toastMocks . call ( { type : 'info' , message, ...options } ) ,
62- ) ,
63- dismiss : toastMocks . dismiss ,
64- update : toastMocks . update ,
65- promise : toastMocks . promise ,
66- } ) ,
67- } ) )
6842const mockOnSave = vi . fn < ( params : AppSiteUpdatePayload ) => Promise < boolean > > ( )
6943
7044vi . mock ( '@/context/i18n' , async ( ) => {
@@ -121,7 +95,6 @@ function render(...args: Parameters<typeof renderWithData>) {
12195
12296describe ( 'SettingsDialog' , ( ) => {
12397 beforeEach ( ( ) => {
124- toastMocks . call . mockClear ( )
12598 mockOnSave . mockReset ( ) . mockResolvedValue ( true )
12699 onPricingUrlUpdate . mockClear ( )
127100 deploymentEdition = 'CLOUD'
@@ -190,7 +163,7 @@ describe('SettingsDialog', () => {
190163 )
191164 } )
192165
193- it ( 'names the Inverted switch from its visible label and exposes its state ' , async ( ) => {
166+ it ( 'toggles inversion through its label without changing the theme color ' , async ( ) => {
194167 const user = userEvent . setup ( )
195168 await renderSettingsDialog ( )
196169
@@ -199,58 +172,84 @@ describe('SettingsDialog', () => {
199172 } )
200173 expect ( invertedSwitch ) . toBeChecked ( )
201174
202- await user . click ( invertedSwitch )
175+ await user . click (
176+ screen . getByText ( 'appOverview.overview.appInfo.settings.chatColorThemeInverted' ) ,
177+ )
203178
204179 expect ( invertedSwitch ) . not . toBeChecked ( )
180+ expect (
181+ screen . getByRole ( 'textbox' , { name : 'appOverview.overview.appInfo.settings.chatColorTheme' } ) ,
182+ ) . toHaveValue ( '#123456' )
205183 } )
206184
207- it ( 'should notify the user when the name is empty' , async ( ) => {
208- await renderSettingsDialog ( )
209- const nameInput = screen . getByPlaceholderText ( 'app.appNamePlaceholder' )
210- fireEvent . change ( nameInput , { target : { value : '' } } )
211- fireEvent . click ( screen . getByText ( 'common.operation.save' ) )
212-
213- await waitFor ( ( ) => {
214- expect ( toastMocks . call ) . toHaveBeenCalledWith (
215- expect . objectContaining ( { message : 'app.newApp.nameNotEmpty' } ) ,
216- )
217- } )
218- expect ( mockOnSave ) . not . toHaveBeenCalled ( )
219- } )
185+ it . each ( [
186+ {
187+ label : 'webName' ,
188+ invalid : '' ,
189+ valid : 'Updated app' ,
190+ error : 'app.newApp.nameNotEmpty' ,
191+ } ,
192+ {
193+ label : 'chatColorTheme' ,
194+ invalid : '#123456extra' ,
195+ valid : '#A020F0' ,
196+ error : 'appOverview.overview.appInfo.settings.invalidHexMessage' ,
197+ } ,
198+ {
199+ label : 'more.privacyPolicy' ,
200+ invalid : 'ftp://invalid-url' ,
201+ valid : 'https://example.com/privacy' ,
202+ error : 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy' ,
203+ } ,
204+ ] ) (
205+ 'associates the $label validation error with its control and permits correction' ,
206+ async ( { label, invalid, valid, error } ) => {
207+ const user = userEvent . setup ( )
208+ await renderSettingsDialog ( )
209+ const input = screen . getByRole ( 'textbox' , {
210+ name : `appOverview.overview.appInfo.settings.${ label } ` ,
211+ } )
212+ await user . clear ( input )
213+ if ( invalid ) await user . type ( input , invalid )
214+ await user . click ( screen . getByRole ( 'button' , { name : 'common.operation.save' } ) )
220215
221- it ( 'should validate the theme color and show an error when the hex is invalid' , async ( ) => {
222- await renderSettingsDialog ( )
223- const colorInput = screen . getByPlaceholderText ( 'E.g #A020F0' )
224- fireEvent . change ( colorInput , { target : { value : ' not-a-hex' } } )
216+ expect ( await screen . findByText ( error ) ) . toBeVisible ( )
217+ expect ( input ) . toHaveAttribute ( 'aria-invalid' , 'true' )
218+ expect ( input ) . toHaveAccessibleDescription ( expect . stringContaining ( error ) )
219+ expect ( mockOnSave ) . not . toHaveBeenCalled ( )
225220
226- fireEvent . click ( screen . getByText ( 'common.operation.save' ) )
227- await waitFor ( ( ) => {
228- expect ( toastMocks . call ) . toHaveBeenCalledWith (
229- expect . objectContaining ( {
230- message : 'appOverview.overview.appInfo.settings.invalidHexMessage' ,
231- } ) ,
232- )
233- } )
234- expect ( mockOnSave ) . not . toHaveBeenCalled ( )
235- } )
221+ await user . clear ( input )
222+ await user . type ( input , valid )
223+ await user . click ( screen . getByRole ( 'button' , { name : 'common.operation.save' } ) )
224+ await waitFor ( ( ) => expect ( mockOnSave ) . toHaveBeenCalledOnce ( ) )
225+ } ,
226+ )
236227
237- it ( 'should validate the privacy policy URL' , async ( ) => {
228+ it ( 'preserves the copyright draft when toggled and saves it with the selected language' , async ( ) => {
229+ const user = userEvent . setup ( )
238230 await renderSettingsDialog ( )
239- const privacyInput = screen . getByPlaceholderText (
240- 'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder' ,
241- )
242-
243- fireEvent . change ( privacyInput , { target : { value : 'ftp://invalid-url' } } )
231+ const prefix = 'appOverview.overview.appInfo.settings'
232+ const copyrightGroup = within ( screen . getByRole ( 'group' , { name : `${ prefix } .more.copyright` } ) )
233+ const copyrightSwitch = copyrightGroup . getByRole ( 'switch' , { name : `${ prefix } .more.copyright` } )
234+ await user . click ( copyrightSwitch )
235+ expect ( copyrightGroup . queryByRole ( 'textbox' ) ) . not . toBeInTheDocument ( )
236+ await user . click ( copyrightSwitch )
237+ expect ( copyrightGroup . getByRole ( 'textbox' ) ) . toHaveValue ( '© Dify' )
238+ await user . clear ( copyrightGroup . getByRole ( 'textbox' ) )
239+ await user . type ( copyrightGroup . getByRole ( 'textbox' ) , 'Updated copyright' )
240+ const language = screen . getByRole ( 'combobox' , { name : `${ prefix } .language` } )
241+ await user . click ( language )
242+ await user . click ( screen . getByRole ( 'option' , { name : '简体中文' } ) )
243+ await user . click ( screen . getByRole ( 'button' , { name : 'common.operation.save' } ) )
244244
245- fireEvent . click ( screen . getByText ( 'common.operation.save' ) )
246- await waitFor ( ( ) => {
247- expect ( toastMocks . call ) . toHaveBeenCalledWith (
245+ await waitFor ( ( ) =>
246+ expect ( mockOnSave ) . toHaveBeenCalledWith (
248247 expect . objectContaining ( {
249- message : 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy' ,
248+ default_language : 'zh-Hans' ,
249+ copyright : 'Updated copyright' ,
250250 } ) ,
251- )
252- } )
253- expect ( mockOnSave ) . not . toHaveBeenCalled ( )
251+ ) ,
252+ )
254253 } )
255254
256255 it ( 'should save valid settings and close the modal' , async ( ) => {
@@ -343,7 +342,6 @@ describe('SettingsDialog', () => {
343342 await waitFor ( ( ) => expect ( name ) . not . toHaveAttribute ( 'readonly' ) )
344343 expect ( screen . getByRole ( 'dialog' , { name : dialogName } ) ) . toBeInTheDocument ( )
345344 expect ( name ) . toHaveValue ( 'Unsaved title' )
346- expect ( toastMocks . call ) . not . toHaveBeenCalled ( )
347345 await user . click ( screen . getByRole ( 'button' , { name : 'common.operation.save' } ) )
348346 await waitFor ( ( ) =>
349347 expect ( screen . queryByRole ( 'dialog' , { name : dialogName } ) ) . not . toBeInTheDocument ( ) ,
@@ -530,7 +528,18 @@ describe('SettingsDialog', () => {
530528 copyrightEnabled = false
531529
532530 await renderSettingsDialog ( )
533- fireEvent . click ( ( await screen . findAllByText ( 'billing.upgradeBtn.encourageShort' ) ) [ 0 ] ! )
531+ const copyrightGroup = within (
532+ screen . getByRole ( 'group' , {
533+ name : 'appOverview.overview.appInfo.settings.more.copyright' ,
534+ } ) ,
535+ )
536+ expect ( copyrightGroup . getByRole ( 'switch' ) ) . toHaveAttribute ( 'aria-disabled' , 'true' )
537+ expect ( copyrightGroup . getByRole ( 'textbox' ) ) . toBeDisabled ( )
538+ await userEvent . setup ( ) . click (
539+ copyrightGroup . getByRole ( 'button' , {
540+ name : 'billing.upgradeBtn.encourageShort' ,
541+ } ) ,
542+ )
534543
535544 await waitFor ( ( ) =>
536545 expect ( onPricingUrlUpdate . mock . lastCall ?. [ 0 ] . searchParams . get ( 'pricing' ) ) . toBe ( 'open' ) ,
0 commit comments