test: create tests for stores batch 3 (#1358)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mdatelle <mike@datelle.net>
This commit is contained in:
Michael Datelle
2025-04-16 17:06:52 -04:00
committed by GitHub
parent 1236b7743e
commit 72860e71fe
25 changed files with 2629 additions and 140 deletions

View File

@@ -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<string, string>();
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<typeof import('pinia')>('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<QueryPayloads | null>(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);
});
});
});

View File

@@ -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<typeof useCallbackActionsStore>;
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', () => {

View File

@@ -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();
});
});
});

View File

@@ -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 () => {

View File

@@ -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<typeof useModalStore>;
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);
});
});
});

View File

@@ -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<typeof useNotificationsStore>;
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');
});
});
});

View File

@@ -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<typeof usePurchaseStore>;
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'
);
});
});
});

View File

@@ -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<typeof useReplaceRenewStore>;
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<typeof useServerStore>);
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<typeof useServerStore>);
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<typeof useServerStore>);
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);
});
});
});
});

View File

@@ -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<typeof useThemeStore>;
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)');
});
});
});

View File

@@ -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<typeof useTrialStore>;
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();
});
});
});

View File

@@ -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<typeof useUnraidApiStore>;
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',
});
});
});
});

View File

@@ -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<typeof useUnraidApiSettingsStore>;
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);
});
});
});

View File

@@ -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<typeof useUpdateOsStore>;
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();
});
});
});

View File

@@ -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<typeof useUpdateOsActionsStore>;
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');
});
});
});

View File

@@ -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('<h1>Test Title</h1><p>Test content</p>'),
}),
},
}));
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<ServerUpdateOsResponse> = {
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<ServerUpdateOsResponse> = {
...mockStableRelease,
version: '6.12.5-beta1',
};
describe('UpdateOsChangelog Store', () => {
let store: ReturnType<typeof useUpdateOsChangelogStore>;
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();
});
});
});

View File

@@ -179,5 +179,6 @@ export const useCallbackActionsStore = defineStore('callbackActions', () => {
// helpers
sendType: 'fromUpc',
encryptionKey: import.meta.env.VITE_CALLBACK_KEY,
callbackError,
};
});

View File

@@ -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<boolean>(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 {

View File

@@ -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<NotificationFragmentFragment[]>([]);
const isOpen = ref<boolean>(false);
const title = computed<string>(() => isOpen.value ? 'Notifications Are Open' : 'Notifications Are Closed');
const title = computed<string>(() =>
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;

View File

@@ -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
);
};

View File

@@ -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,
};
});

View File

@@ -1,3 +1,4 @@
import { computed, ref, watch } from 'vue';
import { createPinia, defineStore, setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';

View File

@@ -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<TrialStatus>('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 {

View File

@@ -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

View File

@@ -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,
};
});

View File

@@ -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<boolean>(false);
const callbackUpdateRelease = ref<Release | null>(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');
}