diff --git a/web/__test__/components/DropdownContent.test.ts b/web/__test__/components/DropdownContent.test.ts new file mode 100644 index 0000000000..e2be458c29 --- /dev/null +++ b/web/__test__/components/DropdownContent.test.ts @@ -0,0 +1,211 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { shallowMount } from '@vue/test-utils'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ServerStateData, ServerStateDataAction } from '~/types/server'; +import type { UserProfileLink } from '~/types/userProfile'; +import type { Ref } from 'vue'; + +import DropdownContent from '~/components/UserProfile/DropdownContent.vue'; +import { createTestI18n } from '../utils/i18n'; + +const { accountStoreMocks, errorsStoreRefs, serverStoreRefs, updateOsStoreRefs } = vi.hoisted(() => ({ + accountStoreMocks: { + manage: vi.fn(), + myKeys: vi.fn(), + }, + errorsStoreRefs: { + errors: null as Ref | null, + }, + serverStoreRefs: { + connectPluginInstalled: null as Ref<'dynamix.unraid.net.plg' | ''> | null, + keyActions: null as Ref | null, + rebootType: null as Ref | null, + registered: null as Ref | null, + regUpdatesExpired: null as Ref | null, + stateData: null as Ref | null, + stateDataError: null as Ref<{ message: string } | undefined> | null, + }, + updateOsStoreRefs: { + available: null as Ref | null, + availableWithRenewal: null as Ref | null, + }, +})); + +vi.mock('~/store/account', () => ({ + useAccountStore: () => accountStoreMocks, +})); + +vi.mock('~/store/errors', async () => { + const { ref } = await import('vue'); + const { defineStore } = await import('pinia'); + + errorsStoreRefs.errors = ref([]); + + const useErrorsStore = defineStore('errorsMockForDropdownContent', () => ({ + errors: errorsStoreRefs.errors!, + })); + + return { useErrorsStore }; +}); + +vi.mock('~/store/updateOs', async () => { + const { ref } = await import('vue'); + const { defineStore } = await import('pinia'); + + updateOsStoreRefs.available = ref(null); + updateOsStoreRefs.availableWithRenewal = ref(null); + + const useUpdateOsStore = defineStore('updateOsMockForDropdownContent', () => ({ + available: updateOsStoreRefs.available!, + availableWithRenewal: updateOsStoreRefs.availableWithRenewal!, + localCheckForUpdate: vi.fn(), + setModalOpen: vi.fn(), + })); + + return { useUpdateOsStore }; +}); + +vi.mock('~/store/server', async () => { + const { ref } = await import('vue'); + const { defineStore } = await import('pinia'); + + serverStoreRefs.keyActions = ref([]); + serverStoreRefs.connectPluginInstalled = ref('dynamix.unraid.net.plg'); + serverStoreRefs.rebootType = ref(''); + serverStoreRefs.registered = ref(false); + serverStoreRefs.regUpdatesExpired = ref(false); + serverStoreRefs.stateData = ref({ + actions: [], + error: false, + heading: '', + humanReadable: '', + message: '', + }); + serverStoreRefs.stateDataError = ref(undefined); + + const useServerStore = defineStore('serverMockForDropdownContent', () => ({ + keyActions: serverStoreRefs.keyActions!, + connectPluginInstalled: serverStoreRefs.connectPluginInstalled!, + rebootType: serverStoreRefs.rebootType!, + registered: serverStoreRefs.registered!, + regUpdatesExpired: serverStoreRefs.regUpdatesExpired!, + stateData: serverStoreRefs.stateData!, + stateDataError: serverStoreRefs.stateDataError!, + })); + + return { useServerStore }; +}); + +describe('DropdownContent', () => { + const isManageLicenseItem = ( + item: unknown + ): item is UserProfileLink<'manageLicense'> & { name: 'manageLicense' } => { + return ( + typeof item === 'object' && item !== null && (item as { name?: string }).name === 'manageLicense' + ); + }; + + beforeEach(() => { + setActivePinia(createPinia()); + + serverStoreRefs.keyActions!.value = []; + serverStoreRefs.connectPluginInstalled!.value = 'dynamix.unraid.net.plg'; + serverStoreRefs.rebootType!.value = ''; + serverStoreRefs.registered!.value = false; + serverStoreRefs.regUpdatesExpired!.value = false; + serverStoreRefs.stateData!.value = { + actions: [{ name: 'signIn', text: 'Sign in to Unraid Connect' }], + error: false, + heading: '', + humanReadable: '', + message: '', + }; + serverStoreRefs.stateDataError!.value = undefined; + + errorsStoreRefs.errors!.value = []; + updateOsStoreRefs.available!.value = null; + updateOsStoreRefs.availableWithRenewal!.value = null; + }); + + it('does not show manage-license helper text when sign-in is the only action', () => { + const wrapper = shallowMount(DropdownContent, { + global: { + plugins: [createTestI18n()], + }, + }); + + expect(wrapper.text()).toContain('Sign In to your Unraid.net account to get started'); + expect(wrapper.text()).not.toContain( + 'Replace, recover, or link your license on your Unraid Account.' + ); + }); + + it('shows manage-license helper text when key actions are available', () => { + serverStoreRefs.keyActions!.value = [{ name: 'replace', text: 'Replace Key' }]; + + const wrapper = shallowMount(DropdownContent, { + global: { + plugins: [createTestI18n()], + }, + }); + + expect(wrapper.text()).toContain('Replace, recover, or link your license on your Unraid Account.'); + }); + + it('shows the localized trial helper text when trial start is available', () => { + serverStoreRefs.keyActions!.value = [{ name: 'trialStart', text: 'Start Trial' }]; + + const wrapper = shallowMount(DropdownContent, { + global: { + plugins: [createTestI18n()], + }, + }); + + expect(wrapper.text()).toContain('Start your trial from Manage License in your Unraid Account.'); + }); + + it('uses the first key action behavior for Manage License', () => { + const replaceClick = vi.fn(); + serverStoreRefs.registered!.value = true; + serverStoreRefs.stateData!.value = { + actions: [], + error: false, + heading: '', + humanReadable: '', + message: '', + }; + serverStoreRefs.keyActions!.value = [ + { + click: replaceClick, + clickParams: ['foo'], + disabled: true, + external: true, + name: 'replace', + text: 'Replace Key', + title: 'Replace', + }, + ]; + + const wrapper = shallowMount(DropdownContent, { + global: { + plugins: [createTestI18n()], + }, + }); + + const dropdownItems = wrapper.findAllComponents({ name: 'DropdownItem' }); + const manageItem = dropdownItems + .map((itemWrapper) => itemWrapper.props('item')) + .find(isManageLicenseItem); + + expect(manageItem).toBeDefined(); + expect(manageItem?.disabled).toBe(true); + expect(manageItem?.external).toBe(true); + + manageItem?.click?.(manageItem.clickParams); + + expect(replaceClick).toHaveBeenCalledTimes(1); + expect(accountStoreMocks.myKeys).not.toHaveBeenCalled(); + }); +}); diff --git a/web/__test__/store/account.test.ts b/web/__test__/store/account.test.ts index f8b7fcadbb..d0512b72b6 100644 --- a/web/__test__/store/account.test.ts +++ b/web/__test__/store/account.test.ts @@ -38,6 +38,12 @@ const mockUseMutation = vi.fn(() => { }; }); +const { activationCodeStoreMock } = vi.hoisted(() => ({ + activationCodeStoreMock: { + activationCode: null as { code?: string; partner?: string; system?: string } | null, + }, +})); + const mockSend = vi.fn(); const mockSetError = vi.fn(); @@ -74,6 +80,10 @@ vi.mock('~/store/unraidApi', () => ({ }), })); +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ + useActivationCodeDataStore: () => activationCodeStoreMock, +})); + describe('Account Store', () => { let store: ReturnType; @@ -83,6 +93,7 @@ describe('Account Store', () => { vi.mocked(useMutation); vi.clearAllMocks(); vi.useFakeTimers(); + activationCodeStoreMock.activationCode = null; }); afterEach(() => { @@ -113,6 +124,37 @@ describe('Account Store', () => { ); }); + it('should include activationCodeData in payload when available', () => { + activationCodeStoreMock.activationCode = { + code: 'PARTNER-CODE-123', + partner: 'Partner Name', + system: 'Partner System', + }; + + store.myKeys(); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + ACCOUNT_CALLBACK.toString(), + [ + { + server: { + guid: 'test-guid', + name: 'test-server', + activationCodeData: { + code: 'PARTNER-CODE-123', + partner: 'Partner Name', + system: 'Partner System', + }, + }, + type: 'myKeys', + }, + ], + undefined, + 'post' + ); + }); + it('should call recover action correctly', () => { store.recover(); expect(mockSend).toHaveBeenCalledTimes(1); diff --git a/web/src/components/UserProfile/DropdownContent.vue b/web/src/components/UserProfile/DropdownContent.vue index e0ce87a93d..dc0572c941 100644 --- a/web/src/components/UserProfile/DropdownContent.vue +++ b/web/src/components/UserProfile/DropdownContent.vue @@ -21,6 +21,7 @@ import { WEBGUI_TOOLS_UPDATE, } from '~/helpers/urls'; +import type { ServerStateDataAction } from '~/types/server'; import type { UserProfileLink } from '~/types/userProfile'; import Beta from '~/components/UserProfile/Beta.vue'; @@ -62,44 +63,53 @@ const signInAction = computed( const signOutAction = computed( () => stateData.value.actions?.filter((act: { name: string }) => act.name === 'signOut') ?? [] ); -const createManageLicenseAction = (text: string): UserProfileLink<'manageLicense'> => { +const createManageLicenseAction = ( + text: string, + sourceAction?: ServerStateDataAction +): UserProfileLink<'manageLicense'> => { + const wrappedClick = sourceAction?.click + ? (...args: Parameters>) => { + sourceAction.click?.(...args); + emit('close-dropdown'); + } + : sourceAction + ? undefined + : () => { + accountStore.myKeys(); + emit('close-dropdown'); + }; + return { - click: () => { - accountStore.myKeys(); - emit('close-dropdown'); - }, - external: true, - icon: KeyIcon, + ...sourceAction, + click: wrappedClick, + external: sourceAction?.external ?? true, + icon: sourceAction?.icon ?? KeyIcon, name: 'manageLicense', text, - title: text, + title: sourceAction?.title ?? text, }; }; -const licenseActionsToManage = new Set(['activate', 'purchase', 'recover', 'redeem', 'trialStart']); +const manageLicenseAction = computed(() => + createManageLicenseAction('onboarding.licenseStep.actions.manageLicense', keyActions.value?.[0]) +); -/** - * Filter out the renew action from the key actions so we can display it separately and link to the Tools > Registration page - */ const filteredKeyActions = computed(() => { - const actions = keyActions.value?.filter((action) => !['renew'].includes(action.name)); - - if (!actions?.length) { - return actions; + if (!keyActions.value?.length) { + return keyActions.value; } - - const hasLegacyLicenseAction = actions.some((action) => licenseActionsToManage.has(action.name)); - - if (!hasLegacyLicenseAction) { - return actions; - } - - const hasTrialStart = actions.some((action) => action.name === 'trialStart'); - const manageActionText = hasTrialStart - ? 'Manage License / Start Trial' - : 'onboarding.licenseStep.actions.manageLicense'; - const nonLicenseActions = actions.filter((action) => !licenseActionsToManage.has(action.name)); - return [createManageLicenseAction(manageActionText), ...nonLicenseActions]; + return [manageLicenseAction.value]; }); +const showManageLicenseHelperText = computed( + () => !!filteredKeyActions.value?.some((action) => action.name === 'manageLicense') +); +const hasTrialStartAction = computed( + () => keyActions.value?.some((action) => action.name === 'trialStart') ?? false +); +const manageLicenseHelperText = computed(() => + hasTrialStartAction.value + ? t('onboarding.licenseStep.actions.manageLicenseTrialStartHelperText') + : t('onboarding.licenseStep.actions.manageLicenseHelperText') +); const manageUnraidNetAccount = computed((): UserProfileLink => { return { @@ -264,10 +274,13 @@ const unraidConnectWelcome = computed(() => { -