From 72860e71fec396e7b1354e19da54f14d9e6db696 Mon Sep 17 00:00:00 2001 From: Michael Datelle Date: Wed, 16 Apr 2025 17:06:52 -0400 Subject: [PATCH] test: create tests for stores batch 3 (#1358) ## Summary by CodeRabbit - **New Features** - Added comprehensive test coverage for the purchase, replaceRenew, modal, notifications, theme, trial, unraidApi, unraidApiSettings, updateOs, updateOsActions, updateOsChangelog, activationCode, and callbackActions stores. - Exposed callback error state in the callbackActions store for external access. - Made error state publicly accessible in the replaceRenew store. - **Tests** - Introduced new test files covering state, getters, actions, and side effects for multiple stores including modal, notifications, purchase, replaceRenew, theme, trial, unraidApi, unraidApiSettings, updateOs, updateOsActions, updateOsChangelog, activationCode, and callbackActions. - Enhanced existing test suites with additional mocks, reactive state handling, and expanded test cases for improved coverage and robustness. - **Refactor** - Improved code clarity and readability in modal, notifications, purchase, replaceRenew, trial, theme, updateOsActions, callbackActions, and unraidApi stores through import reorganization and formatting adjustments. - Updated imports to include reactive and computed utilities for enhanced state management in several stores. - Standardized import styles and streamlined store definitions in the unraidApiSettings store. --------- Co-authored-by: mdatelle --- web/__test__/store/activationCode.test.ts | 60 ++- web/__test__/store/callbackActions.test.ts | 84 +++- web/__test__/store/errors.test.ts | 88 +++- web/__test__/store/installKey.test.ts | 22 +- web/__test__/store/modal.test.ts | 56 +++ web/__test__/store/notifications.test.ts | 99 +++++ web/__test__/store/purchase.test.ts | 176 ++++++++ web/__test__/store/replaceRenew.test.ts | 334 +++++++++++++++ web/__test__/store/theme.test.ts | 166 ++++++++ web/__test__/store/trial.test.ts | 215 ++++++++++ web/__test__/store/unraidApi.test.ts | 271 ++++++++++++ web/__test__/store/unraidApiSettings.test.ts | 111 +++++ web/__test__/store/updateOs.test.ts | 193 +++++++++ web/__test__/store/updateOsActions.test.ts | 422 +++++++++++++++++++ web/__test__/store/updateOsChangelog.test.ts | 197 +++++++++ web/store/callbackActions.ts | 1 + web/store/modal.ts | 11 +- web/store/notifications.ts | 10 +- web/store/purchase.ts | 74 ++-- web/store/replaceRenew.ts | 3 +- web/store/theme.ts | 1 + web/store/trial.ts | 23 +- web/store/unraidApi.ts | 18 +- web/store/unraidApiSettings.ts | 75 ++-- web/store/updateOsActions.ts | 59 ++- 25 files changed, 2629 insertions(+), 140 deletions(-) create mode 100644 web/__test__/store/modal.test.ts create mode 100644 web/__test__/store/notifications.test.ts create mode 100644 web/__test__/store/purchase.test.ts create mode 100644 web/__test__/store/replaceRenew.test.ts create mode 100644 web/__test__/store/theme.test.ts create mode 100644 web/__test__/store/trial.test.ts create mode 100644 web/__test__/store/unraidApi.test.ts create mode 100644 web/__test__/store/unraidApiSettings.test.ts create mode 100644 web/__test__/store/updateOs.test.ts create mode 100644 web/__test__/store/updateOsActions.test.ts create mode 100644 web/__test__/store/updateOsChangelog.test.ts diff --git a/web/__test__/store/activationCode.test.ts b/web/__test__/store/activationCode.test.ts index a0223bc29..340fdc2f8 100644 --- a/web/__test__/store/activationCode.test.ts +++ b/web/__test__/store/activationCode.test.ts @@ -8,8 +8,13 @@ import { createPinia, setActivePinia } from 'pinia'; import { ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY } from '~/consts'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { QueryPayloads } from '@unraid/shared-callbacks'; + import { useActivationCodeStore } from '~/store/activationCode'; +// Mock the shared-callbacks module to prevent crypto-js issues in test +vi.mock('@unraid/shared-callbacks', () => ({})); + // Mock console methods to suppress output const originalConsoleDebug = console.debug; const originalConsoleError = console.error; @@ -24,7 +29,6 @@ afterAll(() => { console.error = originalConsoleError; }); -// Mock sessionStorage const mockStorage = new Map(); vi.stubGlobal('sessionStorage', { getItem: (key: string) => mockStorage.get(key) ?? null, @@ -34,27 +38,19 @@ vi.stubGlobal('sessionStorage', { }); // Mock dependencies -vi.mock('pinia', async () => { - const mod = await vi.importActual('pinia'); - - return { - ...mod, - storeToRefs: () => ({ - state: ref('ENOKEYFILE'), - callbackData: ref(null), - }), - }; -}); - +// NOTE: Mocks need to be hoistable, so use factory functions +// We need refs here to allow changing the values in tests +const mockServerState = ref('ENOKEYFILE'); vi.mock('~/store/server', () => ({ useServerStore: () => ({ - state: 'ENOKEYFILE', + state: mockServerState, }), })); +const mockCallbackData = ref(null); vi.mock('~/store/callbackActions', () => ({ useCallbackActionsStore: () => ({ - callbackData: null, + callbackData: mockCallbackData, }), })); @@ -64,6 +60,9 @@ describe('Activation Code Store', () => { beforeEach(() => { setActivePinia(createPinia()); + mockServerState.value = 'ENOKEYFILE'; + mockCallbackData.value = null; + store = useActivationCodeStore(); vi.clearAllMocks(); mockStorage.clear(); @@ -106,11 +105,25 @@ describe('Activation Code Store', () => { describe('Modal Visibility', () => { it('should show activation modal by default when conditions are met', () => { store.setData({ code: 'TEST123' }); - expect(store.showActivationModal).toBe(true); }); it('should not show modal when data is null', () => { + // store.data is null by default after beforeEach resets + expect(store.showActivationModal).toBe(false); + }); + + it('should not show modal when server state is not ENOKEYFILE', async () => { + store.setData({ code: 'TEST123' }); + mockServerState.value = 'RUNNING'; + await nextTick(); + expect(store.showActivationModal).toBe(false); + }); + + it('should not show modal when callback data exists', async () => { + store.setData({ code: 'TEST123' }); + mockCallbackData.value = { some: 'data' } as unknown as QueryPayloads; + await nextTick(); expect(store.showActivationModal).toBe(false); }); @@ -119,16 +132,31 @@ describe('Activation Code Store', () => { expect(store.showActivationModal).toBe(true); store.setActivationModalHidden(true); + await nextTick(); expect(store.showActivationModal).toBe(false); expect(sessionStorage.getItem(ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY)).toBe('true'); store.setActivationModalHidden(false); + await nextTick(); expect(store.showActivationModal).toBe(true); expect(sessionStorage.getItem(ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY)).toBeNull(); + + // Set data again before hiding and changing other states + store.setData({ code: 'TEST123' }); + store.setActivationModalHidden(true); + + await nextTick(); + + mockServerState.value = 'STOPPED'; + mockCallbackData.value = { other: 'info' } as unknown as QueryPayloads; + + await nextTick(); + + expect(store.showActivationModal).toBe(false); }); }); }); diff --git a/web/__test__/store/callbackActions.test.ts b/web/__test__/store/callbackActions.test.ts index cd7b732f8..2ce081282 100644 --- a/web/__test__/store/callbackActions.test.ts +++ b/web/__test__/store/callbackActions.test.ts @@ -12,6 +12,7 @@ import type { Mock } from 'vitest'; import { useAccountStore } from '~/store/account'; import { useCallbackActionsStore } from '~/store/callbackActions'; +import { useInstallKeyStore } from '~/store/installKey'; import { useServerStore } from '~/store/server'; import { useUpdateOsActionsStore } from '~/store/updateOsActions'; @@ -122,6 +123,18 @@ vi.mock('~/store/updateOsActions', () => { }; }); +vi.mock('~/store/updateOs', () => { + return { + useUpdateOsStore: vi.fn(() => ({ + $state: {}, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $dispose: vi.fn(), + })), + }; +}); + describe('Callback Actions Store', () => { let store: ReturnType; let preventClose: { addPreventClose: Mock; removePreventClose: Mock }; @@ -218,7 +231,7 @@ describe('Callback Actions Store', () => { expect(store.callbackStatus).toBe('ready'); }); - it('should handle invalid callback type', () => { + it('should handle invalid callback type', async () => { const mockData = { type: 'fromUpc', actions: [], @@ -227,12 +240,14 @@ describe('Callback Actions Store', () => { const consoleSpy = vi.spyOn(console, 'error'); store.saveCallbackData(mockData); + await nextTick(); expect(consoleSpy).toHaveBeenCalledWith( '[redirectToCallbackType]', 'Callback redirect type not present or incorrect' ); expect(store.callbackStatus).toBe('ready'); + expect(store.$state.callbackError).toBe('Callback redirect type not present or incorrect'); }); }); @@ -281,6 +296,25 @@ describe('Callback Actions Store', () => { expect(vi.mocked(useServerStore)().refreshServerState).toHaveBeenCalled(); }); + it('should handle oemSignOut action', async () => { + const mockData: QueryPayloads = { + type: 'forUpc', + actions: [ + { + type: 'oemSignOut', + }, + ], + sender: 'test', + }; + + store.saveCallbackData(mockData); + await nextTick(); + + expect(vi.mocked(useAccountStore)().setAccountAction).toHaveBeenCalled(); + expect(vi.mocked(useAccountStore)().setQueueConnectSignOut).toHaveBeenCalledWith(true); + expect(vi.mocked(useServerStore)().refreshServerState).toHaveBeenCalled(); + }); + it('should handle updateOs action', async () => { const mockData: QueryPayloads = { type: 'forUpc', @@ -306,6 +340,32 @@ describe('Callback Actions Store', () => { expect(vi.mocked(useServerStore)().refreshServerState).not.toHaveBeenCalled(); // Single action, no refresh needed }); + it('should handle downgradeOs action', async () => { + const mockData: QueryPayloads = { + type: 'forUpc', + actions: [ + { + type: 'downgradeOs', + server: { + guid: 'test-guid', + name: 'test-server', + }, + sha256: 'test-sha256', + version: '6.11.5', + } as ExternalUpdateOsAction, + ], + sender: 'test', + }; + const mockUpdateOsActionsStore = useUpdateOsActionsStore(); + + store.saveCallbackData(mockData); + await nextTick(); + + expect(mockUpdateOsActionsStore.setUpdateOsAction).toHaveBeenCalled(); + expect(mockUpdateOsActionsStore.actOnUpdateOsAction).toHaveBeenCalledWith(true); + expect(vi.mocked(useServerStore)().refreshServerState).not.toHaveBeenCalled(); // Single action, no refresh needed + }); + it('should handle multiple actions', async () => { const mockData: QueryPayloads = { type: 'forUpc', @@ -335,6 +395,28 @@ describe('Callback Actions Store', () => { expect(vi.mocked(useUpdateOsActionsStore)().setUpdateOsAction).toHaveBeenCalled(); expect(vi.mocked(useServerStore)().refreshServerState).toHaveBeenCalled(); }); + + it('should handle key install action (e.g., purchase)', async () => { + const mockData: QueryPayloads = { + type: 'forUpc', + actions: [ + { + type: 'purchase', + keyUrl: 'mock-key-url', + }, + ], + sender: 'test', + }; + const mockInstallKeyStore = useInstallKeyStore(); + + store.saveCallbackData(mockData); + await nextTick(); + + expect(mockInstallKeyStore.install).toHaveBeenCalledWith(mockData.actions[0]); + expect(vi.mocked(useAccountStore)().setAccountAction).not.toHaveBeenCalled(); + expect(vi.mocked(useUpdateOsActionsStore)().setUpdateOsAction).not.toHaveBeenCalled(); + expect(vi.mocked(useServerStore)().refreshServerState).toHaveBeenCalled(); + }); }); describe('Status Management', () => { diff --git a/web/__test__/store/errors.test.ts b/web/__test__/store/errors.test.ts index e74a58558..61be0db61 100644 --- a/web/__test__/store/errors.test.ts +++ b/web/__test__/store/errors.test.ts @@ -13,12 +13,10 @@ import { useErrorsStore } from '~/store/errors'; const mockFeedbackButton = vi.fn(); -// Mock OBJ_TO_STR function vi.mock('~/helpers/functions', () => ({ OBJ_TO_STR: (obj: unknown) => JSON.stringify(obj), })); -// Mock FeedbackButton global vi.stubGlobal('FeedbackButton', mockFeedbackButton); describe('Errors Store', () => { @@ -93,11 +91,34 @@ describe('Errors Store', () => { expect(store.errors).toHaveLength(0); }); + + it('should not change errors when removing by non-existent index', async () => { + store.setError(mockError); + const initialErrors = [...store.errors]; + expect(initialErrors).toHaveLength(1); + + store.removeErrorByIndex(99); + await nextTick(); + + expect(store.errors).toHaveLength(1); + expect(store.errors).toEqual(initialErrors); + }); + + it('should not change errors when removing by non-existent ref', async () => { + store.setError(mockError); + const initialErrors = [...store.errors]; + expect(initialErrors).toHaveLength(1); + + store.removeErrorByRef('non-existent-ref'); + await nextTick(); + + expect(store.errors).toHaveLength(1); + expect(store.errors).toEqual(initialErrors); + }); }); describe('Troubleshoot Feature', () => { beforeEach(() => { - // Mock the DOM elements needed for troubleshoot const mockModal = document.createElement('div'); mockModal.className = 'sweet-alert visible'; @@ -126,8 +147,18 @@ describe('Errors Store', () => { document.body.innerHTML = ''; }); - it('should open troubleshoot with error details', async () => { - store.setError(mockError); + it('should open troubleshoot with multiple error details including debugServer', async () => { + const error1: Error = { ...mockError, ref: 'err1' }; + const error2: Error = { + heading: 'Second Error', + level: 'warning', + message: 'Another message', + type: 'serverState', + ref: 'err2', + debugServer: { guid: 'debug-guid', name: 'debug-server' }, + }; + store.setError(error1); + store.setError(error2); await nextTick(); await store.openTroubleshoot({ @@ -141,12 +172,57 @@ describe('Errors Store', () => { const panel = document.querySelector('#troubleshoot_panel') as HTMLElement; expect(mockFeedbackButton).toHaveBeenCalled(); - expect(textarea.value).toContain('Debug Details – Component Errors 1'); + expect(textarea.value).toContain('Debug Details – Component Errors 2'); expect(textarea.value).toContain('Error 1: Test Error'); expect(textarea.value).toContain('Error 1 Message: Test message'); + expect(textarea.value).toContain('Error 1 Ref: err1'); + expect(textarea.value).not.toContain('Error 1 Debug Server'); + expect(textarea.value).toContain('\n***************\n'); + expect(textarea.value).toContain('Error 2: Second Error'); + expect(textarea.value).toContain('Error 2 Message: Another message'); + expect(textarea.value).toContain('Error 2 Level: warning'); + expect(textarea.value).toContain('Error 2 Type: serverState'); + expect(textarea.value).toContain('Error 2 Ref: err2'); + expect(textarea.value).toContain( + 'Error 2 Debug Server:\n{"guid":"debug-guid","name":"debug-server"}' + ); + expect(emailInput.value).toBe('test@example.com'); expect(radio.checked).toBe(true); expect(panel.style.display).toBe('block'); }); + + it('should focus email input if no email provided', async () => { + const focusSpy = vi.spyOn(HTMLInputElement.prototype, 'focus'); + + await store.openTroubleshoot({ + email: '', + includeUnraidApiLogs: true, + }); + + const emailInput = document.querySelector('#troubleshootEmail') as HTMLInputElement; + + expect(focusSpy).toHaveBeenCalled(); + expect(emailInput.value).toBe(''); + + focusSpy.mockRestore(); + }); + + it('should handle errors during troubleshoot opening', async () => { + const testError = new Error('FeedbackButton failed'); + mockFeedbackButton.mockRejectedValueOnce(testError); + + const consoleSpy = vi.spyOn(console, 'error'); + + await store.openTroubleshoot({ + email: 'test@example.com', + includeUnraidApiLogs: true, + }); + + expect(mockFeedbackButton).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith('[openTroubleshoot]', testError); + + consoleSpy.mockRestore(); + }); }); }); diff --git a/web/__test__/store/installKey.test.ts b/web/__test__/store/installKey.test.ts index f837a6275..63cca8fa8 100644 --- a/web/__test__/store/installKey.test.ts +++ b/web/__test__/store/installKey.test.ts @@ -68,6 +68,8 @@ describe('InstallKey Store', () => { expect(store.keyInstallStatus).toBe('failed'); expect(console.error).toHaveBeenCalledWith('[install] no key to install'); + expect(store.keyType).toBeUndefined(); + expect(store.keyUrl).toBeUndefined(); }); it('should set status to installing when install is called', async () => { @@ -85,17 +87,23 @@ describe('InstallKey Store', () => { await promise; }); - it('should call WebguiInstallKey.query with correct url', async () => { + it('should handle successful install and update state', async () => { mockGetFn.mockResolvedValueOnce({ success: true }); + const action = createTestAction({ + type: 'purchase', + keyUrl: 'https://example.com/license.key', + }); - await store.install( - createTestAction({ - type: 'purchase', - keyUrl: 'https://example.com/license.key', - }) - ); + await store.install(action); + const { WebguiInstallKey } = await import('~/composables/services/webgui'); + + expect(WebguiInstallKey.query).toHaveBeenCalledWith({ url: action.keyUrl }); + expect(mockGetFn).toHaveBeenCalled(); expect(store.keyInstallStatus).toBe('success'); + expect(store.keyActionType).toBe('purchase'); + expect(store.keyUrl).toBe('https://example.com/license.key'); + expect(store.keyType).toBe('license'); }); it('should extract key type from .key URL', async () => { diff --git a/web/__test__/store/modal.test.ts b/web/__test__/store/modal.test.ts new file mode 100644 index 000000000..474afcfc7 --- /dev/null +++ b/web/__test__/store/modal.test.ts @@ -0,0 +1,56 @@ +/** + * Modal store test coverage + */ + +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useModalStore } from '~/store/modal'; + +vi.mock('@vueuse/core', () => ({ + useToggle: vi.fn((value) => () => { + value.value = !value.value; + }), +})); + +describe('Modal Store', () => { + let store: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useModalStore(); + vi.clearAllMocks(); + }); + + describe('State and Initialization', () => { + it('should initialize with modal visible', () => { + expect(store.modalVisible).toBe(true); + }); + }); + + describe('Actions', () => { + it('should hide modal', () => { + store.modalHide(); + expect(store.modalVisible).toBe(false); + }); + + it('should show modal', () => { + store.modalHide(); + expect(store.modalVisible).toBe(false); + + store.modalShow(); + expect(store.modalVisible).toBe(true); + }); + + it('should toggle modal visibility', () => { + expect(store.modalVisible).toBe(true); + + store.modalToggle(); + expect(store.modalVisible).toBe(false); + + store.modalToggle(); + expect(store.modalVisible).toBe(true); + }); + }); +}); diff --git a/web/__test__/store/notifications.test.ts b/web/__test__/store/notifications.test.ts new file mode 100644 index 000000000..df94a4847 --- /dev/null +++ b/web/__test__/store/notifications.test.ts @@ -0,0 +1,99 @@ +/** + * Notifications store test coverage + */ + +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { + NotificationFragmentFragment, + NotificationImportance, + NotificationType, +} from '~/composables/gql/graphql'; + +import { useNotificationsStore } from '~/store/notifications'; + +describe('Notifications Store', () => { + let store: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useNotificationsStore(); + }); + + describe('State and Initialization', () => { + it('should initialize with empty notifications array', () => { + expect(store.notifications).toEqual([]); + }); + + it('should initialize with isOpen set to false', () => { + expect(store.isOpen).toBe(false); + }); + }); + + describe('Getters', () => { + it('should return correct title based on isOpen state', () => { + // Initial state (closed) + expect(store.title).toBe('Notifications Are Closed'); + + // After opening + store.toggle(); + expect(store.title).toBe('Notifications Are Open'); + + // After closing again + store.toggle(); + expect(store.title).toBe('Notifications Are Closed'); + }); + }); + + describe('Actions', () => { + it('should toggle isOpen state', () => { + // Initial state is false + expect(store.isOpen).toBe(false); + + // First toggle - should become true + store.toggle(); + expect(store.isOpen).toBe(true); + + // Second toggle - should become false again + store.toggle(); + expect(store.isOpen).toBe(false); + }); + + it('should set notifications correctly', () => { + const mockNotifications: NotificationFragmentFragment[] = [ + { + __typename: 'Notification', + id: '1', + title: 'Test Notification 1', + subject: 'Test Subject 1', + description: 'This is a test notification 1', + importance: 'NORMAL' as NotificationImportance, + type: 'SYSTEM' as NotificationType, + timestamp: '2023-01-01T12:00:00Z', + formattedTimestamp: 'Jan 1, 2023', + }, + { + __typename: 'Notification', + id: '2', + title: 'Test Notification 2', + subject: 'Test Subject 2', + description: 'This is a test notification 2', + importance: 'HIGH' as NotificationImportance, + type: 'UPDATE' as NotificationType, + timestamp: '2023-01-02T12:00:00Z', + formattedTimestamp: 'Jan 2, 2023', + link: 'https://example.com', + }, + ]; + + store.setNotifications(mockNotifications); + + expect(store.notifications).toEqual(mockNotifications); + expect(store.notifications.length).toBe(2); + expect(store.notifications[0].id).toBe('1'); + expect(store.notifications[1].id).toBe('2'); + }); + }); +}); diff --git a/web/__test__/store/purchase.test.ts b/web/__test__/store/purchase.test.ts new file mode 100644 index 000000000..24af83e6e --- /dev/null +++ b/web/__test__/store/purchase.test.ts @@ -0,0 +1,176 @@ +/** + * Purchase store test coverage + */ + +import { createPinia, setActivePinia } from 'pinia'; + +import { PURCHASE_CALLBACK } from '~/helpers/urls'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { usePurchaseStore } from '~/store/purchase'; + +// Mock dependencies +const mockSend = vi.fn(); +const mockServerStore = { + serverPurchasePayload: { + guid: 'test-guid', + name: 'test-server', + }, + inIframe: false, +}; + +vi.mock('~/store/callbackActions', () => ({ + useCallbackActionsStore: () => ({ + send: mockSend, + sendType: 'post', + }), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: () => mockServerStore, +})); + +describe('Purchase Store', () => { + let store: ReturnType; + + beforeEach(() => { + // Reset mock values + mockServerStore.inIframe = false; + + setActivePinia(createPinia()); + store = usePurchaseStore(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Actions', () => { + it('should call activate action correctly', () => { + store.activate(); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + PURCHASE_CALLBACK.toString(), + [ + { + server: { + guid: 'test-guid', + name: 'test-server', + }, + type: 'activate', + }, + ], + undefined, + 'post' + ); + }); + + it('should call redeem action correctly', () => { + store.redeem(); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + PURCHASE_CALLBACK.toString(), + [ + { + server: { + guid: 'test-guid', + name: 'test-server', + }, + type: 'redeem', + }, + ], + undefined, + 'post' + ); + }); + + it('should call purchase action correctly', () => { + store.purchase(); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + PURCHASE_CALLBACK.toString(), + [ + { + server: { + guid: 'test-guid', + name: 'test-server', + }, + type: 'purchase', + }, + ], + undefined, + 'post' + ); + }); + + it('should call upgrade action correctly', () => { + store.upgrade(); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + PURCHASE_CALLBACK.toString(), + [ + { + server: { + guid: 'test-guid', + name: 'test-server', + }, + type: 'upgrade', + }, + ], + undefined, + 'post' + ); + }); + + it('should call renew action correctly', () => { + store.renew(); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + PURCHASE_CALLBACK.toString(), + [ + { + server: { + guid: 'test-guid', + name: 'test-server', + }, + type: 'renew', + }, + ], + undefined, + 'post' + ); + }); + + it('should handle iframe redirection correctly', () => { + // Set up the iframe state + mockServerStore.inIframe = true; + + setActivePinia(createPinia()); + const iframeStore = usePurchaseStore(); + + iframeStore.purchase(); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + PURCHASE_CALLBACK.toString(), + [ + { + server: { + guid: 'test-guid', + name: 'test-server', + }, + type: 'purchase', + }, + ], + 'newTab', + 'post' + ); + }); + }); +}); diff --git a/web/__test__/store/replaceRenew.test.ts b/web/__test__/store/replaceRenew.test.ts new file mode 100644 index 000000000..1dd096a7b --- /dev/null +++ b/web/__test__/store/replaceRenew.test.ts @@ -0,0 +1,334 @@ +/** + * ReplaceRenew store test coverage + */ + +import { createPinia, setActivePinia } from 'pinia'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ValidateGuidResponse } from '~/composables/services/keyServer'; + +import { validateGuid } from '~/composables/services/keyServer'; +import { REPLACE_CHECK_LOCAL_STORAGE_KEY, useReplaceRenewStore } from '~/store/replaceRenew'; +import { useServerStore } from '~/store/server'; + +vi.mock('@unraid/shared-callbacks', () => ({})); + +vi.mock('~/composables/services/keyServer', () => ({ + validateGuid: vi.fn(), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: vi.fn(), +})); + +const mockSessionStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), +}; + +Object.defineProperty(window, 'sessionStorage', { value: mockSessionStorage }); + +describe('ReplaceRenew Store', () => { + let store: ReturnType; + let mockGuid = 'test-guid'; + let mockKeyfile = 'test-keyfile.key'; + + beforeEach(() => { + vi.resetAllMocks(); + mockGuid = 'test-guid'; + mockKeyfile = 'test-keyfile.key'; + + vi.mocked(useServerStore).mockReturnValue({ + guid: mockGuid, + keyfile: mockKeyfile, + } as unknown as ReturnType); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + + setActivePinia(createPinia()); + store = useReplaceRenewStore(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('State and Initialization', () => { + it('should initialize with default state values', () => { + expect(store.keyLinkedStatus).toBe('ready'); + expect(store.renewStatus).toBe('ready'); + expect(store.replaceStatus).toBe('ready'); + }); + + it('should initialize with error state when guid is missing', () => { + vi.mocked(useServerStore).mockReturnValueOnce({ + guid: undefined, + keyfile: mockKeyfile, + } as unknown as ReturnType); + + setActivePinia(createPinia()); + + const newStore = useReplaceRenewStore(); + + expect(newStore.replaceStatus).toBe('error'); + }); + }); + + describe('Computed Properties', () => { + it('should return correct keyLinkedOutput for each status', () => { + expect(store.keyLinkedOutput.variant).toBe('gray'); + expect(store.keyLinkedOutput.text).toBe('Unknown'); + + store.keyLinkedStatus = 'checking'; + expect(store.keyLinkedOutput.variant).toBe('gray'); + expect(store.keyLinkedOutput.text).toBe('Checking...'); + + store.keyLinkedStatus = 'linked'; + expect(store.keyLinkedOutput.variant).toBe('green'); + expect(store.keyLinkedOutput.text).toBe('Linked'); + + store.keyLinkedStatus = 'notLinked'; + expect(store.keyLinkedOutput.variant).toBe('yellow'); + expect(store.keyLinkedOutput.text).toBe('Not Linked'); + + store.keyLinkedStatus = 'error'; + // Test with a specific error message + store.error = { name: 'TestError', message: 'Specific Linked Error' }; + expect(store.keyLinkedOutput.variant).toBe('red'); + expect(store.keyLinkedOutput.text).toBe('Specific Linked Error'); + }); + + it('should return correct replaceStatusOutput for each status', () => { + expect(store.replaceStatusOutput).toBeUndefined(); + + store.replaceStatus = 'checking'; + expect(store.replaceStatusOutput?.variant).toBe('gray'); + expect(store.replaceStatusOutput?.text).toBe('Checking...'); + + store.replaceStatus = 'eligible'; + expect(store.replaceStatusOutput?.variant).toBe('green'); + expect(store.replaceStatusOutput?.text).toBe('Eligible'); + + store.replaceStatus = 'ineligible'; + expect(store.replaceStatusOutput?.variant).toBe('red'); + expect(store.replaceStatusOutput?.text).toBe('Ineligible for self-replacement'); + + store.replaceStatus = 'error'; + store.error = { name: 'TestError', message: 'Specific Replace Error' }; + + expect(store.replaceStatusOutput?.variant).toBe('red'); + expect(store.replaceStatusOutput?.text).toBe('Specific Replace Error'); + }); + }); + + describe('Actions', () => { + it('should purge validation response', async () => { + await store.purgeValidationResponse(); + + expect(mockSessionStorage.removeItem).toHaveBeenCalledWith(REPLACE_CHECK_LOCAL_STORAGE_KEY); + }); + + it('should set status actions correctly', () => { + store.setReplaceStatus('eligible'); + expect(store.replaceStatus).toBe('eligible'); + + store.setRenewStatus('installing'); + expect(store.renewStatus).toBe('installing'); + }); + + describe('check action', () => { + const mockResponse = { + hasNewerKeyfile: false, + linked: true, + replaceable: true, + }; + + beforeEach(() => { + vi.mocked(validateGuid).mockResolvedValue(mockResponse as unknown as ValidateGuidResponse); + mockSessionStorage.getItem.mockReturnValue(null); + vi.mocked(useServerStore).mockReturnValue({ + guid: 'test-guid', + keyfile: 'test-keyfile.key', + } as unknown as ReturnType); + + setActivePinia(createPinia()); + store = useReplaceRenewStore(); + }); + + it('should handle missing guid', async () => { + setActivePinia(createPinia()); + const testStore = useReplaceRenewStore(); + + testStore.setReplaceStatus('error'); + + expect(testStore.replaceStatus).toBe('error'); + }); + + it('should handle missing keyfile', async () => { + setActivePinia(createPinia()); + const testStore = useReplaceRenewStore(); + + testStore.setReplaceStatus('error'); + + expect(testStore.replaceStatus).toBe('error'); + }); + + it('should use cached response if available and valid', async () => { + const cachedResponse = { + key: 'eyfile.key', + timestamp: Date.now(), + hasNewerKeyfile: false, + linked: false, + replaceable: false, + }; + + mockSessionStorage.getItem.mockReturnValue(JSON.stringify(cachedResponse)); + setActivePinia(createPinia()); + const testStore = useReplaceRenewStore(); + + await testStore.check(); + + expect(validateGuid).not.toHaveBeenCalled(); + + expect(testStore.keyLinkedStatus).toBe('notLinked'); + expect(testStore.replaceStatus).toBe('ineligible'); + + expect(mockSessionStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should purge cache and re-fetch if timestamp is expired', async () => { + vi.useFakeTimers(); + const expiredTimestamp = Date.now() - 8 * 24 * 60 * 60 * 1000; + const cachedResponse = { + key: 'eyfile.key', + timestamp: expiredTimestamp, + hasNewerKeyfile: false, + linked: false, + replaceable: false, + }; + + mockSessionStorage.getItem.mockReturnValue(JSON.stringify(cachedResponse)); + vi.mocked(validateGuid).mockResolvedValue(mockResponse as unknown as ValidateGuidResponse); + + setActivePinia(createPinia()); + const testStore = useReplaceRenewStore(); + + await testStore.check(); + + expect(mockSessionStorage.removeItem).toHaveBeenCalledWith(REPLACE_CHECK_LOCAL_STORAGE_KEY); + + expect(validateGuid).toHaveBeenCalled(); + expect(testStore.keyLinkedStatus).toBe('linked'); + expect(testStore.replaceStatus).toBe('eligible'); + + vi.useRealTimers(); + }); + + it('should purge cache and re-fetch if key is missing in cache', async () => { + const cachedResponse = { + timestamp: Date.now(), + hasNewerKeyfile: false, + linked: false, + replaceable: false, + }; + + mockSessionStorage.getItem.mockReturnValue(JSON.stringify(cachedResponse)); + vi.mocked(validateGuid).mockResolvedValue(mockResponse as unknown as ValidateGuidResponse); + + setActivePinia(createPinia()); + const testStore = useReplaceRenewStore(); + + await testStore.check(); + + expect(mockSessionStorage.removeItem).toHaveBeenCalledWith(REPLACE_CHECK_LOCAL_STORAGE_KEY); + expect(validateGuid).toHaveBeenCalled(); + expect(testStore.keyLinkedStatus).toBe('linked'); + expect(testStore.replaceStatus).toBe('eligible'); + }); + + it('should purge cache and re-fetch if key in cache mismatches current keyfile', async () => { + const cachedResponse = { + key: 'mismatched', + timestamp: Date.now(), + hasNewerKeyfile: false, + linked: false, + replaceable: false, + }; + + mockSessionStorage.getItem.mockReturnValue(JSON.stringify(cachedResponse)); + vi.mocked(validateGuid).mockResolvedValue(mockResponse as unknown as ValidateGuidResponse); + setActivePinia(createPinia()); + + const testStore = useReplaceRenewStore(); + + await testStore.check(); + + expect(mockSessionStorage.removeItem).toHaveBeenCalledWith(REPLACE_CHECK_LOCAL_STORAGE_KEY); + expect(validateGuid).toHaveBeenCalled(); + expect(testStore.keyLinkedStatus).toBe('linked'); + expect(testStore.replaceStatus).toBe('eligible'); + }); + + it('should call validateGuid with correct parameters', async () => { + await store.check(); + + expect(validateGuid).toHaveBeenCalledWith({ + guid: 'test-guid', + keyfile: 'test-keyfile.key', + }); + }); + + it('should set statuses based on validateGuid response', async () => { + await store.check(); + + expect(store.keyLinkedStatus).toBe('linked'); + expect(store.replaceStatus).toBe('eligible'); + }); + + it('should cache the validation response', async () => { + vi.useFakeTimers(); + const now = new Date('2023-01-01').getTime(); + vi.setSystemTime(now); + + await store.check(); + + expect(mockSessionStorage.setItem).toHaveBeenCalledWith( + REPLACE_CHECK_LOCAL_STORAGE_KEY, + JSON.stringify({ + key: 'eyfile.key', + timestamp: now, + ...mockResponse, + }) + ); + + vi.useRealTimers(); + }); + + it('should purge cache when skipCache is true', async () => { + mockSessionStorage.getItem.mockReturnValue( + JSON.stringify({ key: 'eyfile.key', timestamp: Date.now() }) + ); + setActivePinia(createPinia()); + const testStore = useReplaceRenewStore(); + + await testStore.check(true); + + expect(mockSessionStorage.removeItem).toHaveBeenCalledWith(REPLACE_CHECK_LOCAL_STORAGE_KEY); + expect(validateGuid).toHaveBeenCalled(); + }); + + it('should handle errors during check', async () => { + const testError = new Error('Test error'); + vi.mocked(validateGuid).mockRejectedValueOnce(testError); + + await store.check(); + + expect(store.replaceStatus).toBe('error'); + expect(console.error).toHaveBeenCalledWith('[ReplaceCheck.check]', testError); + expect(store.error).toEqual(testError); + }); + }); + }); +}); diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts new file mode 100644 index 000000000..0b128278f --- /dev/null +++ b/web/__test__/store/theme.test.ts @@ -0,0 +1,166 @@ +/** + * Theme store test coverage + */ + +import { nextTick } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; + +import { defaultColors } from '~/themes/default'; +import hexToRgba from 'hex-to-rgba'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useThemeStore } from '~/store/theme'; + +vi.mock('hex-to-rgba', () => ({ + default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`), +})); + +describe('Theme Store', () => { + let store: ReturnType; + const originalAddClassFn = document.body.classList.add; + const originalRemoveClassFn = document.body.classList.remove; + const originalStyleCssText = document.body.style.cssText; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useThemeStore(); + + document.body.classList.add = vi.fn(); + document.body.classList.remove = vi.fn(); + document.body.style.cssText = ''; + + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); + + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore original methods + document.body.classList.add = originalAddClassFn; + document.body.classList.remove = originalRemoveClassFn; + document.body.style.cssText = originalStyleCssText; + vi.restoreAllMocks(); + }); + + describe('State and Initialization', () => { + it('should initialize with default theme', () => { + expect(store.theme).toEqual({ + name: 'white', + banner: false, + bannerGradient: false, + bgColor: '', + descriptionShow: false, + metaColor: '', + textColor: '', + }); + expect(store.activeColorVariables).toEqual(defaultColors.white); + }); + + it('should compute darkMode correctly', () => { + expect(store.darkMode).toBe(false); + + store.setTheme({ ...store.theme, name: 'black' }); + expect(store.darkMode).toBe(true); + + store.setTheme({ ...store.theme, name: 'gray' }); + expect(store.darkMode).toBe(true); + + store.setTheme({ ...store.theme, name: 'white' }); + expect(store.darkMode).toBe(false); + }); + + it('should compute bannerGradient correctly', () => { + expect(store.bannerGradient).toBeUndefined(); + + store.setTheme({ + ...store.theme, + banner: true, + bannerGradient: true, + }); + expect(store.bannerGradient).toBe( + 'background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 30%);' + ); + + store.setTheme({ + ...store.theme, + banner: true, + bannerGradient: true, + bgColor: '#123456', + }); + expect(store.bannerGradient).toBe( + 'background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 30%);' + ); + }); + }); + + describe('Actions', () => { + it('should set theme correctly', () => { + const newTheme = { + name: 'black', + banner: true, + bannerGradient: true, + bgColor: '#123456', + descriptionShow: true, + metaColor: '#abcdef', + textColor: '#ffffff', + }; + + store.setTheme(newTheme); + expect(store.theme).toEqual(newTheme); + }); + + it('should update body classes for dark mode', async () => { + store.setTheme({ ...store.theme, name: 'black' }); + + await nextTick(); + + expect(document.body.classList.add).toHaveBeenCalledWith('dark'); + + store.setTheme({ ...store.theme, name: 'white' }); + + await nextTick(); + + expect(document.body.classList.remove).toHaveBeenCalledWith('dark'); + }); + + it('should update activeColorVariables when theme changes', async () => { + store.setTheme({ + ...store.theme, + name: 'white', + textColor: '#333333', + metaColor: '#666666', + bgColor: '#ffffff', + }); + + await nextTick(); + + expect(store.activeColorVariables['--header-text-primary']).toBe('#333333'); + expect(store.activeColorVariables['--header-text-secondary']).toBe('#666666'); + expect(store.activeColorVariables['--header-background-color']).toBe('#ffffff'); + }); + + it('should handle banner gradient correctly', async () => { + const mockHexToRgba = vi.mocked(hexToRgba); + + mockHexToRgba.mockClear(); + + store.setTheme({ + ...store.theme, + banner: true, + bannerGradient: true, + bgColor: '#112233', + }); + + await nextTick(); + + expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0); + expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0.7); + + expect(store.activeColorVariables['--header-gradient-start']).toBe('rgba(mock-#112233-0)'); + expect(store.activeColorVariables['--header-gradient-end']).toBe('rgba(mock-#112233-0.7)'); + }); + }); +}); diff --git a/web/__test__/store/trial.test.ts b/web/__test__/store/trial.test.ts new file mode 100644 index 000000000..1544910e7 --- /dev/null +++ b/web/__test__/store/trial.test.ts @@ -0,0 +1,215 @@ +/** + * Trial store test coverage + */ + +import { nextTick } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { StartTrialResponse } from '~/composables/services/keyServer'; + +import { useTrialStore } from '~/store/trial'; + +const mockAddPreventClose = vi.fn(); +const mockRemovePreventClose = vi.fn(); +const mockStartTrial = vi.fn(); +const mockSaveCallbackData = vi.fn(); +const mockDropdownHide = vi.fn(); + +vi.mock('~/composables/preventClose', () => ({ + addPreventClose: () => mockAddPreventClose(), + removePreventClose: () => mockRemovePreventClose(), +})); + +vi.mock('~/composables/services/keyServer', () => ({ + startTrial: (payload: unknown) => mockStartTrial(payload), +})); + +vi.mock('~/store/callbackActions', () => ({ + useCallbackActionsStore: () => ({ + saveCallbackData: mockSaveCallbackData, + }), +})); + +vi.mock('~/store/dropdown', () => ({ + useDropdownStore: () => ({ + dropdownHide: mockDropdownHide, + }), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: () => ({ + guid: 'test-guid', + }), +})); + +describe('Trial Store', () => { + let store: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useTrialStore(); + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockStartTrial.mockResolvedValue({ + license: 'mock-license-key', + }); + + // Suppress console output during tests + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('State and Getters', () => { + it('should initialize with ready status', () => { + expect(store.trialStatus).toBe('ready'); + expect(store.trialModalLoading).toBe(false); + expect(store.trialModalVisible).toBe(false); + }); + + it('should update trialModalLoading when status is trialExtend or trialStart', () => { + store.setTrialStatus('ready'); + expect(store.trialModalLoading).toBe(false); + + store.setTrialStatus('trialStart'); + expect(store.trialModalLoading).toBe(true); + + store.setTrialStatus('trialExtend'); + expect(store.trialModalLoading).toBe(true); + }); + + it('should update trialModalVisible when status is trialExtend, trialStart, or failed', () => { + store.setTrialStatus('ready'); + expect(store.trialModalVisible).toBe(false); + + store.setTrialStatus('trialStart'); + expect(store.trialModalVisible).toBe(true); + + store.setTrialStatus('trialExtend'); + expect(store.trialModalVisible).toBe(true); + + store.setTrialStatus('failed'); + expect(store.trialModalVisible).toBe(true); + + store.setTrialStatus('success'); + expect(store.trialModalVisible).toBe(false); + }); + }); + + describe('Actions', () => { + it('should set trial status', () => { + store.setTrialStatus('trialStart'); + expect(store.trialStatus).toBe('trialStart'); + + store.setTrialStatus('failed'); + expect(store.trialStatus).toBe('failed'); + }); + + it('should call addPreventClose and dropdownHide when status changes to trialStart', async () => { + const watchSpy = vi.spyOn(global, 'setTimeout'); + + store.setTrialStatus('trialStart'); + + await vi.runAllTimersAsync(); + + expect(mockAddPreventClose).toHaveBeenCalledTimes(1); + expect(mockDropdownHide).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenLastCalledWith(expect.any(Function), 1500); + }); + + it('should call addPreventClose and dropdownHide when status changes to trialExtend', async () => { + const watchSpy = vi.spyOn(global, 'setTimeout'); + + store.setTrialStatus('trialExtend'); + + await vi.runAllTimersAsync(); + + expect(mockAddPreventClose).toHaveBeenCalledTimes(1); + expect(mockDropdownHide).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenLastCalledWith(expect.any(Function), 1500); + }); + + it('should call removePreventClose when status changes to failed', async () => { + store.setTrialStatus('failed'); + await nextTick(); + + expect(mockRemovePreventClose).toHaveBeenCalledTimes(1); + }); + + it('should call removePreventClose when status changes to success', async () => { + store.setTrialStatus('success'); + await nextTick(); + + expect(mockRemovePreventClose).toHaveBeenCalledTimes(1); + }); + + it('should request trial after delay when status is trialStart', async () => { + store.setTrialStatus('trialStart'); + + await vi.advanceTimersByTimeAsync(1500); + + expect(mockStartTrial).toHaveBeenCalledWith({ + guid: 'test-guid', + timestamp: expect.any(Number), + }); + }); + + it('should request trial after delay when status is trialExtend', async () => { + store.setTrialStatus('trialExtend'); + + await vi.advanceTimersByTimeAsync(1500); + + expect(mockStartTrial).toHaveBeenCalledWith({ + guid: 'test-guid', + timestamp: expect.any(Number), + }); + }); + }); + + describe('Trial Request', () => { + it('should handle successful trial request', async () => { + await store.requestTrial('trialStart'); + + expect(mockStartTrial).toHaveBeenCalledWith({ + guid: 'test-guid', + timestamp: expect.any(Number), + }); + + expect(mockSaveCallbackData).toHaveBeenCalledWith({ + actions: [ + { + keyUrl: 'mock-license-key', + type: 'trialStart', + }, + ], + sender: expect.any(String), + type: 'forUpc', + }); + + expect(store.trialStatus).toBe('success'); + }); + + it('should set failed status when no license is returned', async () => { + mockStartTrial.mockResolvedValueOnce({ + license: undefined, + } as StartTrialResponse); + + await store.requestTrial('trialStart'); + + expect(store.trialStatus).toBe('failed'); + expect(mockSaveCallbackData).not.toHaveBeenCalled(); + }); + + it('should set failed status when an error occurs', async () => { + mockStartTrial.mockRejectedValueOnce(new Error('API error')); + + await store.requestTrial('trialStart'); + + expect(store.trialStatus).toBe('failed'); + expect(mockSaveCallbackData).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/__test__/store/unraidApi.test.ts b/web/__test__/store/unraidApi.test.ts new file mode 100644 index 000000000..19a711cf2 --- /dev/null +++ b/web/__test__/store/unraidApi.test.ts @@ -0,0 +1,271 @@ +/** + * UnraidApi store test coverage + */ + +import { nextTick } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUnraidApiStore } from '~/store/unraidApi'; + +vi.mock('~/helpers/create-apollo-client', () => ({ + client: { + clearStore: vi.fn().mockResolvedValue(undefined), + stop: vi.fn(), + }, +})); + +vi.mock('~/composables/services/webgui', () => ({ + WebguiUnraidApiCommand: vi.fn().mockResolvedValue(undefined), +})); + +const mockErrorsStore = { + setError: vi.fn(), + removeErrorByRef: vi.fn(), +}; + +const mockServerStore = { + connectPluginInstalled: true, + stateDataError: false, + csrf: 'mock-csrf-token', +}; + +describe('UnraidApi Store', () => { + vi.mock('~/store/errors', () => ({ + useErrorsStore: () => mockErrorsStore, + })); + + vi.mock('~/store/server', () => ({ + useServerStore: () => mockServerStore, + })); + + let store: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + Object.assign(mockErrorsStore, { + setError: vi.fn(), + removeErrorByRef: vi.fn(), + }); + + Object.assign(mockServerStore, { + connectPluginInstalled: true, + stateDataError: false, + csrf: 'mock-csrf-token', + }); + + setActivePinia(createPinia()); + + store = useUnraidApiStore(); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Initial state', () => { + it('should initialize with correct default values', () => { + expect(store.unraidApiStatus).toBe('connecting'); + expect(store.prioritizeCorsError).toBe(false); + expect(store.unraidApiClient).not.toBeNull(); + }); + }); + + describe('Computed properties', () => { + it('should have offlineError computed when status is offline', () => { + expect(store.offlineError).toBeUndefined(); + + store.unraidApiStatus = 'offline'; + expect(store.offlineError).toBeInstanceOf(Error); + expect(store.offlineError?.message).toBe('The Unraid API is currently offline.'); + }); + + it('should have unraidApiRestartAction when status is offline', () => { + expect(store.unraidApiRestartAction).toBeUndefined(); + + store.unraidApiStatus = 'offline'; + expect(store.unraidApiRestartAction).toBeDefined(); + expect(store.unraidApiRestartAction?.text).toBe('Restart unraid-api'); + expect(typeof store.unraidApiRestartAction?.click).toBe('function'); + }); + + it('should not provide restart action when plugin not installed', () => { + store.unraidApiStatus = 'offline'; + mockServerStore.connectPluginInstalled = false; + + store = useUnraidApiStore(); + + expect(store.unraidApiRestartAction).toBeUndefined(); + }); + + it('should not provide restart action when stateDataError exists', () => { + store.unraidApiStatus = 'offline'; + mockServerStore.stateDataError = true; + + store = useUnraidApiStore(); + + expect(store.unraidApiRestartAction).toBeUndefined(); + }); + + it('should have restart action with click function when status is offline', () => { + store.unraidApiStatus = 'offline'; + + const action = store.unraidApiRestartAction; + expect(action).toBeDefined(); + expect(action?.text).toBe('Restart unraid-api'); + expect(typeof action?.click).toBe('function'); + }); + }); + + describe('Watchers', () => { + it('should set error when status changes to offline', async () => { + store.unraidApiStatus = 'offline'; + await nextTick(); + + expect(mockErrorsStore.setError).toHaveBeenCalledWith({ + heading: 'Warning: API is offline!', + message: 'The Unraid API is currently offline.', + ref: 'unraidApiOffline', + level: 'warning', + type: 'unraidApiState', + }); + }); + + it('should remove error when status changes from offline', async () => { + store.unraidApiStatus = 'offline'; + await nextTick(); + + store.unraidApiStatus = 'online'; + await nextTick(); + + expect(mockErrorsStore.removeErrorByRef).toHaveBeenCalledWith('unraidApiOffline'); + }); + }); + + describe('Client actions', () => { + it('should close client correctly', async () => { + if (store.unraidApiClient) { + const clearStoreSpy = vi.fn().mockResolvedValue(undefined); + const stopSpy = vi.fn(); + + const originalClearStore = store.unraidApiClient.clearStore; + const originalStop = store.unraidApiClient.stop; + + store.unraidApiClient.clearStore = clearStoreSpy; + store.unraidApiClient.stop = stopSpy; + + try { + await store.closeUnraidApiClient(); + + expect(clearStoreSpy).toHaveBeenCalledTimes(1); + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(store.unraidApiClient).toBeNull(); + expect(store.unraidApiStatus).toBe('offline'); + } finally { + if (store.unraidApiClient) { + store.unraidApiClient.clearStore = originalClearStore; + store.unraidApiClient.stop = originalStop; + } + } + } + }); + + it('should handle null client during close', async () => { + store.unraidApiClient = null; + await store.closeUnraidApiClient(); + + expect(store.unraidApiClient).toBeNull(); + }); + }); + + describe('Restart actions', () => { + it('should restart client when status is offline', async () => { + const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui'); + const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand); + + store.unraidApiStatus = 'offline'; + await store.restartUnraidApiClient(); + + expect(mockWebguiCommand).toHaveBeenCalledWith({ + csrf_token: 'mock-csrf-token', + command: 'start', + }); + expect(store.unraidApiStatus).toBe('restarting'); + }); + + it('should restart client when status is online', async () => { + const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui'); + const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand); + + store.unraidApiStatus = 'online'; + await store.restartUnraidApiClient(); + + expect(mockWebguiCommand).toHaveBeenCalledWith({ + csrf_token: 'mock-csrf-token', + command: 'restart', + }); + expect(store.unraidApiStatus).toBe('restarting'); + }); + + it('should handle error during restart', async () => { + const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui'); + const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand); + + mockWebguiCommand.mockRejectedValueOnce(new Error('API restart failed')); + + store.unraidApiStatus = 'online'; + await store.restartUnraidApiClient(); + + expect(mockWebguiCommand).toHaveBeenCalled(); + expect(mockErrorsStore.setError).toHaveBeenCalledWith({ + heading: 'Error: unraid-api restart', + message: 'API restart failed', + level: 'error', + ref: 'restartUnraidApiClient', + type: 'request', + }); + }); + + it('should handle string error during restart', async () => { + const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui'); + const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand); + + mockWebguiCommand.mockRejectedValueOnce('string error'); + + store.unraidApiStatus = 'online'; + await store.restartUnraidApiClient(); + + expect(mockWebguiCommand).toHaveBeenCalled(); + expect(mockErrorsStore.setError).toHaveBeenCalledWith({ + heading: 'Error: unraid-api restart', + message: 'STRING ERROR', + level: 'error', + ref: 'restartUnraidApiClient', + type: 'request', + }); + }); + + it('should handle unknown error during restart', async () => { + const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui'); + const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand); + + mockWebguiCommand.mockRejectedValueOnce(null); + + store.unraidApiStatus = 'online'; + await store.restartUnraidApiClient(); + + expect(mockWebguiCommand).toHaveBeenCalled(); + expect(mockErrorsStore.setError).toHaveBeenCalledWith({ + heading: 'Error: unraid-api restart', + message: 'Unknown error', + level: 'error', + ref: 'restartUnraidApiClient', + type: 'request', + }); + }); + }); +}); diff --git a/web/__test__/store/unraidApiSettings.test.ts b/web/__test__/store/unraidApiSettings.test.ts new file mode 100644 index 000000000..6d6690814 --- /dev/null +++ b/web/__test__/store/unraidApiSettings.test.ts @@ -0,0 +1,111 @@ +/** + * UnraidApiSettings store test coverage + */ + +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WanAccessType, WanForwardType } from '~/composables/gql/graphql'; +import { useUnraidApiSettingsStore } from '~/store/unraidApiSettings'; + +const mockOrigins = ['http://example.com']; +const mockRemoteAccess = { + accessType: WanAccessType.ALWAYS, + forwardType: WanForwardType.UPNP, + port: 8080, +}; + +const mockLoadFn = vi.fn(); +const mockMutateFn = vi.fn(); + +vi.mock('@vue/apollo-composable', () => ({ + useLazyQuery: () => ({ + load: mockLoadFn, + result: { + value: { + extraAllowedOrigins: mockOrigins, + remoteAccess: mockRemoteAccess, + }, + }, + }), + useMutation: () => ({ + mutate: mockMutateFn.mockImplementation((args) => { + if (args?.input?.origins) { + return Promise.resolve({ + data: { + setAdditionalAllowedOrigins: args.input.origins, + }, + }); + } + return Promise.resolve({ + data: { + setupRemoteAccess: true, + }, + }); + }), + }), +})); + +describe('UnraidApiSettings Store', () => { + let store: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useUnraidApiSettingsStore(); + vi.clearAllMocks(); + }); + + describe('getAllowedOrigins', () => { + it('should get origins successfully', async () => { + const origins = await store.getAllowedOrigins(); + + expect(mockLoadFn).toHaveBeenCalled(); + expect(Array.isArray(origins)).toBe(true); + expect(origins).toEqual(mockOrigins); + }); + }); + + describe('setAllowedOrigins', () => { + it('should set origins and return the updated list of allowed origins', async () => { + const newOrigins = ['http://example.com', 'http://test.com']; + const result = await store.setAllowedOrigins(newOrigins); + + expect(mockMutateFn).toHaveBeenCalledWith({ + input: { origins: newOrigins }, + }); + expect(result).toEqual(newOrigins); + }); + }); + + describe('getRemoteAccess', () => { + it('should get remote access configuration successfully', async () => { + const result = await store.getRemoteAccess(); + + expect(mockLoadFn).toHaveBeenCalled(); + expect(result).toBeDefined(); + + if (result) { + expect(result).toEqual(mockRemoteAccess); + expect(result.accessType).toBe(WanAccessType.ALWAYS); + expect(result.forwardType).toBe(WanForwardType.UPNP); + expect(result.port).toBe(8080); + } + }); + }); + + describe('setupRemoteAccess', () => { + it('should setup remote access successfully and return true', async () => { + const input = { + accessType: WanAccessType.ALWAYS, + forwardType: WanForwardType.STATIC, + port: 9090, + }; + + const result = await store.setupRemoteAccess(input); + + expect(mockMutateFn).toHaveBeenCalledWith({ input }); + expect(result).toBe(true); + }); + }); +}); diff --git a/web/__test__/store/updateOs.test.ts b/web/__test__/store/updateOs.test.ts new file mode 100644 index 000000000..56ce2033c --- /dev/null +++ b/web/__test__/store/updateOs.test.ts @@ -0,0 +1,193 @@ +/** + * UpdateOs store test coverage + */ + +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUpdateOsStore } from '~/store/updateOs'; + +vi.mock('~/composables/services/webgui', () => { + return { + WebguiCheckForUpdate: vi.fn().mockResolvedValue({ + version: '6.12.0', + name: 'Unraid 6.12.0', + isNewer: true, + isEligible: true, + date: '2023-01-01', + sha256: 'test-sha256', + changelog: 'https://example.com/changelog', + }), + WebguiUpdateCancel: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock('~/store/server', () => { + return { + useServerStore: () => ({ + regExp: '2025-01-01', + regUpdatesExpired: false, + updateOsResponse: { + version: '6.12.0', + name: 'Unraid 6.12.0', + isNewer: true, + isEligible: true, + date: '2023-01-01', + sha256: 'test-sha256', + changelog: 'https://example.com/changelog', + }, + updateOsIgnoredReleases: [], + setUpdateOsResponse: vi.fn(), + }), + }; +}); + +// Mock console.debug to prevent noise in tests +console.debug = vi.fn(); + +describe('UpdateOs Store', () => { + let store: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useUpdateOsStore(); + vi.clearAllMocks(); + }); + + describe('State and Getters', () => { + it('should initialize with correct default values', () => { + expect(store.checkForUpdatesLoading).toBe(false); + expect(store.modalOpen).toBe(false); + }); + + it('should have computed properties with the right types', () => { + // Test that properties exist with the right types + expect(typeof store.available).not.toBe('undefined'); + expect(typeof store.availableRequiresAuth).toBe('boolean'); + }); + }); + + describe('Actions', () => { + it('should check for updates and update state', async () => { + const { WebguiCheckForUpdate } = await import('~/composables/services/webgui'); + + // Mock the method to avoid the error + vi.mocked(WebguiCheckForUpdate).mockResolvedValueOnce({ + version: '6.12.0', + name: 'Unraid 6.12.0', + isNewer: true, + isEligible: true, + date: '2023-01-01', + sha256: 'test-sha256', + changelog: 'https://example.com/changelog', + }); + + await store.localCheckForUpdate(); + + expect(WebguiCheckForUpdate).toHaveBeenCalled(); + expect(store.modalOpen).toBe(true); + }); + + it('should set modal open state', () => { + store.setModalOpen(true); + expect(store.modalOpen).toBe(true); + + store.setModalOpen(false); + expect(store.modalOpen).toBe(false); + }); + + it('should handle errors when checking for updates', async () => { + const { WebguiCheckForUpdate } = await import('~/composables/services/webgui'); + + vi.mocked(WebguiCheckForUpdate).mockRejectedValueOnce(new Error('Network error')); + + await expect(store.localCheckForUpdate()).rejects.toThrow( + '[localCheckForUpdate] Error checking for updates' + ); + + expect(WebguiCheckForUpdate).toHaveBeenCalled(); + }); + + it('should successfully cancel an update', async () => { + const { WebguiUpdateCancel } = await import('~/composables/services/webgui'); + const originalLocation = window.location; + const mockReload = vi.fn(); + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + pathname: '/some/other/path', + reload: mockReload, + }, + }); + + vi.mocked(WebguiUpdateCancel).mockResolvedValueOnce({ success: true }); + + await store.cancelUpdate(); + + expect(WebguiUpdateCancel).toHaveBeenCalled(); + expect(mockReload).toHaveBeenCalled(); + + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + }); + + it('should redirect to /Tools when cancelling update from /Tools/Update path', async () => { + const { WebguiUpdateCancel } = await import('~/composables/services/webgui'); + const originalLocation = window.location; + let hrefValue = ''; + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + pathname: '/Tools/Update', + get href() { + return hrefValue; + }, + set href(value) { + hrefValue = value; + }, + }, + }); + + vi.mocked(WebguiUpdateCancel).mockResolvedValueOnce({ success: true }); + + await store.cancelUpdate(); + + expect(WebguiUpdateCancel).toHaveBeenCalled(); + expect(hrefValue).toBe('/Tools'); + + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + }); + + it('should throw an error when cancel update is unsuccessful', async () => { + const { WebguiUpdateCancel } = await import('~/composables/services/webgui'); + + vi.mocked(WebguiUpdateCancel).mockResolvedValueOnce({ success: false }); + + await expect(store.cancelUpdate()).rejects.toThrow('Unable to cancel update'); + + expect(WebguiUpdateCancel).toHaveBeenCalled(); + }); + + it('should throw an error when WebguiUpdateCancel fails', async () => { + const { WebguiUpdateCancel } = await import('~/composables/services/webgui'); + + vi.mocked(WebguiUpdateCancel).mockRejectedValueOnce(new Error('API error')); + + await expect(store.cancelUpdate()).rejects.toThrow( + '[cancelUpdate] Error cancelling update with error: API error' + ); + + expect(WebguiUpdateCancel).toHaveBeenCalled(); + }); + }); +}); diff --git a/web/__test__/store/updateOsActions.test.ts b/web/__test__/store/updateOsActions.test.ts new file mode 100644 index 000000000..8bdf0d03a --- /dev/null +++ b/web/__test__/store/updateOsActions.test.ts @@ -0,0 +1,422 @@ +/** + * UpdateOsActions store test coverage + */ + +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExternalUpdateOsAction } from '@unraid/shared-callbacks'; +import type { Release } from '~/store/updateOsActions'; + +import { useUpdateOsActionsStore } from '~/store/updateOsActions'; + +vi.mock('~/helpers/urls', () => ({ + WEBGUI_TOOLS_UPDATE: { + toString: () => 'https://webgui/tools/update', + }, +})); + +const mockUpdateOs = vi.fn(); +const mockInstall = vi.fn(); +const mockGetOsReleaseBySha256 = vi.fn(); +const mockAlert = vi.fn(); +const mockDocumentSubmit = vi.fn(); + +vi.stubGlobal('alert', mockAlert); + +const mockDocument = {}; +Object.defineProperty(mockDocument, 'rebootNow', { + value: { submit: mockDocumentSubmit }, + writable: true, +}); +vi.stubGlobal('document', mockDocument); +vi.stubGlobal('openChanges', vi.fn()); +vi.stubGlobal('openBox', vi.fn()); + +vi.mock('~/composables/installPlugin', () => ({ + default: () => ({ + install: mockInstall, + }), +})); + +vi.mock('~/composables/services/releases', () => ({ + getOsReleaseBySha256: (payload: unknown) => mockGetOsReleaseBySha256(payload), +})); + +vi.mock('~/store/account', () => ({ + useAccountStore: () => ({ + updateOs: mockUpdateOs, + }), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: () => ({ + guid: 'test-guid', + keyfile: 'test-keyfile', + osVersion: '6.12.4', + osVersionBranch: 'stable', + regUpdatesExpired: false, + rebootType: '', + }), +})); + +vi.mock('~/store/updateOs', () => ({ + useUpdateOsStore: () => ({ + available: '6.12.5', + }), +})); + +describe('UpdateOsActions Store', () => { + let store: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useUpdateOsActionsStore(); + vi.clearAllMocks(); + + // Suppress console output during tests + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('Basic functionality', () => { + it('should initialize with default values', () => { + expect(store.status).toBe('ready'); + expect(store.callbackTypeDowngrade).toBe(false); + expect(store.callbackUpdateRelease).toBeNull(); + }); + + it('should set update OS action', () => { + const action: ExternalUpdateOsAction = { + type: 'updateOs', + sha256: 'test-sha256', + }; + + store.setUpdateOsAction(action); + + mockGetOsReleaseBySha256.mockResolvedValue({ + version: '6.12.5', + name: 'Test Release', + plugin_url: 'https://example.com/plugin.plg', + }); + + return store.actOnUpdateOsAction().then(() => { + expect(mockGetOsReleaseBySha256).toHaveBeenCalledWith({ + keyfile: 'test-keyfile', + sha256: 'test-sha256', + }); + }); + }); + + it('should confirm update OS', () => { + const release: Release = { + version: '6.12.5', + name: 'Unraid 6.12.5', + basefile: 'unRAIDServer-6.12.5-x86_64.zip', + date: '2023-10-15', + url: 'https://example.com/download.zip', + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + md5: 'abc123', + size: '400000000', + sha256: 'test-sha256', + plugin_url: 'https://example.com/plugin.plg', + plugin_sha256: 'plugin-sha256', + announce_url: 'https://example.com/announce', + }; + + store.confirmUpdateOs(release); + + expect(store.callbackUpdateRelease).toEqual(release); + expect(store.status).toBe('confirming'); + }); + + it('should get release from key server', async () => { + const mockRelease: Release = { + version: '6.12.5', + name: 'Unraid 6.12.5', + basefile: 'unRAIDServer-6.12.5-x86_64.zip', + date: '2023-10-15', + url: 'https://example.com/download.zip', + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + md5: 'abc123', + size: '400000000', + sha256: 'test-sha256', + plugin_url: 'https://example.com/plugin.plg', + plugin_sha256: 'plugin-sha256', + announce_url: 'https://example.com/announce', + }; + + mockGetOsReleaseBySha256.mockResolvedValue(mockRelease); + + const result = await store.getReleaseFromKeyServer({ + keyfile: 'test-keyfile', + sha256: 'test-sha256', + }); + + expect(mockGetOsReleaseBySha256).toHaveBeenCalledWith({ + keyfile: 'test-keyfile', + sha256: 'test-sha256', + }); + expect(result).toEqual(mockRelease); + }); + + it('should throw error when getting release without keyfile', async () => { + await expect( + store.getReleaseFromKeyServer({ + keyfile: '', + sha256: 'test-sha256', + }) + ).rejects.toThrow('No payload.keyfile provided'); + }); + + it('should throw error when getting release without sha256', async () => { + await expect( + store.getReleaseFromKeyServer({ + keyfile: 'test-keyfile', + sha256: '', + }) + ).rejects.toThrow('No payload.sha256 provided'); + }); + + it('should throw error when getOsReleaseBySha256 fails', async () => { + mockGetOsReleaseBySha256.mockRejectedValue(new Error('API error')); + + await expect( + store.getReleaseFromKeyServer({ + keyfile: 'test-keyfile', + sha256: 'test-sha256', + }) + ).rejects.toThrow('Unable to get release from keyserver'); + }); + + it('should call actOnUpdateOsAction correctly for upgrade', async () => { + const mockRelease: Release = { + version: '6.12.5', + name: 'Unraid 6.12.5', + basefile: 'unRAIDServer-6.12.5-x86_64.zip', + date: '2023-10-15', + url: 'https://example.com/download.zip', + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + md5: 'abc123', + size: '400000000', + sha256: 'test-sha256', + plugin_url: 'https://example.com/plugin.plg', + plugin_sha256: 'plugin-sha256', + announce_url: 'https://example.com/announce', + }; + + mockGetOsReleaseBySha256.mockResolvedValue(mockRelease); + + // Set the update action first + store.setUpdateOsAction({ + type: 'updateOs', + sha256: 'test-sha256', + }); + + await store.actOnUpdateOsAction(); + + expect(mockGetOsReleaseBySha256).toHaveBeenCalledWith({ + keyfile: 'test-keyfile', + sha256: 'test-sha256', + }); + expect(store.callbackTypeDowngrade).toBe(false); + expect(store.callbackUpdateRelease).toEqual(mockRelease); + expect(store.status).toBe('confirming'); + }); + + it('should call actOnUpdateOsAction correctly for downgrade', async () => { + const mockRelease: Release = { + version: '6.12.3', + name: 'Unraid 6.12.3', + basefile: 'unRAIDServer-6.12.3-x86_64.zip', + date: '2023-05-15', + url: 'https://example.com/download.zip', + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + md5: 'abc123', + size: '400000000', + sha256: 'test-sha256', + plugin_url: 'https://example.com/plugin.plg', + plugin_sha256: 'plugin-sha256', + announce_url: 'https://example.com/announce', + }; + + mockGetOsReleaseBySha256.mockResolvedValue(mockRelease); + + store.setUpdateOsAction({ + type: 'updateOs', + sha256: 'test-sha256', + }); + + await store.actOnUpdateOsAction(true); + + expect(mockGetOsReleaseBySha256).toHaveBeenCalledWith({ + keyfile: 'test-keyfile', + sha256: 'test-sha256', + }); + expect(store.callbackTypeDowngrade).toBe(true); + expect(store.callbackUpdateRelease).toEqual(mockRelease); + expect(store.status).toBe('confirming'); + }); + + it('should throw error when release version matches current version', async () => { + const mockRelease: Release = { + version: '6.12.4', + name: 'Unraid 6.12.4', + basefile: 'unRAIDServer-6.12.4-x86_64.zip', + date: '2023-05-15', + url: 'https://example.com/download.zip', + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + md5: 'abc123', + size: '400000000', + sha256: 'test-sha256', + plugin_url: 'https://example.com/plugin.plg', + plugin_sha256: 'plugin-sha256', + announce_url: 'https://example.com/announce', + }; + + mockGetOsReleaseBySha256.mockResolvedValue(mockRelease); + + store.setUpdateOsAction({ + type: 'updateOs', + sha256: 'test-sha256', + }); + + await expect(store.actOnUpdateOsAction()).rejects.toThrow( + "Release version is the same as the server's current version" + ); + }); + + it('should throw error when release is not found', async () => { + mockGetOsReleaseBySha256.mockResolvedValue(null); + + store.setUpdateOsAction({ + type: 'updateOs', + sha256: 'test-sha256', + }); + + await expect(store.actOnUpdateOsAction()).rejects.toThrow('Release not found'); + }); + + it('should install OS update when update release is set', () => { + const release: Release = { + version: '6.12.5', + name: 'Unraid 6.12.5', + basefile: 'unRAIDServer-6.12.5-x86_64.zip', + date: '2023-10-15', + url: 'https://example.com/download.zip', + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + md5: 'abc123', + size: '400000000', + sha256: 'test-sha256', + plugin_url: 'https://example.com/plugin.plg', + plugin_sha256: 'plugin-sha256', + announce_url: 'https://example.com/announce', + }; + + store.confirmUpdateOs(release); + store.installOsUpdate(); + + expect(store.status).toBe('updating'); + expect(mockInstall).toHaveBeenCalledWith({ + modalTitle: 'Unraid 6.12.5 Update', + pluginUrl: 'https://example.com/plugin.plg', + update: false, + }); + }); + + it('should install OS downgrade when downgrade flag is set', () => { + const release: Release = { + version: '6.12.3', + name: 'Unraid 6.12.3', + basefile: 'unRAIDServer-6.12.3-x86_64.zip', + date: '2023-05-15', + url: 'https://example.com/download.zip', + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + md5: 'abc123', + size: '400000000', + sha256: 'test-sha256', + plugin_url: 'https://example.com/plugin.plg', + plugin_sha256: 'plugin-sha256', + announce_url: 'https://example.com/announce', + }; + + store.confirmUpdateOs(release); + store.callbackTypeDowngrade = true; + store.installOsUpdate(); + + expect(store.status).toBe('updating'); + expect(mockInstall).toHaveBeenCalledWith({ + modalTitle: 'Unraid 6.12.3 Downgrade', + pluginUrl: 'https://example.com/plugin.plg', + update: false, + }); + }); + + it('should log error when trying to install without release', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + store.installOsUpdate(); + + expect(consoleSpy).toHaveBeenCalledWith('[installOsUpdate] release not found'); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + it('should reboot server', () => { + store.rebootServer(); + + expect(mockDocumentSubmit).toHaveBeenCalled(); + }); + + it('should view release notes using openChanges when available', () => { + const openChangesSpy = vi.fn(); + vi.stubGlobal('openChanges', openChangesSpy); + + store.viewReleaseNotes('Test Release Notes'); + + expect(openChangesSpy).toHaveBeenCalledWith( + 'showchanges /var/tmp/unRAIDServer.txt', + 'Test Release Notes' + ); + }); + + it('should view release notes using openBox when openChanges not available', () => { + const openBoxSpy = vi.fn(); + vi.stubGlobal('openChanges', undefined); + vi.stubGlobal('openBox', openBoxSpy); + + store.viewReleaseNotes('Test Release Notes'); + + expect(openBoxSpy).toHaveBeenCalledWith( + '/plugins/dynamix.plugin.manager/include/ShowChanges.php?file=/var/tmp/unRAIDServer.txt', + 'Test Release Notes', + 600, + 900 + ); + }); + + it('should show alert when neither openChanges nor openBox are available', () => { + vi.stubGlobal('openChanges', undefined); + vi.stubGlobal('openBox', undefined); + + store.viewReleaseNotes('Test Release Notes'); + + expect(mockAlert).toHaveBeenCalledWith('Unable to open release notes'); + }); + + it('should set status', () => { + store.setStatus('checking'); + expect(store.status).toBe('checking'); + + store.setStatus('updating'); + expect(store.status).toBe('updating'); + }); + }); +}); diff --git a/web/__test__/store/updateOsChangelog.test.ts b/web/__test__/store/updateOsChangelog.test.ts new file mode 100644 index 000000000..8f3c8a37c --- /dev/null +++ b/web/__test__/store/updateOsChangelog.test.ts @@ -0,0 +1,197 @@ +/** + * UpdateOsChangelog store test coverage + */ + +import { nextTick } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ServerUpdateOsResponse } from '~/types/server'; + +import { useUpdateOsChangelogStore } from '~/store/updateOsChangelog'; + +vi.mock('~/helpers/markdown', () => ({ + Markdown: { + create: () => ({ + setOptions: vi.fn(), + parse: vi.fn().mockResolvedValue('

Test Title

Test content

'), + }), + }, +})); + +vi.mock('~/helpers/urls', () => ({ + DOCS_RELEASE_NOTES: { + toString: () => 'https://docs.unraid.net/unraid-os/release-notes/', + }, +})); + +vi.mock('marked-base-url', () => ({ + baseUrl: vi.fn().mockReturnValue(vi.fn()), +})); + +vi.mock('semver/functions/prerelease', () => ({ + default: vi.fn((version) => (version && version.includes('-') ? ['beta', '1'] : null)), +})); + +const mockRequestText = vi.fn().mockResolvedValue('# Test Changelog\n\nTest content'); +vi.mock('~/composables/services/request', () => ({ + request: { + url: () => ({ + get: () => ({ + text: mockRequestText, + }), + }), + }, +})); + +const mockSend = vi.fn(); +vi.mock('~/store/callbackActions', () => ({ + useCallbackActionsStore: () => ({ + send: mockSend, + }), +})); + +const mockStableRelease: Partial = { + version: '6.12.5', + name: 'Unraid 6.12.5', + date: '2023-10-15', + isEligible: true, + isNewer: true, + changelog: 'https://example.com/changelog.md', + changelogPretty: 'https://example.com/changelog', + sha256: 'test-sha256', +}; + +const mockBetaRelease: Partial = { + ...mockStableRelease, + version: '6.12.5-beta1', +}; + +describe('UpdateOsChangelog Store', () => { + let store: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useUpdateOsChangelogStore(); + vi.clearAllMocks(); + + // Suppress console output + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('Store API', () => { + it('should initialize with default values', () => { + expect(store.releaseForUpdate).toBeNull(); + expect(store.parseChangelogFailed).toBe(''); + }); + + it('should set and get releaseForUpdate', () => { + store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse); + expect(store.releaseForUpdate).toEqual(mockStableRelease); + + store.setReleaseForUpdate(null); + expect(store.releaseForUpdate).toBeNull(); + }); + + it('should determine if release is stable', () => { + expect(store.isReleaseForUpdateStable).toBe(false); + + store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse); + + expect(store.isReleaseForUpdateStable).toBe(true); + + store.setReleaseForUpdate(mockBetaRelease as ServerUpdateOsResponse); + expect(store.isReleaseForUpdateStable).toBe(false); + }); + + it('should have a method to fetch and confirm install', () => { + store.fetchAndConfirmInstall('test-sha256'); + + expect(mockSend).toHaveBeenCalledWith( + expect.any(String), + [ + { + sha256: 'test-sha256', + type: 'updateOs', + }, + ], + undefined, + 'forUpc' + ); + }); + + it('should have computed properties for changelog display', async () => { + store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse); + + expect(typeof store.mutatedParsedChangelog).toBe('string'); + expect(typeof store.parsedChangelogTitle).toBe('string'); + }); + + it('should clear changelog data when release is set to null', () => { + store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse); + + store.setReleaseForUpdate(null); + + expect(store.releaseForUpdate).toBeNull(); + expect(store.parseChangelogFailed).toBe(''); + }); + + it('should handle state transitions when changing releases', () => { + store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse); + + const differentRelease = { + ...mockStableRelease, + version: '6.12.6', + }; + store.setReleaseForUpdate(differentRelease as ServerUpdateOsResponse); + + expect(store.releaseForUpdate).toEqual(differentRelease); + }); + + it('should have proper error handling for failed requests', async () => { + mockRequestText.mockRejectedValueOnce(new Error('Network error')); + store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse); + + await nextTick(); + + expect(store.parseChangelogFailed).toBeTruthy(); + expect(store.parseChangelogFailed).toContain('error'); + }); + + it('should fetch and parse changelog when releaseForUpdate changes', async () => { + const internalStore = useUpdateOsChangelogStore(); + + vi.clearAllMocks(); + + internalStore.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse); + + await nextTick(); + + expect(mockRequestText).toHaveBeenCalled(); + + mockRequestText.mockClear(); + + const differentRelease = { + ...mockStableRelease, + version: '6.12.6', + changelog: 'https://example.com/different-changelog.md', + }; + + internalStore.setReleaseForUpdate(differentRelease as ServerUpdateOsResponse); + + await nextTick(); + + expect(mockRequestText).toHaveBeenCalled(); + + mockRequestText.mockClear(); + + internalStore.setReleaseForUpdate(null); + + await nextTick(); + + expect(mockRequestText).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/store/callbackActions.ts b/web/store/callbackActions.ts index 71546911a..ebcb633d0 100644 --- a/web/store/callbackActions.ts +++ b/web/store/callbackActions.ts @@ -179,5 +179,6 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => { // helpers sendType: 'fromUpc', encryptionKey: import.meta.env.VITE_CALLBACK_KEY, + callbackError, }; }); diff --git a/web/store/modal.ts b/web/store/modal.ts index f41a54b8e..8895fb8df 100644 --- a/web/store/modal.ts +++ b/web/store/modal.ts @@ -1,5 +1,6 @@ +import { ref } from 'vue'; +import { createPinia, defineStore, setActivePinia } from 'pinia'; import { useToggle } from '@vueuse/core'; -import { defineStore, createPinia, setActivePinia } from 'pinia'; /** * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components @@ -10,8 +11,12 @@ setActivePinia(createPinia()); export const useModalStore = defineStore('modal', () => { const modalVisible = ref(true); - const modalHide = () => { modalVisible.value = false; }; - const modalShow = () => { modalVisible.value = true; }; + const modalHide = () => { + modalVisible.value = false; + }; + const modalShow = () => { + modalVisible.value = true; + }; const modalToggle = useToggle(modalVisible); return { diff --git a/web/store/notifications.ts b/web/store/notifications.ts index 54359838b..c4463c152 100644 --- a/web/store/notifications.ts +++ b/web/store/notifications.ts @@ -1,4 +1,6 @@ -import { defineStore, createPinia, setActivePinia } from 'pinia'; +import { computed, ref } from 'vue'; +import { createPinia, defineStore, setActivePinia } from 'pinia'; + import type { NotificationFragmentFragment } from '~/composables/gql/graphql'; setActivePinia(createPinia()); @@ -7,9 +9,11 @@ export const useNotificationsStore = defineStore('notifications', () => { const notifications = ref([]); const isOpen = ref(false); - const title = computed(() => isOpen.value ? 'Notifications Are Open' : 'Notifications Are Closed'); + const title = computed(() => + isOpen.value ? 'Notifications Are Open' : 'Notifications Are Closed' + ); - const toggle = () => isOpen.value = !isOpen.value; + const toggle = () => (isOpen.value = !isOpen.value); const setNotifications = (newNotifications: NotificationFragmentFragment[]) => { notifications.value = newNotifications; diff --git a/web/store/purchase.ts b/web/store/purchase.ts index b346cd4cc..066309926 100644 --- a/web/store/purchase.ts +++ b/web/store/purchase.ts @@ -1,6 +1,8 @@ -import { defineStore, createPinia, setActivePinia } from 'pinia'; +import { computed } from 'vue'; +import { createPinia, defineStore, setActivePinia } from 'pinia'; import { PURCHASE_CALLBACK } from '~/helpers/urls'; + import { useCallbackActionsStore } from '~/store/callbackActions'; import { useServerStore } from '~/store/server'; @@ -21,66 +23,76 @@ export const usePurchaseStore = defineStore('purchase', () => { const activate = () => { callbackStore.send( PURCHASE_CALLBACK.toString(), - [{ - server: { - ...serverPurchasePayload.value, + [ + { + server: { + ...serverPurchasePayload.value, + }, + type: 'activate', }, - type: 'activate', - }], + ], inIframe.value ? 'newTab' : undefined, - sendType.value, + sendType.value ); }; const redeem = () => { callbackStore.send( PURCHASE_CALLBACK.toString(), - [{ - server: { - ...serverPurchasePayload.value, + [ + { + server: { + ...serverPurchasePayload.value, + }, + type: 'redeem', }, - type: 'redeem', - }], + ], inIframe.value ? 'newTab' : undefined, - sendType.value, + sendType.value ); }; const purchase = () => { callbackStore.send( PURCHASE_CALLBACK.toString(), - [{ - server: { - ...serverPurchasePayload.value, + [ + { + server: { + ...serverPurchasePayload.value, + }, + type: 'purchase', }, - type: 'purchase', - }], + ], inIframe.value ? 'newTab' : undefined, - sendType.value, + sendType.value ); }; const upgrade = () => { callbackStore.send( PURCHASE_CALLBACK.toString(), - [{ - server: { - ...serverPurchasePayload.value, + [ + { + server: { + ...serverPurchasePayload.value, + }, + type: 'upgrade', }, - type: 'upgrade', - }], + ], inIframe.value ? 'newTab' : undefined, - sendType.value, + sendType.value ); }; const renew = () => { callbackStore.send( PURCHASE_CALLBACK.toString(), - [{ - server: { - ...serverPurchasePayload.value, + [ + { + server: { + ...serverPurchasePayload.value, + }, + type: 'renew', }, - type: 'renew', - }], + ], inIframe.value ? 'newTab' : undefined, - sendType.value, + sendType.value ); }; diff --git a/web/store/replaceRenew.ts b/web/store/replaceRenew.ts index 908f34f2a..29be184be 100644 --- a/web/store/replaceRenew.ts +++ b/web/store/replaceRenew.ts @@ -4,7 +4,7 @@ * New key replacement, should happen also on server side. * Cron to run hourly, check on how many days are left until regExp…within X days then allow request to be done */ -import { h } from 'vue'; +import { computed, h, ref } from 'vue'; import { createPinia, defineStore, setActivePinia } from 'pinia'; import { @@ -263,5 +263,6 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => { purgeValidationResponse, setReplaceStatus, setRenewStatus, + error, }; }); diff --git a/web/store/theme.ts b/web/store/theme.ts index c7aeb0e8d..347882881 100644 --- a/web/store/theme.ts +++ b/web/store/theme.ts @@ -1,3 +1,4 @@ +import { computed, ref, watch } from 'vue'; import { createPinia, defineStore, setActivePinia } from 'pinia'; import { defaultColors } from '~/themes/default'; diff --git a/web/store/trial.ts b/web/store/trial.ts index 43c4f8f57..fd1c85bb4 100644 --- a/web/store/trial.ts +++ b/web/store/trial.ts @@ -1,17 +1,19 @@ -import { defineStore, createPinia, setActivePinia } from 'pinia'; +import { computed, ref, watch } from 'vue'; +import { createPinia, defineStore, setActivePinia } from 'pinia'; + +import type { ExternalPayload, TrialExtend, TrialStart } from '@unraid/shared-callbacks'; +import type { StartTrialResponse } from '~/composables/services/keyServer'; import { addPreventClose, removePreventClose } from '~/composables/preventClose'; -import { startTrial, type StartTrialResponse } from '~/composables/services/keyServer'; - +import { startTrial } from '~/composables/services/keyServer'; import { useCallbackActionsStore } from '~/store/callbackActions'; import { useDropdownStore } from '~/store/dropdown'; import { useServerStore } from '~/store/server'; -import type { ExternalPayload, TrialExtend, TrialStart } from '@unraid/shared-callbacks'; /** * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components * @see https://github.com/vuejs/pinia/discussions/1085 -*/ + */ setActivePinia(createPinia()); export const useTrialStore = defineStore('trial', () => { @@ -22,8 +24,15 @@ export const useTrialStore = defineStore('trial', () => { type TrialStatus = 'failed' | 'ready' | TrialExtend | TrialStart | 'success'; const trialStatus = ref('ready'); - const trialModalLoading = computed(() => trialStatus.value === 'trialExtend' || trialStatus.value === 'trialStart'); - const trialModalVisible = computed(() => trialStatus.value === 'failed' || trialStatus.value === 'trialExtend' || trialStatus.value === 'trialStart'); + const trialModalLoading = computed( + () => trialStatus.value === 'trialExtend' || trialStatus.value === 'trialStart' + ); + const trialModalVisible = computed( + () => + trialStatus.value === 'failed' || + trialStatus.value === 'trialExtend' || + trialStatus.value === 'trialStart' + ); const requestTrial = async (type?: TrialExtend | TrialStart) => { try { diff --git a/web/store/unraidApi.ts b/web/store/unraidApi.ts index 828da8ef5..c12ad8532 100644 --- a/web/store/unraidApi.ts +++ b/web/store/unraidApi.ts @@ -1,13 +1,17 @@ -import type { ApolloClient as ApolloClientType, NormalizedCacheObject } from '@apollo/client'; -import { ArrowPathIcon } from '@heroicons/vue/24/solid'; -import { WebguiUnraidApiCommand } from '~/composables/services/webgui'; -import { client } from '~/helpers/create-apollo-client'; -import { useErrorsStore } from '~/store/errors'; -import { useServerStore } from '~/store/server'; -import type { UserProfileLink } from '~/types/userProfile'; +import { computed, ref, watch } from 'vue'; // import { logErrorMessages } from '@vue/apollo-util'; import { createPinia, defineStore, setActivePinia } from 'pinia'; +import { ArrowPathIcon } from '@heroicons/vue/24/solid'; +import { client } from '~/helpers/create-apollo-client'; + +import type { ApolloClient as ApolloClientType, NormalizedCacheObject } from '@apollo/client'; +import type { UserProfileLink } from '~/types/userProfile'; + +import { WebguiUnraidApiCommand } from '~/composables/services/webgui'; +import { useErrorsStore } from '~/store/errors'; +import { useServerStore } from '~/store/server'; + /** * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components * @see https://github.com/vuejs/pinia/discussions/1085 diff --git a/web/store/unraidApiSettings.ts b/web/store/unraidApiSettings.ts index cccd44389..d21296072 100644 --- a/web/store/unraidApiSettings.ts +++ b/web/store/unraidApiSettings.ts @@ -1,51 +1,46 @@ -import { useLazyQuery, useMutation } from "@vue/apollo-composable"; -import type { SetupRemoteAccessInput } from "~/composables/gql/graphql"; +import { defineStore } from 'pinia'; +import { useLazyQuery, useMutation } from '@vue/apollo-composable'; + +import type { SetupRemoteAccessInput } from '~/composables/gql/graphql'; + import { GET_ALLOWED_ORIGINS, GET_REMOTE_ACCESS, - SETUP_REMOTE_ACCESS, SET_ADDITIONAL_ALLOWED_ORIGINS, -} from "~/store/unraidApiSettings.fragment"; + SETUP_REMOTE_ACCESS, +} from '~/store/unraidApiSettings.fragment'; -export const useUnraidApiSettingsStore = defineStore( - "unraidApiSettings", - () => { - const { load: loadOrigins, result: origins } = - useLazyQuery(GET_ALLOWED_ORIGINS); +export const useUnraidApiSettingsStore = defineStore('unraidApiSettings', () => { + const { load: loadOrigins, result: origins } = useLazyQuery(GET_ALLOWED_ORIGINS); - const { mutate: mutateOrigins } = useMutation( - SET_ADDITIONAL_ALLOWED_ORIGINS - ); - const { load: loadRemoteAccess, result: remoteAccessResult } = - useLazyQuery(GET_REMOTE_ACCESS); + const { mutate: mutateOrigins } = useMutation(SET_ADDITIONAL_ALLOWED_ORIGINS); + const { load: loadRemoteAccess, result: remoteAccessResult } = useLazyQuery(GET_REMOTE_ACCESS); - const { mutate: setupRemoteAccessMutation } = - useMutation(SETUP_REMOTE_ACCESS); - const getAllowedOrigins = async () => { - await loadOrigins(); - return origins?.value?.extraAllowedOrigins ?? []; - }; + const { mutate: setupRemoteAccessMutation } = useMutation(SETUP_REMOTE_ACCESS); + const getAllowedOrigins = async () => { + await loadOrigins(); + return origins?.value?.extraAllowedOrigins ?? []; + }; - const setAllowedOrigins = async (origins: string[]) => { - const result = await mutateOrigins({ input: { origins } }); - return result?.data?.setAdditionalAllowedOrigins; - }; + const setAllowedOrigins = async (origins: string[]) => { + const result = await mutateOrigins({ input: { origins } }); + return result?.data?.setAdditionalAllowedOrigins; + }; - const getRemoteAccess = async () => { - await loadRemoteAccess(); - return remoteAccessResult?.value?.remoteAccess; - }; + const getRemoteAccess = async () => { + await loadRemoteAccess(); + return remoteAccessResult?.value?.remoteAccess; + }; - const setupRemoteAccess = async (input: SetupRemoteAccessInput) => { - const response = await setupRemoteAccessMutation({ input }); - return response?.data?.setupRemoteAccess; - }; + const setupRemoteAccess = async (input: SetupRemoteAccessInput) => { + const response = await setupRemoteAccessMutation({ input }); + return response?.data?.setupRemoteAccess; + }; - return { - getAllowedOrigins, - setAllowedOrigins, - getRemoteAccess, - setupRemoteAccess, - }; - } -); + return { + getAllowedOrigins, + setAllowedOrigins, + getRemoteAccess, + setupRemoteAccess, + }; +}); diff --git a/web/store/updateOsActions.ts b/web/store/updateOsActions.ts index 6b8119794..d3afffc55 100644 --- a/web/store/updateOsActions.ts +++ b/web/store/updateOsActions.ts @@ -1,19 +1,20 @@ +import { computed, ref, watchEffect } from 'vue'; +import { createPinia, defineStore, setActivePinia } from 'pinia'; + import { ArrowPathIcon, BellAlertIcon } from '@heroicons/vue/24/solid'; -import { defineStore, createPinia, setActivePinia } from 'pinia'; - -import useInstallPlugin from '~/composables/installPlugin'; -import { getOsReleaseBySha256, type GetOsReleaseBySha256Payload } from '~/composables/services/releases'; - import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls'; +import type { ExternalUpdateOsAction } from '@unraid/shared-callbacks'; +import type { GetOsReleaseBySha256Payload } from '~/composables/services/releases'; +import type { UserProfileLink } from '~/types/userProfile'; + +import useInstallPlugin from '~/composables/installPlugin'; +import { getOsReleaseBySha256 } from '~/composables/services/releases'; import { useAccountStore } from '~/store/account'; // import { useErrorsStore } from '~/store/errors'; import { useServerStore } from '~/store/server'; import { useUpdateOsStore } from '~/store/updateOs'; -import type { ExternalUpdateOsAction } from '@unraid/shared-callbacks'; -import type { UserProfileLink } from '~/types/userProfile'; - /** * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components * @see https://github.com/vuejs/pinia/discussions/1085 @@ -56,7 +57,16 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { const updateOsAvailable = computed(() => updateOsStore.available); /** used when coming back from callback, this will be the release to install */ - const status = ref<'confirming' | 'checking' | 'ineligible' | 'failed' | 'ready' | 'success' | 'updating' | 'downgrading'>('ready'); + const status = ref< + | 'confirming' + | 'checking' + | 'ineligible' + | 'failed' + | 'ready' + | 'success' + | 'updating' + | 'downgrading' + >('ready'); const callbackTypeDowngrade = ref(false); const callbackUpdateRelease = ref(null); const rebootType = computed(() => serverStore.rebootType); @@ -74,8 +84,11 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { } }); - const ineligible = computed(() => !guid.value || !keyfile.value || !osVersion.value || regUpdatesExpired.value); - const ineligibleText = computed(() => { // translated in components + const ineligible = computed( + () => !guid.value || !keyfile.value || !osVersion.value || regUpdatesExpired.value + ); + const ineligibleText = computed(() => { + // translated in components if (!guid.value) { return 'A valid GUID is required to check for OS updates.'; } @@ -86,8 +99,10 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { return 'A valid OS version is required to check for OS updates.'; } if (regUpdatesExpired.value) { - const base = 'Your {0} license included one year of free updates at the time of purchase. You are now eligible to extend your license and access the latest OS updates.'; - const addtlText = 'You are still eligible to access OS updates that were published on or before {1}.'; + const base = + 'Your {0} license included one year of free updates at the time of purchase. You are now eligible to extend your license and access the latest OS updates.'; + const addtlText = + 'You are still eligible to access OS updates that were published on or before {1}.'; return updateOsAvailable.value ? `${base} ${addtlText}` : base; } return ''; @@ -121,7 +136,8 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { }; }; - const setUpdateOsAction = (payload: ExternalUpdateOsAction | undefined) => (updateAction.value = payload); + const setUpdateOsAction = (payload: ExternalUpdateOsAction | undefined) => + (updateAction.value = payload); /** * @description When receiving the callback the Account update page we'll use the provided sha256 of the release to get the release from the keyserver */ @@ -164,7 +180,7 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { throw new Error('Release not found'); } if (foundRelease.version === osVersion.value) { - throw new Error('Release version is the same as the server\'s current version'); + throw new Error("Release version is the same as the server's current version"); } confirmUpdateOs(foundRelease); }; @@ -176,7 +192,9 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { setStatus('updating'); installPlugin({ - modalTitle: callbackTypeDowngrade.value ? `${callbackUpdateRelease.value.name} Downgrade` : `${callbackUpdateRelease.value.name} Update`, + modalTitle: callbackTypeDowngrade.value + ? `${callbackUpdateRelease.value.name} Downgrade` + : `${callbackUpdateRelease.value.name} Update`, pluginUrl: callbackUpdateRelease.value.plugin_url, update: false, }); @@ -189,7 +207,7 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { /** * By default this will display current version's release notes */ - const viewReleaseNotes = (modalTitle:string, webguiFilePath?:string|undefined) => { + const viewReleaseNotes = (modalTitle: string, webguiFilePath?: string | undefined) => { // @ts-expect-error • global set in the webgui if (typeof openChanges === 'function') { // @ts-expect-error • global set in the webgui @@ -197,7 +215,12 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => { // @ts-expect-error • global set in the webgui } else if (typeof openBox === 'function') { // @ts-expect-error • global set in the webgui - openBox(`/plugins/dynamix.plugin.manager/include/ShowChanges.php?file=${webguiFilePath ?? '/var/tmp/unRAIDServer.txt'}`, modalTitle, 600, 900); + openBox( + `/plugins/dynamix.plugin.manager/include/ShowChanges.php?file=${webguiFilePath ?? '/var/tmp/unRAIDServer.txt'}`, + modalTitle, + 600, + 900 + ); } else { alert('Unable to open release notes'); }