test: create tests for stores batch 2 (#1351)

This is batch 2 of the web store tests. I started implementing the
recommended approach in the Pinia docs for the store tests and was able
to eliminate most of the mocking files. The server.test.ts file still
uses `pinia/testing` for now since I was having trouble with some of the
dependencies in the store due to it's complexity.

I also updated the `web-testing-rules`for Cursor in an effort to
streamline the AI's approach when helping with tests. There's some
things it still struggles with and seems like it doesn't always take the
rules into consideration until after it hits a snag. It likes to try and
create a mock for the store it's actually testing even though there's a
rule in place and has to be reminded.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **Tests**
- Added comprehensive test suites for account management and activation
flows to ensure smoother user interactions.
- Introduced new test coverage for callback actions, validating state
management and action handling.
- Added a new test file for the account store, covering various actions
and their interactions.
- Introduced a new test file for the activation code store, verifying
state management and modal visibility.
- Established a new test file for dropdown functionality, ensuring
accurate state management and action methods.
- Added a new test suite for the Errors store, covering error handling
and modal interactions.
- Enhanced test guidelines for Vue components and Pinia stores,
providing clearer documentation and best practices.
- Introduced a new test file for the InstallKey store, validating key
installation processes and error handling.
- Added a new test file for the dropdown store, ensuring accurate
visibility state management and action methods.

- **Refactor**
- Streamlined the structure of account action payloads for consistent
behavior.
- Improved code formatting and reactivity setups to enhance overall
operational stability.
- Enhanced reactivity capabilities in various stores by introducing new
Vue composition API functions.
- Improved code readability and organization in the installKey store and
other related files.
<!-- 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-14 12:30:27 -04:00
committed by GitHub
parent 45ecab6914
commit d74d9f1246
24 changed files with 1707 additions and 260 deletions

View File

@@ -1,47 +0,0 @@
import { provideApolloClient } from '@vue/apollo-composable';
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client/core';
// Types for Apollo Client options
interface TestApolloClientOptions {
uri?: string;
mockData?: Record<string, unknown>;
}
// Single function to create Apollo clients
function createClient(options: TestApolloClientOptions = {}) {
const { uri = 'http://localhost/graphql', mockData = { data: {} } } = options;
return new ApolloClient({
link: createHttpLink({
uri,
credentials: 'include',
fetch: () => Promise.resolve(new Response(JSON.stringify(mockData))),
}),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: 'no-cache',
},
},
});
}
// Default mock client
export const mockApolloClient = createClient();
// Helper function to provide the mock client
export function provideMockApolloClient() {
provideApolloClient(mockApolloClient);
return mockApolloClient;
}
// Create a customizable Apollo Client
export function createTestApolloClient(options: TestApolloClientOptions = {}) {
const client = createClient(options);
provideApolloClient(client);
return client;
}

View File

@@ -1 +0,0 @@
import './request';

View File

@@ -1,15 +0,0 @@
import { vi } from 'vitest';
// Mock any composables that might cause hanging
vi.mock('~/composables/services/request', () => {
return {
useRequest: vi.fn(() => ({
post: vi.fn(() => Promise.resolve({ data: {} })),
get: vi.fn(() => Promise.resolve({ data: {} })),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
})),
};
});

View File

@@ -1,43 +0,0 @@
import { vi } from 'vitest';
import type { SendPayloads } from '@unraid/shared-callbacks';
// Mock shared callbacks
vi.mock('@unraid/shared-callbacks', () => ({
default: {
encrypt: (data: string) => data,
decrypt: (data: string) => data,
},
}));
// Mock implementation of the shared-callbacks module
export const mockSharedCallbacks = {
encrypt: (data: string, _key: string) => {
return data; // Simple mock that returns the input data
},
decrypt: (data: string, _key: string) => {
return data; // Simple mock that returns the input data
},
useCallback: ({ encryptionKey: _encryptionKey }: { encryptionKey: string }) => {
return {
send: (_payload: SendPayloads) => {
return Promise.resolve();
},
watcher: () => {
return null;
},
};
},
};
// Mock the crypto-js/aes module
vi.mock('crypto-js/aes.js', () => ({
default: {
encrypt: (data: string, _key: string) => {
return { toString: () => data };
},
decrypt: (data: string, _key: string) => {
return { toString: () => data };
},
},
}));

View File

@@ -1,13 +0,0 @@
import { vi } from 'vitest';
// Mock specific problematic stores
vi.mock('~/store/errors', () => {
const useErrorsStore = vi.fn(() => ({
errors: { value: [] },
addError: vi.fn(),
clearErrors: vi.fn(),
removeError: vi.fn(),
}));
return { useErrorsStore };
});

View File

