Skip to content
Draft
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
14 changes: 9 additions & 5 deletions web/__test__/components/CallbackFeedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,18 +294,22 @@ describe('CallbackFeedback.vue', () => {
expect(wrapper.text()).toContain('Fix Error');
});

it('renders the generic error state for failed key installs', () => {
it('renders manual-install recovery for failed key installs', () => {
callbackStatus.value = 'error';
keyActionType.value = 'purchase';
keyActionType.value = 'trialStart';
keyInstallStatus.value = 'failed';
keyType.value = 'Pro';
keyUrl.value = 'https://example.com/pro.key';
keyType.value = 'Trial';
keyUrl.value = 'https://keys.lime-technology.com/Trial.key';

const wrapper = mountComponent();

expect(wrapper.find('h1').text()).toBe('Error');
expect(wrapper.find('.description').text()).toBe('Something went wrong');
expect(wrapper.text()).toContain('Failed to Install Pro Key');
expect(wrapper.text()).toContain('Failed to Install Trial Key');
expect(wrapper.get('.brand-button').text()).toBe('Copy Key URL');
expect(wrapper.get('a[href="/Tools/Registration"]').text()).toBe(
'Then go to Tools > Registration to manually install it'
);
expect(wrapper.find('.modal').attributes('data-error')).toBe('true');
expect(wrapper.find('.modal').attributes('data-success')).toBe('false');
});
Expand Down
46 changes: 37 additions & 9 deletions web/__test__/store/installKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import type { ExternalKeyActions } from '@unraid/shared-callbacks';

import { useInstallKeyStore } from '~/store/installKey';

const mockGetFn = vi.fn();
const mockJsonFn = vi.fn();
const mockResponseChain = {
error: vi.fn(),
json: mockJsonFn,
};
mockResponseChain.error.mockReturnValue(mockResponseChain);
const mockGetFn = vi.fn(() => mockResponseChain);
vi.mock('~/composables/services/webgui', () => ({
WebguiInstallKey: {
query: vi.fn(() => ({
Expand Down Expand Up @@ -73,7 +79,7 @@ describe('InstallKey Store', () => {
});

it('should set status to installing when install is called', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' });

const promise = store.install(
createTestAction({
Expand All @@ -88,7 +94,7 @@ describe('InstallKey Store', () => {
});

it('should handle successful install and update state', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' });
const action = createTestAction({
type: 'purchase',
keyUrl: 'https://example.com/license.key',
Expand All @@ -100,14 +106,15 @@ describe('InstallKey Store', () => {

expect(WebguiInstallKey.query).toHaveBeenCalledWith({ url: action.keyUrl });
expect(mockGetFn).toHaveBeenCalled();
expect(mockJsonFn).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 install trialStart keys using the same install flow', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' });
const action = createTestAction({
type: 'trialStart',
keyUrl: 'https://example.com/trial.key',
Expand All @@ -119,14 +126,15 @@ describe('InstallKey Store', () => {

expect(WebguiInstallKey.query).toHaveBeenCalledWith({ url: action.keyUrl });
expect(mockGetFn).toHaveBeenCalled();
expect(mockJsonFn).toHaveBeenCalled();
expect(store.keyInstallStatus).toBe('success');
expect(store.keyActionType).toBe('trialStart');
expect(store.keyUrl).toBe('https://example.com/trial.key');
expect(store.keyType).toBe('trial');
});

it('should extract key type from .key URL', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' });

await store.install(
createTestAction({
Expand All @@ -139,7 +147,7 @@ describe('InstallKey Store', () => {
});

it('should extract key type from .unkey URL', async () => {
mockGetFn.mockResolvedValueOnce({ success: true });
mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' });

await store.install(
createTestAction({
Expand All @@ -154,7 +162,7 @@ describe('InstallKey Store', () => {

describe('Error Handling', () => {
it('should handle string errors during installation', async () => {
mockGetFn.mockRejectedValueOnce('error message');
mockJsonFn.mockRejectedValueOnce('error message');

await store.install(
createTestAction({
Expand All @@ -174,7 +182,7 @@ describe('InstallKey Store', () => {
});

it('should handle Error object during installation', async () => {
mockGetFn.mockRejectedValueOnce(new Error('Test error message'));
mockJsonFn.mockRejectedValueOnce(new Error('Test error message'));

await store.install(
createTestAction({
Expand All @@ -194,7 +202,7 @@ describe('InstallKey Store', () => {
});

it('should handle unknown error types during installation', async () => {
mockGetFn.mockRejectedValueOnce({ something: 'wrong' });
mockJsonFn.mockRejectedValueOnce({ something: 'wrong' });

await store.install(
createTestAction({
Expand All @@ -212,5 +220,25 @@ describe('InstallKey Store', () => {
type: 'installKey',
});
});

it('should reject an invalid success response', async () => {
mockJsonFn.mockResolvedValueOnce({ success: true });

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: 'Invalid response from InstallKey.php',
level: 'error',
ref: 'installKey',
type: 'installKey',
});
});
});
});
129 changes: 129 additions & 0 deletions web/__test__/store/installKey.wretch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { createPinia, setActivePinia } from 'pinia';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ExternalKeyActions } from '@unraid/shared-callbacks';

import { resolveCallbackStatus } from '~/store/callbackActions.helpers';
import { useErrorsStore } from '~/store/errors';
import { useInstallKeyStore } from '~/store/installKey';
Comment on lines +7 to +9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use .js extensions for local ESM imports.

These local TypeScript imports violate the repository ESM import convention.

Proposed fix
-import { resolveCallbackStatus } from '~/store/callbackActions.helpers';
-import { useErrorsStore } from '~/store/errors';
-import { useInstallKeyStore } from '~/store/installKey';
+import { resolveCallbackStatus } from '~/store/callbackActions.helpers.js';
+import { useErrorsStore } from '~/store/errors.js';
+import { useInstallKeyStore } from '~/store/installKey.js';

As per coding guidelines, use .js extensions in TypeScript imports for ESM compatibility.

#!/bin/bash
sed -n '1,15p' web/__test__/store/installKey.wretch.test.ts
fd -HI -t f '^(tsconfig.*\.json|package\.json)$' . -x sh -c 'echo "--- $1"; sed -n "1,180p" "$1"' sh {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/__test__/store/installKey.wretch.test.ts` around lines 7 - 9, Update the
local imports in the installKey test to use `.js` extensions, including the
imports for resolveCallbackStatus, useErrorsStore, and useInstallKeyStore, while
leaving package imports unchanged.


const action: ExternalKeyActions = {
type: 'trialStart',
keyUrl: 'https://keys.lime-technology.com/Trial.key',
};

const jsonResponse = (body: unknown, status: number) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});

const createFetchController = () => {
let resolveFetch: (response: Response) => void = () => {
throw new Error('Fetch controller resolved before initialization');
};
let rejectFetch: (error: unknown) => void = () => {
throw new Error('Fetch controller rejected before initialization');
};
const response = new Promise<Response>((resolve, reject) => {
resolveFetch = resolve;
rejectFetch = reject;
});

return {
reject: rejectFetch,
resolve: resolveFetch,
response,
};
};

const flushUnhandledRejections = () => new Promise((resolve) => setTimeout(resolve, 0));

describe('InstallKey Store with real Wretch response chains', () => {
const unhandledRejection = vi.fn();

beforeEach(() => {
setActivePinia(createPinia());
globalThis.csrf_token = '';
process.on('unhandledRejection', unhandledRejection);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
process.off('unhandledRejection', unhandledRejection);
vi.unstubAllGlobals();
vi.restoreAllMocks();
unhandledRejection.mockClear();
});

const startInstall = () => {
const fetchController = createFetchController();
vi.stubGlobal(
'fetch',
vi.fn(() => fetchController.response)
);
const store = useInstallKeyStore();
const installPromise = store.install(action);

return { fetchController, installPromise, store };
};

it('stays installing until a delayed successful response is consumed', async () => {
const { fetchController, installPromise, store } = startInstall();

expect(store.keyInstallStatus).toBe('installing');
await Promise.resolve();
expect(store.keyInstallStatus).toBe('installing');

fetchController.resolve(jsonResponse({ status: 'success', message: 'Key installed' }, 200));
await installPromise;
await flushUnhandledRejections();

expect(store.keyInstallStatus).toBe('success');
expect(unhandledRejection).not.toHaveBeenCalled();
});

it('turns a delayed HTTP 406 into callback error and preserves its safe message', async () => {
const { fetchController, installPromise, store } = startInstall();

expect(store.keyInstallStatus).toBe('installing');
fetchController.resolve(jsonResponse({ error: 'download error 8' }, 406));
await installPromise;
await flushUnhandledRejections();

expect(store.keyInstallStatus).toBe('failed');
expect(store.keyUrl).toBe(action.keyUrl);
expect(useErrorsStore().errors.at(-1)?.message).toBe('download error 8');
expect(
resolveCallbackStatus({
actions: [action],
accountActionStatus: 'ready',
keyInstallStatus: store.keyInstallStatus,
})
).toBe('error');
expect(unhandledRejection).not.toHaveBeenCalled();
});

it('turns a delayed network rejection into callback error', async () => {
const { fetchController, installPromise, store } = startInstall();

expect(store.keyInstallStatus).toBe('installing');
fetchController.reject(new TypeError('Failed to fetch'));
await installPromise;
await flushUnhandledRejections();

expect(store.keyInstallStatus).toBe('failed');
expect(store.keyUrl).toBe(action.keyUrl);
expect(useErrorsStore().errors.at(-1)?.message).toBe('Failed to fetch');
expect(
resolveCallbackStatus({
actions: [action],
accountActionStatus: 'ready',
keyInstallStatus: store.keyInstallStatus,
})
).toBe('error');
expect(unhandledRejection).not.toHaveBeenCalled();
});
});
73 changes: 65 additions & 8 deletions web/src/store/installKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,58 @@ import type { ExternalKeyActions } from '@unraid/shared-callbacks';
import { WebguiInstallKey } from '~/composables/services/webgui';
import { useErrorsStore } from '~/store/errors';

interface InstallKeySuccessResponse {
message: string;
status: 'success';
}

interface InstallKeyErrorResponse {
error: string;
}

const isInstallKeySuccessResponse = (response: unknown): response is InstallKeySuccessResponse =>
typeof response === 'object' &&
response !== null &&
'message' in response &&
typeof response.message === 'string' &&
'status' in response &&
response.status === 'success';

const isInstallKeyErrorResponse = (response: unknown): response is InstallKeyErrorResponse =>
typeof response === 'object' &&
response !== null &&
'error' in response &&
typeof response.error === 'string';

const parseInstallKeyErrorMessage = (message: string): string | undefined => {
try {
const parsed: unknown = JSON.parse(message);
return isInstallKeyErrorResponse(parsed) ? parsed.error : undefined;
} catch {
return undefined;
}
};

const getInstallKeyErrorMessage = (error: unknown): string => {
if (typeof error === 'object' && error !== null && 'json' in error) {
const errorJson = error.json;
if (isInstallKeyErrorResponse(errorJson)) {
return errorJson.error;
}
}
if (typeof error === 'string') {
return parseInstallKeyErrorMessage(error) ?? error.toUpperCase();
}
if (error instanceof Error) {
return parseInstallKeyErrorMessage(error.message) ?? error.message;
}
return 'Unknown error';
};

const rethrowInstallRequestError = (error: unknown): never => {
throw error;
};

export const useInstallKeyStore = defineStore('installKey', () => {
const errorsStore = useErrorsStore();

Expand Down Expand Up @@ -36,22 +88,27 @@ export const useInstallKeyStore = defineStore('installKey', () => {
}

try {
const installResponse = await WebguiInstallKey.query({ url: keyUrl.value }).get();
const installResponse: unknown = await WebguiInstallKey.query({ url: keyUrl.value })
.get()
.error('Error', rethrowInstallRequestError)
.error('TypeError', rethrowInstallRequestError)
.json();
console.log('[install] WebguiInstallKey installResponse', installResponse);

if (!isInstallKeySuccessResponse(installResponse)) {
const errorMessage = isInstallKeyErrorResponse(installResponse)
? installResponse.error
: 'Invalid response from InstallKey.php';
throw new Error(errorMessage);
}

keyInstallStatus.value = 'success';
} catch (error) {
console.error('[install] WebguiInstallKey error', error);
let errorMessage = 'Unknown error';
if (typeof error === 'string') {
errorMessage = error.toUpperCase();
} else if (error instanceof Error) {
errorMessage = error.message;
}
keyInstallStatus.value = 'failed';
errorsStore.setError({
heading: 'Failed to install key',
message: errorMessage,
message: getInstallKeyErrorMessage(error),
level: 'error',
ref: 'installKey',
type: 'installKey',
Expand Down
Loading