Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions web/__test__/store/replaceRenew.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { useServerStore } from '~/store/server';

vi.mock('@unraid/shared-callbacks', () => ({}));

vi.mock('@unraid/ui', () => ({
BrandLoading: {},
}));

vi.mock('~/composables/services/keyServer', () => ({
validateGuid: vi.fn(),
}));
Expand Down Expand Up @@ -62,7 +66,7 @@ describe('ReplaceRenew Store', () => {
expect(store.replaceStatus).toBe('ready');
});

it('should initialize with error state when guid is missing', () => {
it('should initialize with ready state even when guid is missing', () => {
vi.mocked(useServerStore).mockReturnValueOnce({
guid: undefined,
keyfile: mockKeyfile,
Expand All @@ -72,7 +76,8 @@ describe('ReplaceRenew Store', () => {

const newStore = useReplaceRenewStore();

expect(newStore.replaceStatus).toBe('error');
// Store now always initializes as 'ready' - errors are set when check() is called
expect(newStore.replaceStatus).toBe('ready');
});
});

Expand Down Expand Up @@ -138,6 +143,18 @@ describe('ReplaceRenew Store', () => {
expect(store.renewStatus).toBe('installing');
});

it('should reset all states with reset action', () => {
store.setReplaceStatus('error');
store.keyLinkedStatus = 'error';
store.error = { name: 'Error', message: 'Test error' };

store.reset();

expect(store.replaceStatus).toBe('ready');
expect(store.keyLinkedStatus).toBe('ready');
expect(store.error).toBeNull();
});

describe('check action', () => {
const mockResponse = {
hasNewerKeyfile: false,
Expand Down Expand Up @@ -326,8 +343,59 @@ describe('ReplaceRenew Store', () => {
await store.check();

expect(store.replaceStatus).toBe('error');
expect(store.keyLinkedStatus).toBe('error');
expect(console.error).toHaveBeenCalledWith('[ReplaceCheck.check]', testError);
expect(store.error).toEqual(testError);
expect(store.error).toEqual({ name: 'Error', message: 'Test error' });
});

it('should set error when guid is missing during check', async () => {
vi.mocked(useServerStore).mockReturnValue({
guid: '',
keyfile: mockKeyfile,
} as unknown as ReturnType<typeof useServerStore>);

setActivePinia(createPinia());
const testStore = useReplaceRenewStore();

await testStore.check();

expect(testStore.replaceStatus).toBe('error');
expect(testStore.keyLinkedStatus).toBe('error');
expect(testStore.error?.message).toBe('Flash GUID required to check replacement status');
});

it('should set error when keyfile is missing during check', async () => {
vi.mocked(useServerStore).mockReturnValue({
guid: mockGuid,
keyfile: '',
} as unknown as ReturnType<typeof useServerStore>);

setActivePinia(createPinia());
const testStore = useReplaceRenewStore();

await testStore.check();

expect(testStore.replaceStatus).toBe('error');
expect(testStore.keyLinkedStatus).toBe('error');
expect(testStore.error?.message).toBe('Keyfile required to check replacement status');
});

it('should provide descriptive error for 403 status', async () => {
const error403 = { response: { status: 403 }, message: 'Forbidden' };
vi.mocked(validateGuid).mockRejectedValueOnce(error403);

await store.check();

expect(store.error?.message).toBe('Access denied - license may be linked to another account');
});

it('should provide descriptive error for 500+ status', async () => {
const error500 = { response: { status: 500 }, message: 'Server Error' };
vi.mocked(validateGuid).mockRejectedValueOnce(error500);

await store.check();

expect(store.error?.message).toBe('Key server temporarily unavailable - please try again later');
});
});
});
Expand Down
25 changes: 18 additions & 7 deletions web/src/components/Registration/ReplaceCheck.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';

import { ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
import { ArrowPathIcon, ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
import { Badge, BrandButton } from '@unraid/ui';
import { DOCS_REGISTRATION_REPLACE_KEY } from '~/helpers/urls';

Expand All @@ -11,20 +12,30 @@ import { useReplaceRenewStore } from '~/store/replaceRenew';
const { t } = useI18n();
const replaceRenewStore = useReplaceRenewStore();
const { replaceStatusOutput } = storeToRefs(replaceRenewStore);

const isError = computed(() => replaceStatusOutput.value?.variant === 'red');
const showButton = computed(() => !replaceStatusOutput.value || isError.value);

const handleCheck = () => {
if (isError.value) {
replaceRenewStore.reset();
}
replaceRenewStore.check(true);
};
</script>

<template>
<div class="flex flex-wrap items-center justify-between gap-2">
<BrandButton
v-if="!replaceStatusOutput"
:icon="KeyIcon"
:text="t('registration.replaceCheck.checkEligibility')"
v-if="showButton"
:icon="isError ? ArrowPathIcon : KeyIcon"
:text="isError ? t('common.retry') : t('registration.replaceCheck.checkEligibility')"
class="grow"
@click="replaceRenewStore.check"
@click="handleCheck"
/>

<Badge v-else :variant="replaceStatusOutput.variant" :icon="replaceStatusOutput.icon" size="md">
{{ t(replaceStatusOutput.text ?? 'Unknown') }}
<Badge v-else :variant="replaceStatusOutput?.variant" :icon="replaceStatusOutput?.icon" size="md">
{{ t(replaceStatusOutput?.text ?? 'Unknown') }}
</Badge>

<span class="inline-flex flex-wrap items-center justify-end gap-2">
Expand Down
1 change: 1 addition & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"common.installed": "Installed",
"common.installing": "Installing",
"common.learnMore": "Learn More",
"common.retry": "Retry",
"common.loading2": "Loading…",
"common.success": "Success!",
"common.unknown": "Unknown",
Expand Down
32 changes: 28 additions & 4 deletions web/src/store/replaceRenew.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,7 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
renewStatus.value = status;
};

const replaceStatus = ref<'checking' | 'eligible' | 'error' | 'ineligible' | 'ready'>(
guid.value ? 'ready' : 'error'
);
const replaceStatus = ref<'checking' | 'eligible' | 'error' | 'ineligible' | 'ready'>('ready');
const setReplaceStatus = (status: typeof replaceStatus.value) => {
replaceStatus.value = status;
};
Expand Down Expand Up @@ -169,11 +167,15 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
const check = async (skipCache: boolean = false) => {
if (!guid.value) {
setReplaceStatus('error');
setKeyLinked('error');
error.value = { name: 'Error', message: 'Flash GUID required to check replacement status' };
return;
}
if (!keyfile.value) {
setReplaceStatus('error');
setKeyLinked('error');
error.value = { name: 'Error', message: 'Keyfile required to check replacement status' };
return;
}

try {
Expand Down Expand Up @@ -240,11 +242,32 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
} catch (err) {
const catchError = err as WretchError;
setReplaceStatus('error');
error.value = catchError?.message ? catchError : { name: 'Error', message: 'Unknown error' };
setKeyLinked('error');

let errorMessage = 'Unknown error';
if (catchError?.response?.status === 401) {
errorMessage = 'Authentication failed - please sign in again';
} else if (catchError?.response?.status === 403) {
errorMessage = 'Access denied - license may be linked to another account';
} else if (catchError?.response?.status && catchError.response.status >= 500) {
errorMessage = 'Key server temporarily unavailable - please try again later';
} else if (catchError?.message) {
errorMessage = catchError.message;
} else if (typeof navigator !== 'undefined' && !navigator.onLine) {
errorMessage = 'No internet connection';
}

error.value = { name: 'Error', message: errorMessage };
console.error('[ReplaceCheck.check]', catchError);
}
};

const reset = () => {
replaceStatus.value = 'ready';
keyLinkedStatus.value = 'ready';
error.value = null;
};

return {
// state
keyLinkedStatus,
Expand All @@ -255,6 +278,7 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
// actions
check,
purgeValidationResponse,
reset,
setReplaceStatus,
setRenewStatus,
error,
Expand Down
Loading