@@ -1,2 +0,0 @@
import './errors';
import './server';

View File

@@ -1,16 +0,0 @@
import { vi } from 'vitest';
// Mock the server store which is used by Auth component
vi.mock('~/store/server', () => {
return {
useServerStore: vi.fn(() => ({
authAction: 'authenticate',
stateData: { error: false, message: '' },
authToken: 'mock-token',
isAuthenticated: true,
authenticate: vi.fn(() => Promise.resolve()),
logout: vi.fn(),
resetAuth: vi.fn(),
})),
};
});

View File

@@ -1,13 +1,10 @@
import { config } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { afterAll, beforeAll, vi } from 'vitest';
import { vi } from 'vitest';
// Import mocks
import './mocks/shared-callbacks.js';
import './mocks/ui-components.js';
import './mocks/stores/index.js';
import './mocks/services/index.js';
// Configure Vue Test Utils
config.global.plugins = [
@@ -34,7 +31,3 @@ globalThis.fetch = vi.fn(() =>
text: () => Promise.resolve(''),
} as Response)
);
// Global setup and cleanup
beforeAll(() => {});
afterAll(() => {});

View File

@@ -0,0 +1,482 @@
/**
* Account store test coverage
*/
import { nextTick, ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { useMutation } from '@vue/apollo-composable';
import { ApolloError } from '@apollo/client/core';
import { ACCOUNT_CALLBACK } from '~/helpers/urls';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalSignIn, ExternalSignOut } from '@unraid/shared-callbacks';
import type { ConnectSignInMutationPayload } from '~/store/account';
import type { Ref } from 'vue';
import { useAccountStore } from '~/store/account';
const mockUseMutation = vi.fn(() => {
let onDoneCallback: ((response: { data: unknown }) => void) | null = null;
return {
mutate: vi.fn().mockImplementation(() => {
onDoneCallback?.({ data: {} });
return Promise.resolve({ data: {} });
}),
onDone: (callback: (response: { data: unknown }) => void) => {
onDoneCallback = callback;
return { off: vi.fn() };
},
onError: (_: (error: ApolloError) => void) => ({ off: vi.fn() }),
loading: ref(false),
error: ref(null) as Ref<null>,
called: ref(false),
};
});
const mockSend = vi.fn();
const mockPurge = vi.fn();
const mockSetError = vi.fn();
vi.mock('~/store/callbackActions', () => ({
useCallbackActionsStore: () => ({
send: mockSend,
sendType: 'post',
}),
}));
vi.mock('~/store/errors', () => ({
useErrorsStore: () => ({
setError: mockSetError,
}),
}));
vi.mock('~/store/replaceRenew', () => ({
useReplaceRenewStore: () => ({
purgeValidationResponse: mockPurge,
}),
}));
vi.mock('~/store/server', () => ({
useServerStore: () => ({
serverAccountPayload: {
guid: 'test-guid',
name: 'test-server',
},
inIframe: false,
}),
}));
vi.mock('~/store/unraidApi', () => ({
useUnraidApiStore: () => ({
unraidApiClient: ref(true),
}),
}));
describe('Account Store', () => {
let store: ReturnType<typeof useAccountStore>;
beforeEach(() => {
setActivePinia(createPinia());
store = useAccountStore();
vi.mocked(useMutation);
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('Actions', () => {
it('should call manage action correctly', () => {
store.manage();
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'manage' }],
undefined,
'post'
);
});
it('should call myKeys action correctly', async () => {
await store.myKeys();
expect(mockPurge).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'myKeys' }],
undefined,
'post'
);
});
it('should call linkKey action correctly', async () => {
await store.linkKey();
expect(mockPurge).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'linkKey' }],
undefined,
'post'
);
});
it('should call recover action correctly', () => {
store.recover();
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'recover' }],
undefined,
'post'
);
});
it('should call signIn action correctly', () => {
store.signIn();
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'signIn' }],
undefined,
'post'
);
});
it('should call signOut action correctly', () => {
store.signOut();
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'signOut' }],
undefined,
'post'
);
});
it('should handle downgradeOs action with and without redirect', async () => {
await store.downgradeOs();
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'downgradeOs' }],
undefined,
'post'
);
await store.downgradeOs(true);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'downgradeOs' }],
'replace',
'post'
);
});
it('should handle updateOs action with and without redirect', async () => {
await store.updateOs();
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'updateOs' }],
undefined,
'post'
);
await store.updateOs(true);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'updateOs' }],
'replace',
'post'
);
});
it('should call replace action correctly', () => {
store.replace();
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'replace' }],
undefined,
'post'
);
});
it('should call trialExtend action correctly', () => {
store.trialExtend();
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'trialExtend' }],
undefined,
'post'
);
});
it('should call trialStart action correctly', () => {
store.trialStart();
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(
ACCOUNT_CALLBACK.toString(),
[{ server: { guid: 'test-guid', name: 'test-server' }, type: 'trialStart' }],
undefined,
'post'
);
});
});
describe('State Management', () => {
const originalConsoleDebug = console.debug;
beforeEach(() => {
console.debug = vi.fn();
});
afterEach(() => {
console.debug = originalConsoleDebug;
});
it('should set account actions and payloads', () => {
const signInAction: ExternalSignIn = {
type: 'signIn',
apiKey: 'test-api-key',
user: {
email: 'test@example.com',
preferred_username: 'test-user',
},
};
const signOutAction: ExternalSignOut = {
type: 'signOut',
};
store.setAccountAction(signInAction);
store.setConnectSignInPayload({
apiKey: signInAction.apiKey,
email: signInAction.user.email as string,
preferred_username: signInAction.user.preferred_username as string,
});
expect(store.accountAction).toEqual(signInAction);
expect(store.accountActionStatus).toBe('waiting');
store.setAccountAction(signOutAction);
store.setQueueConnectSignOut(true);
expect(store.accountAction).toEqual(signOutAction);
expect(store.accountActionStatus).toBe('waiting');
});
});
describe('Apollo Mutations', () => {
const originalConsoleDebug = console.debug;
beforeEach(() => {
console.debug = vi.fn();
});
afterEach(() => {
console.debug = originalConsoleDebug;
});
it('should handle connectSignInMutation success', async () => {
const store = useAccountStore();
const accountActionStatus = ref('ready');
const typedStore = {
...store,
accountActionStatus,
setConnectSignInPayload: (payload: ConnectSignInMutationPayload) => {
store.setConnectSignInPayload(payload);
accountActionStatus.value = 'waiting';
},
setQueueConnectSignOut: (value: boolean) => {
store.setQueueConnectSignOut(value);
accountActionStatus.value = 'waiting';
},
connectSignInMutation: async () => {
accountActionStatus.value = 'updating';
const mockMutation = mockUseMutation();
await mockMutation.mutate();
accountActionStatus.value = 'success';
return mockMutation;
},
connectSignOutMutation: async () => {
accountActionStatus.value = 'updating';
const mockMutation = mockUseMutation();
await mockMutation.mutate();
accountActionStatus.value = 'success';
return mockMutation;
},
};
typedStore.setAccountAction('signIn' as unknown as ExternalSignIn);
typedStore.setConnectSignInPayload({
apiKey: 'test-api-key',
email: 'test@example.com',
preferred_username: 'test-user',
});
expect(accountActionStatus.value).toBe('waiting');
await typedStore.connectSignInMutation();
await nextTick();
expect(accountActionStatus.value).toBe('success');
});
it('should handle connectSignOutMutation success', async () => {
const store = useAccountStore();
const accountActionStatus = ref('ready');
const typedStore = {
...store,
accountActionStatus,
setConnectSignInPayload: (payload: ConnectSignInMutationPayload) => {
store.setConnectSignInPayload(payload);
accountActionStatus.value = 'waiting';
},
setQueueConnectSignOut: (value: boolean) => {
store.setQueueConnectSignOut(value);
accountActionStatus.value = 'waiting';
},
connectSignInMutation: async () => {
accountActionStatus.value = 'updating';
const mockMutation = mockUseMutation();
await mockMutation.mutate();
accountActionStatus.value = 'success';
return mockMutation;
},
connectSignOutMutation: async () => {
accountActionStatus.value = 'updating';
const mockMutation = mockUseMutation();
await mockMutation.mutate();
accountActionStatus.value = 'success';
return mockMutation;
},
};
typedStore.setAccountAction('signOut' as unknown as ExternalSignOut);
typedStore.setQueueConnectSignOut(true);
expect(accountActionStatus.value).toBe('waiting');
await typedStore.connectSignOutMutation();
await nextTick();
expect(accountActionStatus.value).toBe('success');
});
it('should handle mutation errors', async () => {
const store = useAccountStore();
const accountActionStatus = ref('ready');
const mockError = new ApolloError({
graphQLErrors: [{ message: 'Test error' }],
});
// Mock the mutation to trigger error
mockUseMutation.mockImplementationOnce(() => ({
mutate: vi.fn().mockRejectedValue(mockError),
onDone: () => ({ off: vi.fn() }),
onError: (callback: (error: ApolloError) => void) => {
callback(mockError);
accountActionStatus.value = 'failed';
return { off: vi.fn() };
},
loading: ref(false) as Ref<boolean>,
error: ref(null) as Ref<null>,
called: ref(true) as Ref<boolean>,
}));
const typedStore = {
...store,
accountActionStatus,
setConnectSignInPayload: (payload: ConnectSignInMutationPayload) => {
store.setConnectSignInPayload(payload);
accountActionStatus.value = 'waiting';
},
setQueueConnectSignOut: (value: boolean) => {
store.setQueueConnectSignOut(value);
accountActionStatus.value = 'waiting';
},
connectSignInMutation: async () => {
accountActionStatus.value = 'updating';
const mockMutation = mockUseMutation();
try {
await mockMutation.mutate().catch(() => {
accountActionStatus.value = 'failed';
mockSetError({
heading: 'unraid-api failed to update Connect account configuration',
message: 'Test error',
level: 'error',
ref: 'connectSignInMutation',
type: 'account',
});
throw new Error('Test error');
});
} catch {
// Expected error - intentionally empty
}
return mockMutation;
},
connectSignOutMutation: async () => {
accountActionStatus.value = 'updating';
const mockMutation = mockUseMutation();
await mockMutation.mutate();
return mockMutation;
},
};
typedStore.setAccountAction('signIn' as unknown as ExternalSignIn);
typedStore.setConnectSignInPayload({
apiKey: 'test-api-key',
email: 'test@example.com',
preferred_username: 'test-user',
});
expect(accountActionStatus.value).toBe('waiting');
await typedStore.connectSignInMutation();
await nextTick();
expect(accountActionStatus.value).toBe('failed');
expect(mockSetError).toHaveBeenCalledWith({
heading: 'unraid-api failed to update Connect account configuration',
message: 'Test error',
level: 'error',
ref: 'connectSignInMutation',
type: 'account',
});
});
});
});

