diff --git a/.cursor/rules/web-testing-rules.mdc b/.cursor/rules/web-testing-rules.mdc index 32f3317e2..cdd4b5804 100644 --- a/.cursor/rules/web-testing-rules.mdc +++ b/.cursor/rules/web-testing-rules.mdc @@ -8,7 +8,7 @@ alwaysApply: false - This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment - Nuxt is currently set to auto import so some vue files may need compute or ref imported - Use pnpm when running termical commands and stay within the web directory. -- The directory for tests is located under `web/test` when running test just run `pnpm test` +- The directory for tests is located under `web/__test__` when running test just run `pnpm test` ### Setup - Use `mount` from Vue Test Utils for component testing @@ -18,6 +18,8 @@ alwaysApply: false ```typescript import { mount } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTestingPinia } from '@pinia/testing' +import { useSomeStore } from '@/stores/myStore' import YourComponent from '~/components/YourComponent.vue'; // Mock dependencies @@ -33,14 +35,25 @@ describe('YourComponent', () => { it('renders correctly', () => { const wrapper = mount(YourComponent, { global: { + plugins: [createTestingPinia()], stubs: { // Stub child components when needed ChildComponent: true, }, }, }); + + const store = useSomeStore() // uses the testing pinia! + // state can be directly manipulated + store.name = 'my new name' + + // actions are stubbed by default, meaning they don't execute their code by default. + // See below to customize this behavior. + store.someAction() + + expect(store.someAction).toHaveBeenCalledTimes(1) - // Assertions + // Assertions on components expect(wrapper.text()).toContain('Expected content'); }); }); @@ -51,6 +64,7 @@ describe('YourComponent', () => { - Verify that the expected elements are rendered - Test component interactions (clicks, inputs, etc.) - Check for expected prop handling and event emissions +- Use `createTestingPinia()` for mocking stores in components ### Finding Elements - Use semantic queries like `find('button')` or `find('[data-test="id"]')` but prefer not to use data test ID's @@ -84,6 +98,8 @@ describe('YourComponent', () => { ## Store Testing with Pinia ### Basic Setup +- When testing Store files use `createPinia` and `setActivePinia` + ```typescript import { createPinia, setActivePinia } from 'pinia'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/web/__test__/components/Auth.test.ts b/web/__test__/components/Auth.test.ts index d56fc8125..e6004e744 100644 --- a/web/__test__/components/Auth.test.ts +++ b/web/__test__/components/Auth.test.ts @@ -2,125 +2,139 @@ * Auth Component Test Coverage */ -import { ref } from 'vue'; +import { nextTick, ref } from 'vue'; import { mount } from '@vue/test-utils'; -import { describe, expect, it, vi } from 'vitest'; +import { GlobeAltIcon } from '@heroicons/vue/24/solid'; +import { createTestingPinia } from '@pinia/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ServerconnectPluginInstalled } from '~/types/server'; import Auth from '~/components/Auth.ce.vue'; +import { useServerStore } from '~/store/server'; -// Define types for our mocks -interface AuthAction { - text: string; - icon: string; - click?: () => void; - disabled?: boolean; - title?: string; -} - -interface StateData { - error: boolean; - heading?: string; - message?: string; -} - -// Mock vue-i18n vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, }), })); -// Mock the useServerStore composable -const mockServerStore = { - authAction: ref(undefined), - stateData: ref({ error: false }), -}; - -vi.mock('~/store/server', () => ({ - useServerStore: () => mockServerStore, +vi.mock('crypto-js/aes', () => ({ + default: {}, })); -// Mock pinia's storeToRefs to simply return the store -vi.mock('pinia', () => ({ - storeToRefs: (store: unknown) => store, +vi.mock('@unraid/shared-callbacks', () => ({ + useCallback: vi.fn(() => ({ + send: vi.fn(), + watcher: vi.fn(), + })), +})); + +const mockAccountStore = { + signIn: vi.fn(), +}; + +vi.mock('~/store/account', () => ({ + useAccountStore: () => mockAccountStore, +})); + +vi.mock('~/store/activationCode', () => ({ + useActivationCodeStore: vi.fn(() => ({ + code: ref(null), + partnerName: ref(null), + })), })); describe('Auth Component', () => { - it('displays an authentication button when authAction is available', () => { - // Configure auth action - mockServerStore.authAction.value = { - text: 'Sign in to Unraid', - icon: 'key', - click: vi.fn(), - }; - mockServerStore.stateData.value = { error: false }; + let serverStore: ReturnType; - // Mount component - const wrapper = mount(Auth); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('displays an authentication button when authAction is available', async () => { + const wrapper = mount(Auth, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); + + // Patch the underlying state that `authAction` depends on + serverStore = useServerStore(); + serverStore.$patch({ + state: 'ENOKEYFILE', + registered: false, + connectPluginInstalled: 'INSTALLED' as ServerconnectPluginInstalled, + }); + + await nextTick(); - // Verify button exists const button = wrapper.findComponent({ name: 'BrandButton' }); + expect(button.exists()).toBe(true); - // Check props passed to button - expect(button.props('text')).toBe('Sign in to Unraid'); - expect(button.props('icon')).toBe('key'); + expect(button.props('text')).toBe('Sign In with Unraid.net Account'); + expect(button.props('icon')).toBe(GlobeAltIcon); }); it('displays error messages when stateData.error is true', () => { - // Configure with error state - mockServerStore.authAction.value = { - text: 'Sign in to Unraid', - icon: 'key', - }; - mockServerStore.stateData.value = { - error: true, - heading: 'Error Title', - message: 'Error Message Content', - }; + const wrapper = mount(Auth, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); - // Mount component - const wrapper = mount(Auth); + // Patch the underlying state that `stateData` depends on + serverStore = useServerStore(); + serverStore.$patch({ + state: 'EEXPIRED', + registered: false, + connectPluginInstalled: 'INSTALLED' as ServerconnectPluginInstalled, + }); - // Verify error message is displayed const errorHeading = wrapper.find('h3'); expect(errorHeading.exists()).toBe(true); - expect(errorHeading.text()).toBe('Error Title'); - expect(wrapper.text()).toContain('Error Message Content'); + expect(errorHeading.text()).toBe('Stale Server'); + expect(wrapper.text()).toContain( + 'Please refresh the page to ensure you load your latest configuration' + ); }); it('calls the click handler when button is clicked', async () => { - // Create mock click handler - const clickHandler = vi.fn(); + const wrapper = mount(Auth, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); - // Configure with click handler - mockServerStore.authAction.value = { - text: 'Sign in to Unraid', - icon: 'key', - click: clickHandler, - }; - mockServerStore.stateData.value = { error: false }; + serverStore = useServerStore(); + serverStore.$patch({ + state: 'ENOKEYFILE', + registered: false, + connectPluginInstalled: 'INSTALLED' as ServerconnectPluginInstalled, + }); - // Mount component - const wrapper = mount(Auth); + await nextTick(); - // Click the button await wrapper.findComponent({ name: 'BrandButton' }).vm.$emit('click'); - // Verify click handler was called - expect(clickHandler).toHaveBeenCalledTimes(1); + expect(mockAccountStore.signIn).toHaveBeenCalledTimes(1); }); it('does not render button when authAction is undefined', () => { - // Configure with undefined auth action - mockServerStore.authAction.value = undefined; - mockServerStore.stateData.value = { error: false }; + const wrapper = mount(Auth, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); - // Mount component - const wrapper = mount(Auth); + serverStore = useServerStore(); + serverStore.$patch({ + state: 'PRO', + registered: true, + }); - // Verify button doesn't exist const button = wrapper.findComponent({ name: 'BrandButton' }); expect(button.exists()).toBe(false); diff --git a/web/__test__/components/ColorSwitcher.test.ts b/web/__test__/components/ColorSwitcher.test.ts new file mode 100644 index 000000000..4e18a9313 --- /dev/null +++ b/web/__test__/components/ColorSwitcher.test.ts @@ -0,0 +1,201 @@ +/** + * ColorSwitcher Component Test Coverage + */ + +import { nextTick } from 'vue'; +import { setActivePinia } from 'pinia'; +import { mount } from '@vue/test-utils'; + +import { Input, Label, Select, SelectTrigger, Switch } from '@unraid/ui'; +import { createTestingPinia } from '@pinia/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import ColorSwitcher from '~/components/ColorSwitcher.ce.vue'; +import { useThemeStore } from '~/store/theme'; + +// Explicitly mock @unraid/ui to ensure we use the actual components +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + }; +}); + +// Mock the default colors (optional, but can make tests more predictable) +vi.mock('~/themes/default', () => ({ + defaultColors: { + white: { + '--header-text-primary': '#ffffff', + '--header-text-secondary': '#eeeeee', + '--header-background-color': '#111111', + }, + black: { + '--header-text-primary': '#000000', + '--header-text-secondary': '#222222', + '--header-background-color': '#cccccc', + }, + }, +})); + +describe('ColorSwitcher', () => { + let themeStore: ReturnType; + + beforeEach(() => { + // Get store instance before each test + const pinia = createTestingPinia({ createSpy: vi.fn }); + setActivePinia(pinia); // Set the active pinia instance + themeStore = useThemeStore(); + + vi.clearAllMocks(); + }); + + it('renders all form elements correctly', () => { + const wrapper = mount(ColorSwitcher, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }); + + const labels = wrapper.findAllComponents(Label); + expect(labels).toHaveLength(7); + + const inputs = wrapper.findAllComponents(Input); + expect(inputs).toHaveLength(3); + + const switches = wrapper.findAllComponents(Switch); + expect(switches).toHaveLength(3); + + expect(wrapper.findComponent(SelectTrigger).exists()).toBe(true); + }); + + it('updates theme store when theme selection changes', async () => { + const wrapper = mount(ColorSwitcher, { + global: { + stubs: { + Select: { + template: '
', + props: ['modelValue'], + emits: ['update:modelValue'], + }, + }, + }, + }); + + const selectComponent = wrapper.findComponent(Select); + await selectComponent.vm.$emit('update:modelValue', 'black'); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenCalledTimes(2); + + expect(themeStore.setTheme).toHaveBeenLastCalledWith({ + name: 'black', + banner: true, + bannerGradient: true, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }); + }); + + it('updates theme store when color inputs change', async () => { + const wrapper = mount(ColorSwitcher, { + global: {}, + }); + + const inputs = wrapper.findAllComponents(Input); + const primaryTextInput = inputs[0]; + const secondaryTextInput = inputs[1]; + const bgInput = inputs[2]; + + await primaryTextInput.setValue('#ff0000'); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenCalledTimes(2); + expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ textColor: '#ff0000' })); + + await secondaryTextInput.setValue('#00ff00'); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenCalledTimes(3); + expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ metaColor: '#00ff00' })); + + await bgInput.setValue('#0000ff'); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenCalledTimes(4); + expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ bgColor: '#0000ff' })); + + expect(themeStore.setTheme).toHaveBeenLastCalledWith({ + name: 'white', + banner: true, + bannerGradient: true, + descriptionShow: true, + textColor: '#ff0000', + metaColor: '#00ff00', + bgColor: '#0000ff', + }); + }); + + it('updates theme store when switches change', async () => { + const wrapper = mount(ColorSwitcher, { + global: {}, + }); + + themeStore = useThemeStore(); + + vi.clearAllMocks(); + + const switches = wrapper.findAllComponents(Switch); + const gradientSwitch = switches[0]; + const descriptionSwitch = switches[1]; + const bannerSwitch = switches[2]; + + await descriptionSwitch.vm.$emit('update:checked', false); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenLastCalledWith( + expect.objectContaining({ descriptionShow: false }) + ); + + await bannerSwitch.vm.$emit('update:checked', false); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenLastCalledWith( + expect.objectContaining({ banner: false, bannerGradient: true }) + ); + + await gradientSwitch.vm.$emit('update:checked', false); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenLastCalledWith( + expect.objectContaining({ bannerGradient: false }) + ); + }); + + it('enables gradient automatically when banner is enabled', async () => { + const wrapper = mount(ColorSwitcher, { + global: {}, + }); + + themeStore = useThemeStore(); + + const switches = wrapper.findAllComponents(Switch); + const gradientSwitch = switches[0]; + const bannerSwitch = switches[2]; + + await bannerSwitch.vm.$emit('update:checked', false); + await nextTick(); + await gradientSwitch.vm.$emit('update:checked', false); + await nextTick(); + + vi.clearAllMocks(); + + await bannerSwitch.vm.$emit('update:checked', true); + await nextTick(); + + expect(themeStore.setTheme).toHaveBeenLastCalledWith( + expect.objectContaining({ banner: true, bannerGradient: true }) + ); + }); +}); diff --git a/web/__test__/components/DevSettings.test.ts b/web/__test__/components/DevSettings.test.ts new file mode 100644 index 000000000..a5949825b --- /dev/null +++ b/web/__test__/components/DevSettings.test.ts @@ -0,0 +1,51 @@ +/** + * DevSettings Component Test Coverage + */ + +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { CogIcon } from '@heroicons/vue/24/solid'; +import { Button, PopoverContent, PopoverTrigger } from '@unraid/ui'; +import { describe, expect, it, vi } from 'vitest'; + +import DevSettings from '~/components/DevSettings.vue'; + +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + }; +}); + +describe('DevSettings', () => { + it('renders the trigger button and hides content initially', () => { + const wrapper = mount(DevSettings, { + global: { + stubs: { DummyServerSwitcher: true }, + }, + }); + + const triggerButton = wrapper.findComponent(PopoverTrigger).findComponent(Button); + expect(triggerButton.exists()).toBe(true); + expect(triggerButton.findComponent(CogIcon).exists()).toBe(true); + + expect(wrapper.findComponent(PopoverContent).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'DummyServerSwitcher' }).exists()).toBe(false); + }); + + it('does not error when trigger button is clicked', async () => { + const wrapper = mount(DevSettings, { + global: { + stubs: { DummyServerSwitcher: true, PopoverContent: true }, + }, + }); + const triggerButton = wrapper.findComponent(PopoverTrigger).findComponent(Button); + + await triggerButton.trigger('click'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + + // No assertion needed here, the test passes if no error is thrown during the click simulation. + }); +}); diff --git a/web/__test__/components/DowngradeOs.test.ts b/web/__test__/components/DowngradeOs.test.ts new file mode 100644 index 000000000..b493cca0d --- /dev/null +++ b/web/__test__/components/DowngradeOs.test.ts @@ -0,0 +1,191 @@ +/** + * DowngradeOs Component Test Coverage + */ + +import { setActivePinia } from 'pinia'; +import { mount } from '@vue/test-utils'; + +import { createTestingPinia } from '@pinia/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import DowngradeOs from '~/components/DowngradeOs.ce.vue'; +import { useServerStore } from '~/store/server'; + +vi.mock('crypto-js/aes', () => ({ + default: {}, +})); + +vi.mock('@unraid/shared-callbacks', () => ({ + useCallback: vi.fn(() => ({ + send: vi.fn(), + watcher: vi.fn(), + })), +})); + +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + }; +}); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})); + +const PageContainerStub = { + template: '
', +}; +const UpdateOsStatusStub = { + template: '
', + props: ['title', 'subtitle', 'downgradeNotAvailable', 'showExternalDowngrade', 't'], +}; +const UpdateOsDowngradeStub = { + template: '
', + props: ['releaseDate', 'version', 't'], +}; +const UpdateOsThirdPartyDriversStub = { + template: '
', + props: ['t'], +}; + +describe('DowngradeOs', () => { + let serverStore: ReturnType; + + beforeEach(() => { + const pinia = createTestingPinia({ createSpy: vi.fn }); + + setActivePinia(pinia); + serverStore = useServerStore(); + vi.clearAllMocks(); + }); + + it('calls setRebootVersion on mount with prop value', () => { + const rebootVersionProp = '6.10.0'; + + mount(DowngradeOs, { + props: { + rebootVersion: rebootVersionProp, + }, + global: { + stubs: { + PageContainer: PageContainerStub, + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsDowngrade: UpdateOsDowngradeStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + }, + }, + }); + + expect(serverStore.setRebootVersion).toHaveBeenCalledTimes(1); + expect(serverStore.setRebootVersion).toHaveBeenCalledWith(rebootVersionProp); + }); + + it('renders UpdateOsStatus with initial props', () => { + const wrapper = mount(DowngradeOs, { + global: { + stubs: { + PageContainer: PageContainerStub, + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsDowngrade: UpdateOsDowngradeStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + }, + }, + }); + + const statusStub = wrapper.findComponent(UpdateOsStatusStub); + + expect(statusStub.exists()).toBe(true); + expect(statusStub.props('title')).toBe('Downgrade Unraid OS'); + expect(statusStub.props('subtitle')).toBe(''); + expect(statusStub.props('downgradeNotAvailable')).toBe(true); + expect(statusStub.props('showExternalDowngrade')).toBe(false); + }); + + it('renders UpdateOsDowngrade when restoreVersion is provided and rebootType is empty', () => { + serverStore.rebootType = ''; + + const wrapper = mount(DowngradeOs, { + props: { + restoreVersion: '6.9.2', + restoreReleaseDate: '2023-01-01', + }, + global: { + stubs: { + PageContainer: PageContainerStub, + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsDowngrade: UpdateOsDowngradeStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + }, + }, + }); + + const downgradeStub = wrapper.findComponent(UpdateOsDowngradeStub); + + expect(downgradeStub.exists()).toBe(true); + expect(downgradeStub.props('version')).toBe('6.9.2'); + expect(downgradeStub.props('releaseDate')).toBe('2023-01-01'); + + expect(wrapper.findComponent(UpdateOsStatusStub).props('downgradeNotAvailable')).toBe(false); + expect(wrapper.findComponent(UpdateOsThirdPartyDriversStub).exists()).toBe(false); + }); + + it('renders UpdateOsThirdPartyDrivers when rebootType is thirdPartyDriversDownloading', () => { + serverStore.rebootType = 'thirdPartyDriversDownloading'; + + const wrapper = mount(DowngradeOs, { + props: {}, + global: { + stubs: { + PageContainer: PageContainerStub, + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsDowngrade: UpdateOsDowngradeStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + }, + }, + }); + + expect(wrapper.findComponent(UpdateOsThirdPartyDriversStub).exists()).toBe(true); + expect(wrapper.findComponent(UpdateOsDowngradeStub).exists()).toBe(false); + }); + + it('passes correct subtitle to UpdateOsStatus when rebootType is update', () => { + serverStore.rebootType = 'update'; + + const wrapper = mount(DowngradeOs, { + global: { + stubs: { + PageContainer: PageContainerStub, + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsDowngrade: UpdateOsDowngradeStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + }, + }, + }); + + const statusStub = wrapper.findComponent(UpdateOsStatusStub); + expect(statusStub.props('subtitle')).toBe( + 'Please finish the initiated update to enable a downgrade.' + ); + }); + + it('passes correct showExternalDowngrade based on osVersionBranch', () => { + serverStore.osVersionBranch = 'next'; + + const wrapper = mount(DowngradeOs, { + global: { + stubs: { + PageContainer: PageContainerStub, + UpdateOsStatus: UpdateOsStatusStub, + UpdateOsDowngrade: UpdateOsDowngradeStub, + UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, + }, + }, + }); + + expect(wrapper.findComponent(UpdateOsStatusStub).props('showExternalDowngrade')).toBe(true); + }); +}); diff --git a/web/__test__/components/DownloadApiLogs.test.ts b/web/__test__/components/DownloadApiLogs.test.ts index af4689c69..e7a5f830d 100644 --- a/web/__test__/components/DownloadApiLogs.test.ts +++ b/web/__test__/components/DownloadApiLogs.test.ts @@ -5,11 +5,11 @@ import { mount } from '@vue/test-utils'; import { BrandButton } from '@unraid/ui'; +import { createTestingPinia } from '@pinia/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import DownloadApiLogs from '~/components/DownloadApiLogs.ce.vue'; -// Mock the urls helper with a predictable mock URL vi.mock('~/helpers/urls', () => ({ CONNECT_FORUMS: new URL('http://mock-forums.local'), CONTACT: new URL('http://mock-contact.local'), @@ -17,7 +17,6 @@ vi.mock('~/helpers/urls', () => ({ WEBGUI_GRAPHQL: new URL('http://mock-webgui.local'), })); -// Mock vue-i18n with a simple implementation vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, @@ -34,6 +33,7 @@ describe('DownloadApiLogs', () => { it('provides a download button with the correct URL', () => { const wrapper = mount(DownloadApiLogs, { global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], stubs: { ArrowDownTrayIcon: true, ArrowTopRightOnSquareIcon: true, @@ -60,6 +60,7 @@ describe('DownloadApiLogs', () => { it('displays support links to documentation and help resources', () => { const wrapper = mount(DownloadApiLogs, { global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], stubs: { ArrowDownTrayIcon: true, ArrowTopRightOnSquareIcon: true, @@ -67,11 +68,9 @@ describe('DownloadApiLogs', () => { }, }); - // Find all support links const links = wrapper.findAll('a'); expect(links.length).toBe(4); - // Verify each link has correct href and text expect(links[1].attributes('href')).toBe('http://mock-forums.local/'); expect(links[1].text()).toContain('Unraid Connect Forums'); @@ -81,7 +80,6 @@ describe('DownloadApiLogs', () => { expect(links[3].attributes('href')).toBe('http://mock-contact.local/'); expect(links[3].text()).toContain('Unraid Contact Page'); - // Verify all links open in new tab links.slice(1).forEach((link) => { expect(link.attributes('target')).toBe('_blank'); expect(link.attributes('rel')).toBe('noopener noreferrer'); @@ -91,6 +89,7 @@ describe('DownloadApiLogs', () => { it('displays instructions about log usage and privacy', () => { const wrapper = mount(DownloadApiLogs, { global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], stubs: { ArrowDownTrayIcon: true, ArrowTopRightOnSquareIcon: true, @@ -100,7 +99,6 @@ describe('DownloadApiLogs', () => { const text = wrapper.text(); - // Verify key instructional text is present expect(text).toContain( 'The primary method of support for Unraid Connect is through our forums and Discord' ); diff --git a/web/__test__/components/DummyServerSwitcher.test.ts b/web/__test__/components/DummyServerSwitcher.test.ts new file mode 100644 index 000000000..3e03e0542 --- /dev/null +++ b/web/__test__/components/DummyServerSwitcher.test.ts @@ -0,0 +1,82 @@ +/** + * DummyServerSwitcher Component Test Coverage + */ + +import { nextTick } from 'vue'; +import { setActivePinia } from 'pinia'; +import { mount } from '@vue/test-utils'; + +import { Select, SelectTrigger } from '@unraid/ui'; +import { createTestingPinia } from '@pinia/testing'; +import { defaultServer, useDummyServerStore } from '~/_data/serverState'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ServerSelector } from '~/_data/serverState'; + +import DummyServerSwitcher from '~/components/DummyServerSwitcher.vue'; + +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + }; +}); + +describe('DummyServerSwitcher', () => { + let dummyServerStore: ReturnType; + + beforeEach(() => { + const pinia = createTestingPinia({ createSpy: vi.fn }); + + setActivePinia(pinia); + dummyServerStore = useDummyServerStore(); + dummyServerStore.selector = defaultServer; + vi.clearAllMocks(); + }); + + it('renders initial state correctly', () => { + const wrapper = mount(DummyServerSwitcher); + + expect(wrapper.find('h1').text()).toBe('Server State Selection'); + expect(wrapper.find('details > summary').text()).toContain('Initial Server State: default'); + + const expectedInitialState = JSON.stringify(dummyServerStore.serverState, null, 4); + + expect(wrapper.find('details > pre').text()).toBe(expectedInitialState); + expect(wrapper.findComponent(SelectTrigger).exists()).toBe(true); + }); + + it('updates the store selector and displayed state when selection changes', async () => { + const wrapper = mount(DummyServerSwitcher, { + // Stub Select to simplify interaction + global: { + stubs: { + Select: { + template: '
', + props: ['modelValue'], + emits: ['update:modelValue'], + }, + }, + }, + }); + + const selectComponent = wrapper.findComponent(Select); + + expect(dummyServerStore.selector).toBe('default'); + + const newSelection: ServerSelector = 'oemActivation'; + + await selectComponent.vm.$emit('update:modelValue', newSelection); + await nextTick(); + + expect(dummyServerStore.selector).toBe(newSelection); + expect(wrapper.find('details > summary').text()).toContain(`Initial Server State: ${newSelection}`); + + const expectedNewState = JSON.stringify(dummyServerStore.serverState, null, 4); + + expect(wrapper.find('details > pre').text()).toBe(expectedNewState); + }); + + // More tests to come +}); diff --git a/web/__test__/components/HeaderOsVersion.test.ts b/web/__test__/components/HeaderOsVersion.test.ts new file mode 100644 index 000000000..a350a8357 --- /dev/null +++ b/web/__test__/components/HeaderOsVersion.test.ts @@ -0,0 +1,147 @@ +/** + * HeaderOsVersion Component Test Coverage + */ + +import { nextTick } from 'vue'; +import { setActivePinia } from 'pinia'; +import { mount } from '@vue/test-utils'; + +import { Badge } from '@unraid/ui'; +import { createTestingPinia } from '@pinia/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TestingPinia } from '@pinia/testing'; +import type { VueWrapper } from '@vue/test-utils'; +import type { Error as CustomApiError } from '~/store/errors'; +import type { ServerUpdateOsResponse } from '~/types/server'; + +import HeaderOsVersion from '~/components/HeaderOsVersion.ce.vue'; +import { useErrorsStore } from '~/store/errors'; +import { useServerStore } from '~/store/server'; + +const testMockReleaseNotesUrl = 'http://mock.release.notes/v'; + +vi.mock('crypto-js/aes', () => ({ default: {} })); +vi.mock('@unraid/shared-callbacks', () => ({ + useCallback: vi.fn(() => ({ send: vi.fn(), watcher: vi.fn() })), +})); + +vi.mock('~/helpers/urls', async (importOriginal) => { + const actual = await importOriginal(); + const mockReleaseNotesUrl = 'http://mock.release.notes/v'; + const mockWebGuiToolsUpdate = '/mock/Tools/Update'; + const mockWebGuiToolsDowngrade = '/mock/Tools/Downgrade'; + + return { + ...actual, + getReleaseNotesUrl: vi.fn((version: string) => `${mockReleaseNotesUrl}${version}`), + WEBGUI_TOOLS_UPDATE: mockWebGuiToolsUpdate, + WEBGUI_TOOLS_DOWNGRADE: mockWebGuiToolsDowngrade, + }; +}); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: unknown) => { + if (params && Array.isArray(params)) { + let result = key; + params.forEach((val, index) => { + result = result.replace(`{${index}}`, String(val)); + }); + + return result; + } + + const keyMap: Record = { + 'Reboot Required for Update': 'Reboot Required for Update', + 'Reboot Required for Downgrade': 'Reboot Required for Downgrade', + 'Updating 3rd party drivers': 'Updating 3rd party drivers', + 'Update Available': 'Update Available', + 'Update Released': 'Update Released', + 'View release notes': 'View release notes', + }; + + return keyMap[key] ?? key; + }, + }), +})); + +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Badge: actual.Badge, + }; +}); + +describe('HeaderOsVersion', () => { + let wrapper: VueWrapper; + let testingPinia: TestingPinia; + let serverStore: ReturnType; + let errorsStore: ReturnType; + + const findUpdateStatusComponent = () => { + const statusElement = wrapper.find('a.group:not([title*="release notes"]), button.group'); + return statusElement.exists() ? statusElement : null; + }; + + beforeEach(() => { + testingPinia = createTestingPinia({ createSpy: vi.fn }); + setActivePinia(testingPinia); + + serverStore = useServerStore(); + errorsStore = useErrorsStore(); + + serverStore.osVersion = '6.12.0'; + serverStore.rebootType = ''; + serverStore.updateOsResponse = undefined; + serverStore.regExp = 0; + serverStore.updateOsIgnoredReleases = []; + errorsStore.errors = []; + + wrapper = mount(HeaderOsVersion, { + global: { + plugins: [testingPinia], + components: { Badge }, + }, + }); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.restoreAllMocks(); + }); + + it('renders OS version badge with correct link and no update status initially', () => { + const versionBadgeLink = wrapper.find('a[title*="release notes"]'); + + expect(versionBadgeLink.exists()).toBe(true); + expect(versionBadgeLink.attributes('href')).toBe(`${testMockReleaseNotesUrl}6.12.0`); + + const badge = versionBadgeLink.findComponent(Badge); + + expect(badge.exists()).toBe(true); + expect(badge.text()).toContain('6.12.0'); + expect(findUpdateStatusComponent()).toBeNull(); + }); + + it('does not render update status when stateDataError is present', async () => { + const mockError: CustomApiError = { + message: 'State data fetch failed', + heading: 'Fetch Error', + level: 'error', + type: 'serverState', + }; + errorsStore.errors = [mockError]; + serverStore.updateOsResponse = { + version: '6.13.0', + isNewer: true, + isEligible: true, + } as ServerUpdateOsResponse; + serverStore.rebootType = ''; + + await nextTick(); + + expect(findUpdateStatusComponent()).toBeNull(); + }); +}); diff --git a/web/__test__/components/I18nHost.test.ts b/web/__test__/components/I18nHost.test.ts new file mode 100644 index 000000000..d3c5b3ce7 --- /dev/null +++ b/web/__test__/components/I18nHost.test.ts @@ -0,0 +1,133 @@ +/** + * I18nHost Component Test Coverage + */ + +import { defineComponent, inject } from 'vue'; +import { I18nInjectionKey } from 'vue-i18n'; +import { mount } from '@vue/test-utils'; + +import _en_US from '~/locales/en_US.json'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { VueWrapper } from '@vue/test-utils'; +import type { Composer, I18n } from 'vue-i18n'; + +import I18nHost from '~/components/I18nHost.ce.vue'; + +const en_US: Record = _en_US; + +vi.mock('~/helpers/i18n-utils', () => ({ + createHtmlEntityDecoder: vi.fn(() => (text: string) => text), +})); + +const TestConsumerComponent = defineComponent({ + template: '
{{ $i18n?.locale }}
', + + setup() { + const i18n = inject(I18nInjectionKey); + return { i18n }; + }, +}); + +describe('I18nHost', () => { + let wrapper: VueWrapper; + const originalWindowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA; + + beforeEach(() => { + delete (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA; + vi.clearAllMocks(); + }); + + afterEach(() => { + wrapper?.unmount(); + + if (originalWindowLocaleData !== undefined) { + (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA = originalWindowLocaleData; + } else { + delete (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA; + } + vi.restoreAllMocks(); + }); + + it('provides i18n instance with default locale when no window data exists', () => { + wrapper = mount(I18nHost, { + slots: { + default: TestConsumerComponent, + }, + }); + + const consumerWrapper = wrapper.findComponent(TestConsumerComponent); + const providedI18n = consumerWrapper.vm.i18n as I18n<{ + message: typeof en_US; + }>; + + expect(providedI18n).toBeDefined(); + expect((providedI18n.global as Composer).locale.value).toBe('en_US'); + expect((providedI18n.global as Composer).fallbackLocale.value).toBe('en_US'); + + const messages = (providedI18n.global as Composer).messages.value as { + en_US?: Record; + ja?: Record; + }; + + expect(messages.en_US?.['My Servers']).toBe(en_US['My Servers']); + }); + + it('parses and provides i18n instance with locale from window.LOCALE_DATA', () => { + const mockJaMessages = { 'test-key': 'テストキー' }; + const localeData = { ja: mockJaMessages }; + (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA = encodeURIComponent( + JSON.stringify(localeData) + ); + + wrapper = mount(I18nHost, { + slots: { + default: TestConsumerComponent, + }, + }); + + const consumerWrapper = wrapper.findComponent(TestConsumerComponent); + const providedI18n = consumerWrapper.vm.i18n as I18n<{ + message: typeof en_US; + }>; + const messages = (providedI18n.global as Composer).messages.value as { + en_US?: Record; + ja?: Record; + }; + + expect(providedI18n).toBeDefined(); + expect((providedI18n.global as Composer).locale.value).toBe('ja'); + expect((providedI18n.global as Composer).fallbackLocale.value).toBe('en_US'); + expect(messages.ja?.['test-key']).toBe(mockJaMessages['test-key']); + expect(messages.en_US?.['My Servers']).toBe(en_US['My Servers']); + }); + + it('handles invalid JSON in window.LOCALE_DATA gracefully', () => { + (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA = 'invalid JSON string{%'; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + wrapper = mount(I18nHost, { + slots: { + default: TestConsumerComponent, + }, + }); + + const consumerWrapper = wrapper.findComponent(TestConsumerComponent); + const providedI18n = consumerWrapper.vm.i18n as I18n<{ + message: typeof en_US; + }>; + const messages = (providedI18n.global as Composer).messages.value as { + en_US?: Record; + ja?: Record; + }; + + expect(providedI18n).toBeDefined(); + expect((providedI18n.global as Composer).locale.value).toBe('en_US'); + expect((providedI18n.global as Composer).fallbackLocale.value).toBe('en_US'); + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy).toHaveBeenCalledWith('[I18nHost] error parsing messages', expect.any(Error)); + expect(messages.en_US?.['My Servers']).toBe(en_US['My Servers']); + + errorSpy.mockRestore(); + }); +}); diff --git a/web/__test__/components/KeyActions.test.ts b/web/__test__/components/KeyActions.test.ts index b0466e4b2..d1c31b255 100644 --- a/web/__test__/components/KeyActions.test.ts +++ b/web/__test__/components/KeyActions.test.ts @@ -2,11 +2,11 @@ * KeyActions Component Test Coverage */ -import { ref } from 'vue'; import { mount } from '@vue/test-utils'; import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'; import { BrandButton } from '@unraid/ui'; +import { createTestingPinia } from '@pinia/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ServerStateDataAction, ServerStateDataActionType } from '~/types/server'; @@ -15,24 +15,17 @@ import KeyActions from '../../components/KeyActions.vue'; import '~/__test__/mocks/ui-components'; -// Create mock store actions -const storeKeyActions = [ - { name: 'purchase' as ServerStateDataActionType, text: 'Purchase Key', click: vi.fn() }, - { name: 'redeem' as ServerStateDataActionType, text: 'Redeem Key', click: vi.fn() }, -]; +vi.mock('crypto-js/aes', () => ({ + default: {}, +})); -// Mock the store and Pinia -vi.mock('pinia', () => ({ - storeToRefs: vi.fn(() => ({ - keyActions: ref(storeKeyActions), +vi.mock('@unraid/shared-callbacks', () => ({ + useCallback: vi.fn(() => ({ + send: vi.fn(), + watcher: vi.fn(), })), })); -vi.mock('../../store/server', () => ({ - useServerStore: vi.fn(), -})); - -// Mock translation function (simple implementation) const t = (key: string) => `translated_${key}`; describe('KeyActions', () => { @@ -40,18 +33,6 @@ describe('KeyActions', () => { vi.clearAllMocks(); }); - it('renders buttons from store when no actions prop is provided', () => { - const wrapper = mount(KeyActions, { - props: { t }, - }); - - const buttons = wrapper.findAllComponents(BrandButton); - - expect(buttons.length).toBe(2); - expect(buttons[0].text()).toContain('translated_Purchase Key'); - expect(buttons[1].text()).toContain('translated_Redeem Key'); - }); - it('renders buttons from props when actions prop is provided', () => { const actions: ServerStateDataAction[] = [ { name: 'purchase' as ServerStateDataActionType, text: 'Custom Action 1', click: vi.fn() }, @@ -76,6 +57,9 @@ describe('KeyActions', () => { t, actions: [], }, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, }); expect(wrapper.find('ul').exists()).toBe(true); @@ -95,7 +79,6 @@ describe('KeyActions', () => { }, }); - // Click the button await wrapper.findComponent(BrandButton).trigger('click'); expect(click).toHaveBeenCalledTimes(1); }); @@ -118,7 +101,6 @@ describe('KeyActions', () => { }, }); - // Click the disabled button await wrapper.findComponent(BrandButton).trigger('click'); expect(click).not.toHaveBeenCalled(); }); diff --git a/web/__test__/components/Modal.test.ts b/web/__test__/components/Modal.test.ts new file mode 100644 index 000000000..df6bcd1c9 --- /dev/null +++ b/web/__test__/components/Modal.test.ts @@ -0,0 +1,229 @@ +/** + * Modal Component Test Coverage + */ + +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MountingOptions, VueWrapper } from '@vue/test-utils'; +import type { Props as ModalProps } from '~/components/Modal.vue'; + +import Modal from '~/components/Modal.vue'; + +vi.mock('@unraid/ui', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})); + +const mockSetProperty = vi.fn(); +const mockRemoveProperty = vi.fn(); + +Object.defineProperty(document.body.style, 'setProperty', { + value: mockSetProperty, + writable: true, +}); +Object.defineProperty(document.body.style, 'removeProperty', { + value: mockRemoveProperty, + writable: true, +}); + +const t = (key: string) => key; + +describe('Modal', () => { + let wrapper: VueWrapper; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + wrapper?.unmount(); + document.body.style.removeProperty('overflow'); + vi.restoreAllMocks(); + }); + + const mountModal = (options: MountingOptions = {}) => { + const { slots, ...restOptions } = options; + + return mount(Modal, { + props: { + t, + open: true, + ...(restOptions.props || {}), + }, + slots: slots as Record, + global: { + stubs: { + TransitionRoot: { + template: '
', + props: ['show'], + }, + TransitionChild: { + template: '
', + }, + ...(restOptions.global?.stubs || {}), + }, + ...(restOptions.global || {}), + }, + attachTo: restOptions.attachTo, + }); + }; + + it('applies and removes body scroll lock based on open prop', async () => { + wrapper = mount(Modal, { + props: { + t, + open: false, + }, + }); + + // Initially hidden + expect(mockSetProperty).not.toHaveBeenCalled(); + + await wrapper.setProps({ open: true }); + await nextTick(); + + expect(mockSetProperty).toHaveBeenCalledWith('overflow', 'hidden'); + + mockSetProperty.mockClear(); + mockRemoveProperty.mockClear(); + + await wrapper.setProps({ open: false }); + await nextTick(); + + expect(mockRemoveProperty).toHaveBeenCalledWith('overflow'); + expect(mockSetProperty).not.toHaveBeenCalled(); + }); + + it('renders description in main content', async () => { + const testDescription = 'This is the modal description.'; + + wrapper = mountModal({ props: { t, description: testDescription } }); + + const main = wrapper.find('[class*="max-h-"]'); + + expect(main.find('h2').exists()).toBe(true); + expect(main.text()).toContain(testDescription); + }); + + it('does not emit close event on overlay click when disableOverlayClose is true', async () => { + wrapper = mountModal({ props: { t, disableOverlayClose: true } }); + + const overlay = wrapper.find('[class*="fixed inset-0 z-0"]'); + + await overlay.trigger('click'); + + expect(wrapper.emitted('close')).toBeUndefined(); + }); + + it('emits close event when Escape key is pressed', async () => { + wrapper = mountModal({ attachTo: document.body }); + + await wrapper.find('[role="dialog"]').trigger('keyup.esc'); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('applies maxWidth class correctly', async () => { + const maxWidth = 'sm:max-w-2xl'; + + wrapper = mount(Modal, { + props: { + t, + open: true, + maxWidth, + }, + }); + + await nextTick(); + + expect(wrapper.find('[class*="sm:max-w-"]').classes()).toContain(maxWidth); + }); + + it('applies error and success classes correctly', async () => { + wrapper = mount(Modal, { + props: { + t, + open: true, + error: true, + }, + }); + + await nextTick(); + + let modalDiv = wrapper.find('[class*="text-left relative z-10"]'); + + expect(modalDiv.classes()).toContain('shadow-unraid-red/30'); + expect(modalDiv.classes()).toContain('border-unraid-red/10'); + + wrapper.setProps({ error: false, success: true }); + + await nextTick(); + + modalDiv = wrapper.find('[class*="text-left relative z-10"]'); + + expect(modalDiv.classes()).toContain('shadow-green-600/30'); + expect(modalDiv.classes()).toContain('border-green-600/10'); + }); + + it('disables shadow when disableShadow is true', async () => { + wrapper = mount(Modal, { + props: { + t, + open: true, + disableShadow: true, + }, + }); + + await nextTick(); + + const modalDiv = wrapper.find('[class*="text-left relative z-10"]'); + + expect(modalDiv.classes()).toContain('shadow-none'); + expect(modalDiv.classes()).toContain('border-none'); + }); + + it('applies header justification class based on headerJustifyCenter prop', async () => { + wrapper = mount(Modal, { + props: { + t, + open: true, + headerJustifyCenter: false, + }, + }); + + await nextTick(); + + expect(wrapper.find('header').classes()).toContain('justify-between'); + expect(wrapper.find('header').classes()).not.toContain('justify-center'); + + wrapper.setProps({ headerJustifyCenter: true }); + + await nextTick(); + + expect(wrapper.find('header').classes()).toContain('justify-center'); + expect(wrapper.find('header').classes()).not.toContain('justify-between'); + }); + + it('applies overlay color and opacity classes', async () => { + const overlayColor = 'bg-blue-500'; + const overlayOpacity = 'bg-opacity-50'; + + wrapper = mount(Modal, { + props: { + t, + open: true, + overlayColor, + overlayOpacity, + }, + }); + + await nextTick(); + + const overlay = wrapper.find('[class*="fixed inset-0 z-0"]'); + + expect(overlay.classes()).toContain(overlayColor); + expect(overlay.classes()).toContain(overlayOpacity); + }); +}); diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts new file mode 100644 index 000000000..e8cbb3935 --- /dev/null +++ b/web/__test__/components/Registration.test.ts @@ -0,0 +1,241 @@ +/** + * Registration Component Test Coverage + */ + +import { defineComponent } from 'vue'; +import { setActivePinia } from 'pinia'; +import { mount } from '@vue/test-utils'; + +import { createTestingPinia } from '@pinia/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { VueWrapper } from '@vue/test-utils'; +import type { ServerconnectPluginInstalled } from '~/types/server'; +import type { Pinia } from 'pinia'; + +import Registration from '~/components/Registration.ce.vue'; +import MockedRegistrationItem from '~/components/Registration/Item.vue'; +import { usePurchaseStore } from '~/store/purchase'; +import { useReplaceRenewStore } from '~/store/replaceRenew'; +import { useServerStore } from '~/store/server'; + +vi.mock('crypto-js/aes.js', () => ({ default: {} })); + +vi.mock('@unraid/shared-callbacks', () => ({ + useCallback: vi.fn(() => ({ + send: vi.fn(), + watcher: vi.fn(), + })), +})); + +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + BrandButton: { template: '', props: ['text', 'title', 'icon', 'disabled'] }, + CardWrapper: { template: '
' }, + PageContainer: { template: '
' }, + }; +}); + +vi.mock('~/components/KeyActions.vue', () => ({ + default: { template: '
', props: ['t', 'filterOut'] }, +})); + +vi.mock('~/components/Registration/KeyLinkedStatus.vue', () => ({ + default: { template: '
', props: ['t'] }, +})); + +vi.mock('~/components/Registration/ReplaceCheck.vue', () => ({ + default: { template: '
', props: ['t'] }, +})); + +vi.mock('~/components/Registration/UpdateExpirationAction.vue', () => ({ + default: { template: '
', props: ['t'] }, +})); + +vi.mock('~/components/UserProfile/UptimeExpire.vue', () => ({ + default: { + template: '
', + props: ['t', 'forExpire', 'shortText'], + }, +})); + +vi.mock('~/components/Registration/Item.vue', () => ({ + default: defineComponent({ + props: ['label', 'text', 'component', 'componentProps', 'error', 'warning', 'componentOpacity'], + name: 'RegistrationItem', + template: ` +
+
{{ label }}
+
+ {{ text }} + +
+
+ `, + setup(props) { + return { ...props }; + }, + }), +})); + +// Define initial state for the server store for testing +const initialServerState = { + dateTimeFormat: { date: 'MMM D, YYYY', time: 'h:mm A' }, + deviceCount: 0, + guid: '', + flashVendor: '', + flashProduct: '', + keyfile: '', + regGuid: '', + regTm: '', + regTo: '', + regTy: '', + regExp: null, + regUpdatesExpired: false, + serverErrors: [], + state: 'ENOKEYFILE', + stateData: { heading: 'Default Heading', message: 'Default Message' }, + stateDataError: false, + tooManyDevices: false, +}; + +const mockFormattedDateTime = vi.fn(() => 'Formatted Date'); +vi.mock('~/composables/dateTime', () => ({ + default: vi.fn(() => ({ + outputDateTimeFormatted: { value: mockFormattedDateTime() }, + })), +})); + +const t = (key: string) => key; + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t }), +})); + +describe('Registration.ce.vue', () => { + let wrapper: VueWrapper; + let pinia: Pinia; + let serverStore: ReturnType; + let replaceRenewStore: ReturnType; + let purchaseStore: ReturnType; + + const findItemByLabel = (labelKey: string) => { + const items = wrapper.findAllComponents({ name: 'RegistrationItem' }); + + return items.find((item) => item.props('label') === t(labelKey)); + }; + + beforeEach(() => { + pinia = createTestingPinia({ + createSpy: vi.fn, + initialState: { + server: { ...initialServerState }, + }, + stubActions: true, + }); + setActivePinia(pinia); + + serverStore = useServerStore(); + replaceRenewStore = useReplaceRenewStore(); + purchaseStore = usePurchaseStore(); + + serverStore.deprecatedUnraidSSL = undefined; + + replaceRenewStore.check = vi.fn(); + + vi.clearAllMocks(); + + // Mount after store setup + wrapper = mount(Registration, { + global: { + plugins: [pinia], + components: { + RegistrationItem: MockedRegistrationItem, + }, + }, + }); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.restoreAllMocks(); + }); + + it('renders default heading and message when state is ENOKEYFILE', () => { + const heading = wrapper.find('h3'); + const subheading = wrapper.find('.prose'); + + expect(heading.text()).toContain("Let's Unleash Your Hardware"); + expect(subheading.text()).toContain('Choose an option below'); + expect(findItemByLabel(t('License key type'))).toBeUndefined(); + expect(findItemByLabel(t('Flash GUID'))).toBeUndefined(); + expect(wrapper.find('[data-testid="key-actions"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="replace-check"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="key-linked-status"]').exists()).toBe(false); + }); + + it('triggers expected action when key action is clicked', async () => { + serverStore.state = 'TRIAL'; + + await wrapper.vm.$nextTick(); + + const items = wrapper.findAllComponents({ name: 'RegistrationItem' }); + const keyActionsItem = items.find((item) => { + const componentProp = item.props('component'); + + return componentProp?.template?.includes('data-testid="key-actions"'); + }); + + expect(keyActionsItem, 'RegistrationItem for KeyActions not found').toBeDefined(); + + const componentProps = keyActionsItem!.props('componentProps') as { + filterOut?: string[]; + t: unknown; + }; + const expectedActions = serverStore.keyActions?.filter( + (action) => !componentProps?.filterOut?.includes(action.name) + ); + + expect(expectedActions, 'No expected actions found in store for TRIAL state').toBeDefined(); + expect(expectedActions!.length).toBeGreaterThan(0); + + const purchaseAction = expectedActions!.find((a) => a.name === 'purchase'); + + expect(purchaseAction, 'Purchase action not found in expected actions').toBeDefined(); + + purchaseAction!.click?.(); + + expect(purchaseStore.purchase).toHaveBeenCalled(); + }); + + it('renders registered state information when state is PRO', async () => { + serverStore.state = 'PRO'; + serverStore.regTy = 'Pro'; + serverStore.regTo = 'Test User'; + serverStore.regGuid = '12345-ABCDE'; + serverStore.registered = true; + serverStore.connectPluginInstalled = 'INSTALLED' as ServerconnectPluginInstalled; + serverStore.guid = 'FLASH-GUID-123'; + serverStore.deviceCount = 5; + + await wrapper.vm.$nextTick(); + + const keyTypeItem = findItemByLabel(t('License key type')); + + expect(keyTypeItem).toBeDefined(); + expect(keyTypeItem?.props('text')).toBe('Pro'); + + const registeredToItem = findItemByLabel(t('Registered to')); + + expect(registeredToItem).toBeDefined(); + expect(registeredToItem?.props('text')).toBe('Test User'); + expect(findItemByLabel(t('Flash GUID'))).toBeDefined(); + expect(findItemByLabel(t('Attached Storage Devices'))).toBeDefined(); + expect(wrapper.find('[data-testid="key-actions"]').exists()).toBe(false); + }); +}); diff --git a/web/__test__/setup.ts b/web/__test__/setup.ts index c6a31f5b9..1c7261894 100644 --- a/web/__test__/setup.ts +++ b/web/__test__/setup.ts @@ -1,6 +1,5 @@ import { config } from '@vue/test-utils'; -import { createTestingPinia } from '@pinia/testing'; import { vi } from 'vitest'; // Import mocks @@ -8,10 +7,6 @@ import './mocks/ui-components.js'; // Configure Vue Test Utils config.global.plugins = [ - createTestingPinia({ - createSpy: vi.fn, - }), - // Simple mock for i18n { install: vi.fn(), }, diff --git a/web/_data/serverState.ts b/web/_data/serverState.ts index a7ec6a2cd..037e95071 100644 --- a/web/_data/serverState.ts +++ b/web/_data/serverState.ts @@ -6,12 +6,14 @@ // import QueryStringAddon from 'wretch/addons/queryString'; // import { OS_RELEASES } from '~/helpers/urls'; +import { computed, ref } from 'vue'; +import { defineStore } from 'pinia'; + import type { Server, ServerState, // ServerUpdateOsResponse, } from '~/types/server'; -import { defineStore } from 'pinia' // dayjs plugins // extend(customParseFormat); @@ -194,7 +196,7 @@ const baseServerState: Server = { }; export type ServerSelector = 'default' | 'oemActivation'; -const defaultServer: ServerSelector = 'default'; +export const defaultServer: ServerSelector = 'default'; const servers: Record = { default: baseServerState, @@ -217,8 +219,8 @@ const servers: Record = { }, }; -export const useDummyServerStore = defineStore('_dummyServer',() => { +export const useDummyServerStore = defineStore('_dummyServer', () => { const selector = ref(defaultServer); const serverState = computed(() => servers[selector.value] ?? servers.default); return { selector, serverState }; -}) +}); diff --git a/web/components/ColorSwitcher.ce.vue b/web/components/ColorSwitcher.ce.vue index d92906bbe..85051df43 100644 --- a/web/components/ColorSwitcher.ce.vue +++ b/web/components/ColorSwitcher.ce.vue @@ -1,9 +1,22 @@