Expand Vitest coverage for shared frontend utils.
Cover Axios error message extraction, generic error fallbacks, ArrayBuffer views used by Web Crypto, and share URL builders.
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
import { AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveAxiosErrorMessage } from '@/utils/http/resolveAxiosErrorMessage';
|
||||||
|
|
||||||
|
const fallback = 'Upload failed';
|
||||||
|
|
||||||
|
const axiosErrorWithResponse = (data: unknown, message = 'Request failed'): AxiosError => {
|
||||||
|
return new AxiosError(
|
||||||
|
message,
|
||||||
|
AxiosError.ERR_BAD_REQUEST,
|
||||||
|
{} as InternalAxiosRequestConfig,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
headers: {},
|
||||||
|
config: {} as InternalAxiosRequestConfig,
|
||||||
|
data
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('resolveAxiosErrorMessage', () => {
|
||||||
|
it('falls back for non-Axios errors', () => {
|
||||||
|
expect(resolveAxiosErrorMessage(new Error('network'), fallback)).toBe('network');
|
||||||
|
expect(resolveAxiosErrorMessage('oops', fallback)).toBe(fallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when Axios error has no response', () => {
|
||||||
|
const error = new AxiosError('timeout', AxiosError.ETIMEDOUT);
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe('timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses string response data', () => {
|
||||||
|
const error = axiosErrorWithResponse('Rate limit exceeded');
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Rate limit exceeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses object message string (Nest-style)', () => {
|
||||||
|
const error = axiosErrorWithResponse({ message: 'Invalid ciphertext' });
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Invalid ciphertext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a single string from message array', () => {
|
||||||
|
const error = axiosErrorWithResponse({ message: ['iv must be valid base64'] });
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe('iv must be valid base64');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins multiple strings in message array', () => {
|
||||||
|
const error = axiosErrorWithResponse({
|
||||||
|
message: ['expiresAt must be a date', 'maxViews must be a number']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe(
|
||||||
|
'expiresAt must be a date, maxViews must be a number'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-string entries in message array', () => {
|
||||||
|
const error = axiosErrorWithResponse({
|
||||||
|
message: ['valid', 42, null, 'also valid']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe('valid, also valid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when response data has no usable message', () => {
|
||||||
|
const error = axiosErrorWithResponse({ message: [] }, 'HTTP 400');
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when response data is an object without message', () => {
|
||||||
|
const error = axiosErrorWithResponse({ statusCode: 400 }, 'HTTP 400');
|
||||||
|
|
||||||
|
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
|
||||||
|
|
||||||
|
describe('resolveErrorMessage', () => {
|
||||||
|
it('returns Error.message when non-empty', () => {
|
||||||
|
expect(resolveErrorMessage(new Error('upload failed'))).toBe('upload failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fallback when Error.message is empty', () => {
|
||||||
|
expect(resolveErrorMessage(new Error(''), 'custom fallback')).toBe('custom fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default fallback for non-Error values', () => {
|
||||||
|
expect(resolveErrorMessage(null)).toBe('Something went wrong');
|
||||||
|
expect(resolveErrorMessage('plain string')).toBe('Something went wrong');
|
||||||
|
expect(resolveErrorMessage({ message: 'not an Error' })).toBe('Something went wrong');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom fallback for non-Error values', () => {
|
||||||
|
expect(resolveErrorMessage(undefined, 'worker failed')).toBe('worker failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||||
|
|
||||||
|
describe('buildHotlinkImagePath', () => {
|
||||||
|
it('places upload id and secret in the path segments', () => {
|
||||||
|
const uploadId = 'upload-uuid';
|
||||||
|
const secret = 'url-safe-secret';
|
||||||
|
|
||||||
|
expect(buildHotlinkImagePath(uploadId, secret)).toBe(`/i/${uploadId}/${secret}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
|
||||||
|
|
||||||
|
describe('buildZkViewerPath', () => {
|
||||||
|
it('places upload id in the path and fragment key in the hash', () => {
|
||||||
|
const uploadId = 'abc-123';
|
||||||
|
const fragmentKey = 'dGVzdC1rZXk';
|
||||||
|
|
||||||
|
expect(buildZkViewerPath(uploadId, fragmentKey)).toBe(`/v/${uploadId}#${fragmentKey}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength';
|
||||||
|
import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
|
||||||
|
import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm';
|
||||||
|
import { generateDek } from '@/utils/crypto/generateDek';
|
||||||
|
import { generateIv } from '@/utils/crypto/generateIv';
|
||||||
|
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||||
|
import { toArrayBuffer } from '@/utils/toArrayBuffer';
|
||||||
|
|
||||||
|
describe('toArrayBuffer', () => {
|
||||||
|
it('returns the same ArrayBuffer instance', () => {
|
||||||
|
const buffer = new ArrayBuffer(8);
|
||||||
|
|
||||||
|
expect(toArrayBuffer(buffer)).toBe(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a Uint8Array view into a standalone ArrayBuffer', () => {
|
||||||
|
const pool = new Uint8Array([0, 0, 1, 2, 3, 0]);
|
||||||
|
const view = pool.subarray(2, 5);
|
||||||
|
|
||||||
|
const copied = toArrayBuffer(view);
|
||||||
|
|
||||||
|
expect(copied).not.toBe(pool.buffer);
|
||||||
|
expect(new Uint8Array(copied!)).toEqual(new Uint8Array([1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unsupported values', () => {
|
||||||
|
expect(toArrayBuffer(null)).toBeNull();
|
||||||
|
expect(toArrayBuffer('bytes')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports Web Crypto when DEK is a subarray of a larger buffer', async () => {
|
||||||
|
const dek = generateDek();
|
||||||
|
const pool = new Uint8Array(dek.byteLength + 4);
|
||||||
|
pool.set([9, 8, 7, 6], 0);
|
||||||
|
pool.set(dek, 4);
|
||||||
|
const dekView = pool.subarray(4, 4 + AES_GCM_KEY_BYTE_LENGTH);
|
||||||
|
|
||||||
|
const plaintext = new TextEncoder().encode('subarray-dek');
|
||||||
|
const iv = generateIv();
|
||||||
|
const key = await importAesGcmKey(dekView);
|
||||||
|
const ciphertext = await encryptAesGcm(plaintext, key, iv);
|
||||||
|
const recovered = await decryptAesGcm(ciphertext, key, iv);
|
||||||
|
|
||||||
|
expect(recovered).toEqual(plaintext);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user