View File

@@ -0,0 +1,134 @@
/**
* Activation code store test coverage
*/
import { nextTick, ref } from 'vue';
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 { useActivationCodeStore } from '~/store/activationCode';
// Mock console methods to suppress output
const originalConsoleDebug = console.debug;
const originalConsoleError = console.error;
beforeAll(() => {
console.debug = vi.fn();
console.error = vi.fn();
});
afterAll(() => {
console.debug = originalConsoleDebug;
console.error = originalConsoleError;
});
// Mock sessionStorage
const mockStorage = new Map<string, string>();
vi.stubGlobal('sessionStorage', {
getItem: (key: string) => mockStorage.get(key) ?? null,
setItem: (key: string, value: string) => mockStorage.set(key, value),
removeItem: (key: string) => mockStorage.delete(key),
clear: () => mockStorage.clear(),
});
// Mock dependencies
vi.mock('pinia', async () => {
const mod = await vi.importActual<typeof import('pinia')>('pinia');
return {
...mod,
storeToRefs: () => ({
state: ref('ENOKEYFILE'),
callbackData: ref(null),
}),
};
});
vi.mock('~/store/server', () => ({
useServerStore: () => ({
state: 'ENOKEYFILE',
}),
}));
vi.mock('~/store/callbackActions', () => ({
useCallbackActionsStore: () => ({
callbackData: null,
}),
}));
describe('Activation Code Store', () => {
let store: ReturnType<typeof useActivationCodeStore>;
beforeEach(() => {
setActivePinia(createPinia());
store = useActivationCodeStore();
vi.clearAllMocks();
mockStorage.clear();
});
describe('State and Actions', () => {
const mockData = {
code: 'TEST123',
partnerName: 'Test Partner',
partnerUrl: 'https://test.com',
partnerLogo: true,
};
it('should initialize with null data', () => {
expect(store.code).toBeNull();
expect(store.partnerName).toBeNull();
expect(store.partnerUrl).toBeNull();
expect(store.partnerLogo).toBeNull();
});
it('should set data correctly', () => {
store.setData(mockData);
expect(store.code).toBe('TEST123');
expect(store.partnerName).toBe('Test Partner');
expect(store.partnerUrl).toBe('https://test.com');
expect(store.partnerLogo).toBe('/webGui/images/partner-logo.svg');
});
it('should handle data without optional fields', () => {
store.setData({ code: 'TEST123' });
expect(store.code).toBe('TEST123');
expect(store.partnerName).toBeNull();
expect(store.partnerUrl).toBeNull();
expect(store.partnerLogo).toBeNull();
});
});
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', () => {
expect(store.showActivationModal).toBe(false);
});
it('should handle modal visibility state in session storage', async () => {
store.setData({ code: 'TEST123' });
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();
});
});
});

View File

@@ -0,0 +1,397 @@
/**
* Callback actions store test coverage
*/
import { nextTick, ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalSignIn, ExternalUpdateOsAction, QueryPayloads } from '@unraid/shared-callbacks';
import type { Mock } from 'vitest';
import { useAccountStore } from '~/store/account';
import { useCallbackActionsStore } from '~/store/callbackActions';
import { useServerStore } from '~/store/server';
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
// Mock console methods to suppress output
const originalConsoleDebug = console.debug;
const originalConsoleError = console.error;
beforeAll(() => {
console.debug = vi.fn();
console.error = vi.fn();
});
afterAll(() => {
console.debug = originalConsoleDebug;
console.error = originalConsoleError;
});
// Mock modules using factory functions to avoid hoisting issues
vi.mock('@unraid/shared-callbacks', () => {
const mockWatcher = vi.fn();
const mockSend = vi.fn();
return {
useCallback: vi.fn(({ encryptionKey: _encryptionKey }) => ({
send: mockSend,
watcher: mockWatcher,
parse: vi.fn(),
})),
};
});
vi.mock('~/composables/preventClose', () => {
const addPreventClose = vi.fn();
const removePreventClose = vi.fn();
return {
addPreventClose,
removePreventClose,
};
});
vi.mock('~/store/account', () => {
const setAccountAction = vi.fn();
const setConnectSignInPayload = vi.fn();
const setQueueConnectSignOut = vi.fn();
return {
useAccountStore: vi.fn(() => ({
$state: {},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
setAccountAction,
setConnectSignInPayload,
setQueueConnectSignOut,
accountActionStatus: ref('success'),
})),
};
});
vi.mock('~/store/installKey', () => {
const install = vi.fn();
return {
useInstallKeyStore: vi.fn(() => ({
$state: {},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
install,
keyInstallStatus: ref('success'),
})),
};
});
vi.mock('~/store/server', () => {
const refreshServerState = vi.fn();
return {
useServerStore: vi.fn(() => ({
$state: {},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
refreshServerState,
refreshServerStateStatus: ref('done'),
})),
};
});
vi.mock('~/store/updateOsActions', () => {
const setUpdateOsAction = vi.fn();
const actOnUpdateOsAction = vi.fn();
return {
useUpdateOsActionsStore: vi.fn(() => ({
$state: {},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
setUpdateOsAction,
actOnUpdateOsAction,
})),
};
});
describe('Callback Actions Store', () => {
let store: ReturnType<typeof useCallbackActionsStore>;
let preventClose: { addPreventClose: Mock; removePreventClose: Mock };
let mockWatcher: Mock;
beforeEach(async () => {
setActivePinia(createPinia());
store = useCallbackActionsStore();
const preventCloseModule = await import('~/composables/preventClose');
preventClose = {
addPreventClose: vi.mocked(preventCloseModule.addPreventClose),
removePreventClose: vi.mocked(preventCloseModule.removePreventClose),
};
const { useCallback } = await import('@unraid/shared-callbacks');
mockWatcher = vi.mocked(useCallback).mock.results[0].value.watcher;
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('Initial State', () => {
it('should initialize with default values', () => {
expect(store.callbackStatus).toBe('ready');
expect(store.callbackData).toBeUndefined();
expect(store.sendType).toBe('fromUpc');
expect(store.encryptionKey).toBe(import.meta.env.VITE_CALLBACK_KEY);
});
});
describe('Watcher Functionality', () => {
it('should call saveCallbackData when watcher returns data', async () => {
const mockData: QueryPayloads = {
type: 'forUpc',
actions: [
{
type: 'signIn',
user: { email: 'test@example.com', preferred_username: 'test' },
apiKey: 'test-key',
} as ExternalSignIn,
],
sender: 'test',
};
mockWatcher.mockReturnValue(mockData);
store.watcher();
expect(mockWatcher).toHaveBeenCalled();
expect(store.callbackData).toEqual(mockData);
expect(store.callbackStatus).toBe('loading');
});
it('should not call saveCallbackData when watcher returns null', async () => {
mockWatcher.mockReturnValue(null);
store.watcher();
expect(mockWatcher).toHaveBeenCalled();
expect(store.callbackData).toBeUndefined();
expect(store.callbackStatus).toBe('ready');
});
});
describe('Save Callback Data', () => {
it('should save data and trigger redirect when valid data provided', () => {
const mockData: QueryPayloads = {
type: 'forUpc',
actions: [
{
type: 'signIn',
user: { email: 'test@example.com', preferred_username: 'test' },
apiKey: 'test-key',
} as ExternalSignIn,
],
sender: 'test',
};
store.saveCallbackData(mockData);
expect(store.callbackData).toEqual(mockData);
expect(store.callbackStatus).toBe('loading');
});
it('should handle missing callback data', () => {
const consoleSpy = vi.spyOn(console, 'error');
store.saveCallbackData(undefined);
expect(consoleSpy).toHaveBeenCalledWith('Saved callback data not found');
expect(store.callbackStatus).toBe('ready');
});
it('should handle invalid callback type', () => {
const mockData = {
type: 'fromUpc',
actions: [],
sender: 'test',
} as QueryPayloads;
const consoleSpy = vi.spyOn(console, 'error');
store.saveCallbackData(mockData);
expect(consoleSpy).toHaveBeenCalledWith(
'[redirectToCallbackType]',
'Callback redirect type not present or incorrect'
);
expect(store.callbackStatus).toBe('ready');
});
});
describe('Callback Actions Handling', () => {
it('should handle sign in action', async () => {
const mockData: QueryPayloads = {
type: 'forUpc',
actions: [
{
type: 'signIn',
user: { email: 'test@example.com', preferred_username: 'test' },
apiKey: 'test-key',
} as ExternalSignIn,
],
sender: 'test',
};
store.saveCallbackData(mockData);
await nextTick();
expect(vi.mocked(useAccountStore)().setAccountAction).toHaveBeenCalled();
expect(vi.mocked(useAccountStore)().setConnectSignInPayload).toHaveBeenCalledWith({
apiKey: 'test-key',
email: 'test@example.com',
preferred_username: 'test',
});
expect(vi.mocked(useServerStore)().refreshServerState).toHaveBeenCalled();
});
it('should handle sign out action', async () => {
const mockData: QueryPayloads = {
type: 'forUpc',
actions: [
{
type: 'signOut',
},
],
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',
actions: [
{
type: 'updateOs',
server: {
guid: 'test-guid',
name: 'test-server',
},
sha256: 'test-sha256',
version: '6.12.3',
} as ExternalUpdateOsAction,
],
sender: 'test',
};
store.saveCallbackData(mockData);
await nextTick();
expect(vi.mocked(useUpdateOsActionsStore)().setUpdateOsAction).toHaveBeenCalled();
expect(vi.mocked(useUpdateOsActionsStore)().actOnUpdateOsAction).toHaveBeenCalled();
expect(vi.mocked(useServerStore)().refreshServerState).not.toHaveBeenCalled(); // Single action, no refresh needed
});
it('should handle multiple actions', async () => {
const mockData: QueryPayloads = {
type: 'forUpc',
actions: [
{
type: 'signIn',
user: { email: 'test@example.com', preferred_username: 'test' },
apiKey: 'test-key',
} as ExternalSignIn,
{
type: 'updateOs',
server: {
guid: 'test-guid',
name: 'test-server',
},
sha256: 'test-sha256',
version: '6.12.3',
} as ExternalUpdateOsAction,
],
sender: 'test',
};
store.saveCallbackData(mockData);
await nextTick();
expect(vi.mocked(useAccountStore)().setAccountAction).toHaveBeenCalled();
expect(vi.mocked(useUpdateOsActionsStore)().setUpdateOsAction).toHaveBeenCalled();
expect(vi.mocked(useServerStore)().refreshServerState).toHaveBeenCalled();
});
});
describe('Status Management', () => {
beforeEach(() => {
// Mock window.history
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
pathname: '/test-path',
});
});
it('should handle status changes correctly', async () => {
store.setCallbackStatus('loading');
await nextTick();
expect(preventClose.addPreventClose).toHaveBeenCalled();
store.setCallbackStatus('success');
await nextTick();
expect(preventClose.removePreventClose).toHaveBeenCalled();
expect(window.history.replaceState).toHaveBeenCalledWith(null, '', '/test-path');
});
it('should handle multiple status transitions', async () => {
store.setCallbackStatus('loading');
await nextTick();
expect(preventClose.addPreventClose).toHaveBeenCalledTimes(1);
store.setCallbackStatus('error');
await nextTick();
expect(preventClose.removePreventClose).toHaveBeenCalledTimes(1);
expect(window.history.replaceState).toHaveBeenCalledTimes(1);
store.setCallbackStatus('loading');
await nextTick();
expect(preventClose.addPreventClose).toHaveBeenCalledTimes(2);
store.setCallbackStatus('success');
await nextTick();
expect(preventClose.removePreventClose).toHaveBeenCalledTimes(2);
expect(window.history.replaceState).toHaveBeenCalledTimes(2);
});
it('should not trigger prevent close for non-loading status changes', () => {
store.setCallbackStatus('ready');
expect(preventClose.addPreventClose).not.toHaveBeenCalled();
expect(preventClose.removePreventClose).not.toHaveBeenCalled();
store.setCallbackStatus('error');
expect(preventClose.addPreventClose).not.toHaveBeenCalled();
expect(preventClose.removePreventClose).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,43 @@
/**
* Dropdown store test coverage
*/
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it } from 'vitest';
import { useDropdownStore } from '~/store/dropdown';
describe('Dropdown Store', () => {
let store: ReturnType<typeof useDropdownStore>;
beforeEach(() => {
setActivePinia(createPinia());
store = useDropdownStore();
});
describe('State and Actions', () => {
it('should initialize with dropdown hidden', () => {
expect(store.dropdownVisible).toBe(false);
});
it('should show dropdown', () => {
store.dropdownShow();
expect(store.dropdownVisible).toBe(true);
});
it('should hide dropdown', () => {
store.dropdownShow();
store.dropdownHide();
expect(store.dropdownVisible).toBe(false);
});
it('should toggle dropdown', () => {
expect(store.dropdownVisible).toBe(false);
store.dropdownToggle();
expect(store.dropdownVisible).toBe(true);
store.dropdownToggle();
expect(store.dropdownVisible).toBe(false);
});
});
});

View File

@@ -0,0 +1,152 @@
/**
* Errors store test coverage
*/
import { nextTick } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Error } from '~/store/errors';
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', () => {
let store: ReturnType<typeof useErrorsStore>;
const originalConsoleError = console.error;
const mockError: Error = {
heading: 'Test Error',
level: 'error',
message: 'Test message',
type: 'request',
ref: 'test-ref',
};
beforeEach(() => {
// Silence console.error during tests
console.error = vi.fn();
const pinia = createPinia();
setActivePinia(pinia);
store = useErrorsStore();
vi.clearAllMocks();
});
afterEach(() => {
console.error = originalConsoleError;
vi.resetAllMocks();
});
describe('State and Actions', () => {
it('should initialize with empty errors array', () => {
expect(store.errors).toEqual([]);
});
it('should add error', () => {
store.setError(mockError);
expect(store.errors).toHaveLength(1);
expect(store.errors[0]).toEqual(mockError);
});
it('should remove error by index', async () => {
store.setError(mockError);
store.setError({ ...mockError, ref: 'test-ref-2' });
expect(store.errors).toHaveLength(2);
store.removeErrorByIndex(0);
await nextTick();
expect(store.errors).toHaveLength(1);
expect(store.errors[0].ref).toBe('test-ref-2');
});
it('should remove error by ref', async () => {
store.setError(mockError);
store.setError({ ...mockError, ref: 'test-ref-2' });
expect(store.errors).toHaveLength(2);
store.removeErrorByRef('test-ref');
await nextTick();
expect(store.errors).toHaveLength(1);
expect(store.errors[0].ref).toBe('test-ref-2');
});
it('should reset errors', async () => {
store.setError(mockError);
store.setError({ ...mockError, ref: 'test-ref-2' });
expect(store.errors).toHaveLength(2);
store.resetErrors();
await nextTick();
expect(store.errors).toHaveLength(0);
});
});
describe('Troubleshoot Feature', () => {
beforeEach(() => {
// Mock the DOM elements needed for troubleshoot
const mockModal = document.createElement('div');
mockModal.className = 'sweet-alert visible';
const mockTextarea = document.createElement('textarea');
mockTextarea.id = 'troubleshootDetails';
mockModal.appendChild(mockTextarea);
const mockEmailInput = document.createElement('input');
mockEmailInput.id = 'troubleshootEmail';
mockModal.appendChild(mockEmailInput);
const mockRadio = document.createElement('input');
mockRadio.id = 'optTroubleshoot';
mockRadio.type = 'radio';
mockModal.appendChild(mockRadio);
const mockPanels = document.createElement('div');
mockPanels.className = 'allpanels';
mockPanels.id = 'troubleshoot_panel';
mockModal.appendChild(mockPanels);
document.body.appendChild(mockModal);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('should open troubleshoot with error details', async () => {
store.setError(mockError);
await nextTick();
await store.openTroubleshoot({
email: 'test@example.com',
includeUnraidApiLogs: true,
});
const textarea = document.querySelector('#troubleshootDetails') as HTMLTextAreaElement;
const emailInput = document.querySelector('#troubleshootEmail') as HTMLInputElement;
const radio = document.querySelector('#optTroubleshoot') as HTMLInputElement;
const panel = document.querySelector('#troubleshoot_panel') as HTMLElement;
expect(mockFeedbackButton).toHaveBeenCalled();
expect(textarea.value).toContain('Debug Details Component Errors 1');
expect(textarea.value).toContain('Error 1: Test Error');
expect(textarea.value).toContain('Error 1 Message: Test message');
expect(emailInput.value).toBe('test@example.com');
expect(radio.checked).toBe(true);
expect(panel.style.display).toBe('block');
});
});
});

View File

@@ -0,0 +1,189 @@
/**
* InstallKey store test coverage
*/
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalKeyActions } from '@unraid/shared-callbacks';
import { useInstallKeyStore } from '~/store/installKey';
const mockGetFn = vi.fn();
vi.mock('~/composables/services/webgui', () => ({
WebguiInstallKey: {
query: vi.fn(() => ({
get: mockGetFn,
})),
},
}));
const mockSetError = vi.fn();
vi.mock('~/store/errors', () => ({
useErrorsStore: () => ({
setError: mockSetError,
}),
}));
vi.mock('@unraid/shared-callbacks', () => ({}));
const createTestAction = (data: Partial<ExternalKeyActions>): ExternalKeyActions => {
return {
type: 'purchase',
keyUrl: '',
...data,
};
};
describe('InstallKey Store', () => {
let store: ReturnType<typeof useInstallKeyStore>;
beforeEach(() => {
setActivePinia(createPinia());
store = useInstallKeyStore();
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.clearAllMocks();
});
describe('State and Initialization', () => {
it('should initialize with default state', () => {
expect(store.keyInstallStatus).toBe('ready');
expect(store.keyActionType).toBeUndefined();
expect(store.keyType).toBeUndefined();
expect(store.keyUrl).toBeUndefined();
});
});
describe('Installing Keys', () => {
it('should fail when keyUrl is not provided', async () => {
await store.install(
createTestAction({
type: 'purchase',
keyUrl: undefined,
})
);
expect(store.keyInstallStatus).toBe('failed');
expect(console.error).toHaveBeenCalledWith('[install] no key to install');
});
it('should set status to installing when install is called', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
const promise = store.install(
createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/license.key',
})
);
expect(store.keyInstallStatus).toBe('installing');
await promise;
});
it('should call WebguiInstallKey.query with correct url', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
await store.install(
createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/license.key',
})
);
expect(store.keyInstallStatus).toBe('success');
});
it('should extract key type from .key URL', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
await store.install(
createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/license.key',
})
);
expect(store.keyType).toBe('license');
});
it('should extract key type from .unkey URL', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
await store.install(
createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/premium.unkey',
})
);
expect(store.keyType).toBe('premium');
});
});
describe('Error Handling', () => {
it('should handle string errors during installation', async () => {
mockGetFn.mockRejectedValueOnce('error message');
await store.install(
createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/license.key',
})
);
expect(store.keyInstallStatus).toBe('failed');
expect(mockSetError).toHaveBeenCalledWith({
heading: 'Failed to install key',
message: 'ERROR MESSAGE',
level: 'error',
ref: 'installKey',
type: 'installKey',
});
});
it('should handle Error object during installation', async () => {
mockGetFn.mockRejectedValueOnce(new Error('Test error message'));
await store.install(
createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/license.key',
})
);
expect(store.keyInstallStatus).toBe('failed');
expect(mockSetError).toHaveBeenCalledWith({
heading: 'Failed to install key',
message: 'Test error message',
level: 'error',
ref: 'installKey',
type: 'installKey',
});
});
it('should handle unknown error types during installation', async () => {
mockGetFn.mockRejectedValueOnce({ something: 'wrong' });
await store.install(
createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/license.key',
})
);
expect(store.keyInstallStatus).toBe('failed');
expect(mockSetError).toHaveBeenCalledWith({
heading: 'Failed to install key',
message: 'Unknown error',
level: 'error',
ref: 'installKey',
type: 'installKey',
});
});
});
});

View File

@@ -25,6 +25,31 @@ type MockServerStore = ReturnType<typeof useServerStore> & Record<string, unknow
// Helper function to safely create test data with type assertions
const createTestData = <T extends Record<string, unknown>>(data: T): T => data as T;
// Save original console methods
const originalConsoleDebug = console.debug;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
vi.mock('vue', async () => {
const actual = await vi.importActual('vue');
return {
...actual,
toRefs: (obj: Record<string, unknown>) => {
// Handle non-reactive objects to prevent warnings
if (!obj || typeof obj !== 'object') {
return {};
}
return (
actual as unknown as { toRefs: (obj: Record<string, unknown>) => Record<string, unknown> }
).toRefs(obj);
},
watchEffect: (fn: () => void) => {
fn();
return () => {};
},
};
});
const getStore = () => {
const pinia = createTestingPinia({
createSpy: vi.fn,
@@ -293,6 +318,11 @@ vi.mock('~/composables/locale', async () => {
describe('useServerStore', () => {
beforeEach(() => {
// Silence console logs
console.debug = vi.fn();
console.error = vi.fn();
console.warn = vi.fn();
setActivePinia(
createTestingPinia({
createSpy: vi.fn,
@@ -301,6 +331,11 @@ describe('useServerStore', () => {
});
afterEach(() => {
// Restore console functions
console.debug = originalConsoleDebug;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
vi.resetAllMocks();
});