test: front-end tests cleanup (round 1)
the aim is to make the tests more about the behavior rather than implementation, add some missing tests, and improve the code.
This commit is contained in:
committed by
Mazen Touati
parent
07b4708c76
commit
6ba071dc98
@@ -242,7 +242,11 @@ const shouldShowGeneratorIcon = (index: number, parameter: ExtendedParameter) =>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center px-2">
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center px-2"
|
||||
data-testid="delete-button"
|
||||
>
|
||||
<AppTooltipWrapper
|
||||
value="Delete"
|
||||
:on-click="() => handleDeleteParameter(index)"
|
||||
|
||||
@@ -125,12 +125,19 @@ watch(
|
||||
<div
|
||||
v-if="store.isCommandOpen"
|
||||
class="fixed inset-0 z-50"
|
||||
data-testid="value-generator-overlay"
|
||||
@click="store.closeCommand"
|
||||
>
|
||||
<div class="absolute w-full max-w-md" :style="commandPosition" @click.stop>
|
||||
<div
|
||||
class="absolute w-full max-w-md"
|
||||
:style="commandPosition"
|
||||
data-testid="value-generator-command"
|
||||
@click.stop
|
||||
>
|
||||
<AppCommand
|
||||
class="rounded-lg border shadow-md"
|
||||
data-ValueGenerator-focus-hook
|
||||
data-testid="value-generator-focus-hook"
|
||||
@keydown.escape="store.closeCommand"
|
||||
>
|
||||
<AppCommandInput
|
||||
|
||||
@@ -128,10 +128,10 @@ const cancelRequest = () => {
|
||||
/>
|
||||
<div class="w-8 border-b border-zinc-200"></div>
|
||||
<span class="text-xs">
|
||||
<span>{{ duration }}</span>
|
||||
<span data-testid="response-status-duration">{{ duration }}</span>
|
||||
<template v-if="!pendingRequestData?.isProcessing">
|
||||
<span class="text-color-muted mx-1 text-xs">/</span>
|
||||
<span>{{ size }}</span>
|
||||
<span data-testid="response-status-size">{{ size }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,15 @@ const props = defineProps<ResponseStatusCodeProps>();
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<StatusIndicator :status="props.status" />
|
||||
<span class="text-xs text-nowrap">{{ props.status }}</span>
|
||||
<AppBadge v-if="props.response" variant="outline" class="text-nowrap">
|
||||
<span class="text-xs text-nowrap" data-testid="response-status-text">
|
||||
{{ props.status }}
|
||||
</span>
|
||||
<AppBadge
|
||||
v-if="props.response"
|
||||
variant="outline"
|
||||
class="text-nowrap"
|
||||
data-testid="response-status-badge"
|
||||
>
|
||||
{{ props.response.statusCode }} -
|
||||
{{ props.response.statusText }}
|
||||
</AppBadge>
|
||||
|
||||
@@ -30,6 +30,7 @@ const indicatorColor = computed<string>(() => {
|
||||
<template>
|
||||
<Spinner
|
||||
v-if="props.status === STATUS.PENDING"
|
||||
data-testid="pending-request-spinner"
|
||||
class="text-accent-foreground size-4 animate-spin"
|
||||
/>
|
||||
<AppRoundIndicator v-else :class="indicatorColor" />
|
||||
|
||||
@@ -21,8 +21,12 @@ const lastLog = computed(() => historyStore.lastLog);
|
||||
<template>
|
||||
<div :class="cn('bg-background flex h-full flex-col', props.class)">
|
||||
<ResponseStatus class="border-b" />
|
||||
<ResponseViewerEmptyState v-if="!lastLog" />
|
||||
<ResponseViewerInternalError v-else-if="lastLog.error" :error="lastLog.error" />
|
||||
<ResponseViewerResponse v-else />
|
||||
<ResponseViewerEmptyState v-if="!lastLog" data-testid="response-empty" />
|
||||
<ResponseViewerInternalError
|
||||
v-else-if="lastLog.error"
|
||||
data-testid="response-error"
|
||||
:error="lastLog.error"
|
||||
/>
|
||||
<ResponseViewerResponse v-else data-testid="response-content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useHttpClient } from '@/composables/request/useHttpClient';
|
||||
import { ErrorPlainResponse, PendingRequest } from '@/interfaces/http';
|
||||
import { useRequestsHistoryStore } from '@/stores';
|
||||
import {
|
||||
createRequestTimer,
|
||||
generateErrorRequestLog,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
} from '@/utils/request';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRequestsHistoryStore } from './useRequestsHistoryStore';
|
||||
|
||||
/**
|
||||
* Store for managing request execution and timing.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, RenderOptions, screen } from '@testing-library/vue';
|
||||
import type { MountingOptions } from '@vue/test-utils';
|
||||
import { mount, VueWrapper } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { Component } from 'vue';
|
||||
import { createRouter, createWebHistory, Router } from 'vue-router';
|
||||
|
||||
/*
|
||||
* Custom test utilities for consistent Vue component testing.
|
||||
* Provides common setup patterns and helper functions.
|
||||
*/
|
||||
export interface RenderWithProvidersOptions extends RenderOptions<unknown> {
|
||||
router?: Router;
|
||||
}
|
||||
|
||||
export function createMockRouter(): Router {
|
||||
return createRouter({
|
||||
@@ -33,31 +33,59 @@ export function createMockRouter(): Router {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a Vue component with common test setup.
|
||||
* Includes Pinia store, router, and global stubs.
|
||||
*/
|
||||
export function mountWithPlugins(
|
||||
export function renderWithProviders(
|
||||
component: Component,
|
||||
options: any = {},
|
||||
): VueWrapper<any> {
|
||||
options: RenderWithProvidersOptions = {},
|
||||
) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const router = createMockRouter();
|
||||
const router = options.router ?? createMockRouter();
|
||||
|
||||
return {
|
||||
user: userEvent.setup(),
|
||||
...render(component, {
|
||||
...options,
|
||||
global: {
|
||||
...(options.global ?? {}),
|
||||
plugins: [...(options.global?.plugins ?? []), pinia, router],
|
||||
stubs: {
|
||||
keepAlive: true,
|
||||
Transition: true,
|
||||
Teleport: true,
|
||||
'router-link': true,
|
||||
'router-view': true,
|
||||
...(options.global?.stubs ?? {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mountWithPlugins(
|
||||
component: Component,
|
||||
options: MountingOptions<unknown> & { router?: Router } = {},
|
||||
): VueWrapper<unknown> {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const router = options.router ?? createMockRouter();
|
||||
|
||||
return mount(component, {
|
||||
...options,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
...(options.global ?? {}),
|
||||
plugins: [...(options.global?.plugins ?? []), pinia, router],
|
||||
stubs: {
|
||||
keepAlive: true,
|
||||
Transition: true,
|
||||
Teleport: true,
|
||||
'router-link': true,
|
||||
'router-view': true,
|
||||
'keep-alive': true,
|
||||
transition: true,
|
||||
'transition-group': true,
|
||||
teleport: true,
|
||||
...(options.global?.stubs ?? {}),
|
||||
},
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export { screen };
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { mount, VueWrapper } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Component } from 'vue';
|
||||
import { createRouter, createWebHistory, Router } from 'vue-router';
|
||||
|
||||
/**
|
||||
* Test utilities for dark theme testing.
|
||||
* Provides helpers to test components in both light and dark modes.
|
||||
*/
|
||||
|
||||
// Mock router for testing
|
||||
function createMockRouter(): Router {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: { template: '<div>Home</div>' },
|
||||
},
|
||||
{
|
||||
path: '/main',
|
||||
name: 'main',
|
||||
component: { template: '<div>Main</div>' },
|
||||
},
|
||||
{
|
||||
path: '/status',
|
||||
name: 'status',
|
||||
component: { template: '<div>Status</div>' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a component with dark theme simulation.
|
||||
* Adds dark class to the document element to simulate dark mode.
|
||||
*/
|
||||
export function mountWithDarkTheme(
|
||||
component: Component,
|
||||
options: any = {},
|
||||
): VueWrapper<any> {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const router = createMockRouter();
|
||||
|
||||
// Add dark class to document element
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
const wrapper = mount(component, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'router-link': true,
|
||||
'router-view': true,
|
||||
'keep-alive': true,
|
||||
transition: true,
|
||||
'transition-group': true,
|
||||
teleport: true,
|
||||
},
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
// Store original unmount function
|
||||
const originalUnmount = wrapper.unmount.bind(wrapper);
|
||||
|
||||
// Override unmount to clean up dark class
|
||||
wrapper.unmount = () => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
return originalUnmount();
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a component with light theme simulation.
|
||||
* Ensures dark class is not present on the document element.
|
||||
*/
|
||||
export function mountWithLightTheme(
|
||||
component: Component,
|
||||
options: any = {},
|
||||
): VueWrapper<any> {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const router = createMockRouter();
|
||||
|
||||
// Ensure dark class is not present
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
return mount(component, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'router-link': true,
|
||||
'router-view': true,
|
||||
'keep-alive': true,
|
||||
transition: true,
|
||||
'transition-group': true,
|
||||
teleport: true,
|
||||
},
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a component in both light and dark themes.
|
||||
* Runs the same test function for both themes.
|
||||
*/
|
||||
export function testBothThemes(
|
||||
component: Component,
|
||||
testFn: (wrapper: VueWrapper<any>, theme: 'light' | 'dark') => void,
|
||||
options: any = {},
|
||||
) {
|
||||
describe('Light Theme', () => {
|
||||
it('should render correctly in light theme', () => {
|
||||
const wrapper = mountWithLightTheme(component, options);
|
||||
testFn(wrapper, 'light');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Theme', () => {
|
||||
it('should render correctly in dark theme', () => {
|
||||
const wrapper = mountWithDarkTheme(component, options);
|
||||
testFn(wrapper, 'dark');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a component has the correct dark theme classes.
|
||||
*/
|
||||
export function expectDarkThemeClasses(
|
||||
wrapper: VueWrapper<any>,
|
||||
expectedClasses: string[],
|
||||
) {
|
||||
const element = wrapper.element;
|
||||
const classList = Array.from(element.classList);
|
||||
|
||||
expectedClasses.forEach(expectedClass => {
|
||||
expect(classList).toContain(expectedClass);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a component has the correct light theme classes.
|
||||
*/
|
||||
export function expectLightThemeClasses(
|
||||
wrapper: VueWrapper<any>,
|
||||
expectedClasses: string[],
|
||||
) {
|
||||
const element = wrapper.element;
|
||||
const classList = Array.from(element.classList);
|
||||
|
||||
expectedClasses.forEach(expectedClass => {
|
||||
expect(classList).toContain(expectedClass);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Common dark theme class patterns for testing.
|
||||
*/
|
||||
export const darkThemePatterns = {
|
||||
// Background colors
|
||||
backgrounds: {
|
||||
primary: 'dark:bg-zinc-950',
|
||||
secondary: 'dark:bg-zinc-900',
|
||||
tertiary: 'dark:bg-zinc-800',
|
||||
muted: 'dark:bg-gray-800',
|
||||
},
|
||||
|
||||
// Text colors
|
||||
text: {
|
||||
primary: 'dark:text-zinc-50',
|
||||
secondary: 'dark:text-zinc-100',
|
||||
muted: 'dark:text-zinc-400',
|
||||
mutedSecondary: 'dark:text-gray-300',
|
||||
},
|
||||
|
||||
// Border colors
|
||||
borders: {
|
||||
primary: 'dark:border-zinc-800',
|
||||
secondary: 'dark:border-gray-700',
|
||||
},
|
||||
|
||||
// Focus states
|
||||
focus: {
|
||||
ring: 'dark:focus-visible:ring-zinc-300',
|
||||
ringOffset: 'dark:ring-offset-zinc-950',
|
||||
},
|
||||
|
||||
// Hover states
|
||||
hover: {
|
||||
primary: 'dark:hover:bg-zinc-800',
|
||||
secondary: 'dark:hover:bg-gray-700',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Common light theme class patterns for testing.
|
||||
*/
|
||||
export const lightThemePatterns = {
|
||||
// Background colors
|
||||
backgrounds: {
|
||||
primary: 'bg-white',
|
||||
secondary: 'bg-zinc-50',
|
||||
tertiary: 'bg-gray-50',
|
||||
},
|
||||
|
||||
// Text colors
|
||||
text: {
|
||||
primary: 'text-zinc-950',
|
||||
secondary: 'text-zinc-900',
|
||||
muted: 'text-zinc-500',
|
||||
},
|
||||
|
||||
// Border colors
|
||||
borders: {
|
||||
primary: 'border-zinc-200',
|
||||
secondary: 'border-gray-100',
|
||||
},
|
||||
|
||||
// Focus states
|
||||
focus: {
|
||||
ring: 'focus-visible:ring-zinc-950',
|
||||
ringOffset: 'ring-offset-white',
|
||||
},
|
||||
|
||||
// Hover states
|
||||
hover: {
|
||||
primary: 'hover:bg-zinc-100',
|
||||
secondary: 'hover:bg-gray-50',
|
||||
},
|
||||
};
|
||||
@@ -1,134 +1,189 @@
|
||||
import RequestHeaders from '@/components/domain/Client/Request/RequestHeader/RequestHeaders.vue';
|
||||
import { mountWithPlugins } from '@/tests/_utils/test-utils';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { GeneratorType, PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
|
||||
import { renderWithProviders } from '@/tests/_utils/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
|
||||
// Mocks for stores consumed via '@/stores' barrel
|
||||
const mockRequestStore = reactive({
|
||||
pendingRequestData: ref<any>(null), // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
updateRequestHeaders: vi.fn(),
|
||||
});
|
||||
|
||||
const mockConfigStore = reactive({
|
||||
headers: [
|
||||
{
|
||||
header: 'X-Global',
|
||||
type: 'raw',
|
||||
value: 'foo',
|
||||
},
|
||||
{ header: 'X-Global', type: 'raw', value: 'foo' },
|
||||
{ header: 'X-Generated', type: 'generator', value: GeneratorType.Email },
|
||||
],
|
||||
});
|
||||
|
||||
const mockValueGeneratorStore = reactive({
|
||||
generateValue: vi.fn(),
|
||||
const generateValue = vi.fn(() => 'generated@example.com');
|
||||
|
||||
const mockRequestStore = reactive({
|
||||
pendingRequestData: ref<PendingRequest | null>(null),
|
||||
updateRequestHeaders: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useRequestStore: () => mockRequestStore,
|
||||
useConfigStore: () => mockConfigStore,
|
||||
useValueGeneratorStore: () => mockValueGeneratorStore,
|
||||
}));
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
describe('RequestHeaders.vue', () => {
|
||||
return {
|
||||
...actual,
|
||||
useRequestStore: () => mockRequestStore,
|
||||
useConfigStore: () => mockConfigStore,
|
||||
useValueGeneratorStore: () => ({
|
||||
generateValue,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = () => renderWithProviders(RequestHeaders);
|
||||
|
||||
const setPendingRequest = (request: PendingRequest | null) => {
|
||||
mockRequestStore.pendingRequestData = ref(request);
|
||||
};
|
||||
|
||||
describe('RequestHeaders', () => {
|
||||
beforeEach(() => {
|
||||
mockRequestStore.pendingRequestData = ref<any>(null); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
mockRequestStore.updateRequestHeaders.mockReset();
|
||||
generateValue.mockClear();
|
||||
|
||||
mockConfigStore.headers = [
|
||||
{ header: 'X-Global', type: 'raw', value: 'foo' },
|
||||
{ header: 'X-Generated', type: 'generator', value: GeneratorType.Email },
|
||||
];
|
||||
|
||||
setPendingRequest({
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
headers: [],
|
||||
body: {},
|
||||
payloadType: RequestBodyTypeEnum.EMPTY,
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
queryParameters: [],
|
||||
authorization: { type: AuthorizationType.None },
|
||||
supportedRoutes: [],
|
||||
routeDefinition: {
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
shortEndpoint: 'api/users',
|
||||
},
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
});
|
||||
|
||||
mockRequestStore.updateRequestHeaders.mockClear();
|
||||
});
|
||||
|
||||
it('re-initializes and syncs headers when endpoint and method changes', async () => {
|
||||
mountWithPlugins(RequestHeaders);
|
||||
|
||||
// Simulate initial pending request on endpoint with method GET and no headers set in store
|
||||
mockRequestStore.pendingRequestData = ref({
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
headers: [],
|
||||
});
|
||||
it('initializes headers with global defaults and syncs them to the store', async () => {
|
||||
renderComponent();
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Change only the method (same endpoint). This should re-initialize headers and sync to store.
|
||||
const callsBefore = mockRequestStore.updateRequestHeaders.mock.calls.length;
|
||||
|
||||
mockRequestStore.pendingRequestData = ref({
|
||||
method: 'POST',
|
||||
endpoint: '/api/users',
|
||||
headers: [],
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Expect updateRequestHeaders called with the global header present
|
||||
expect(mockRequestStore.updateRequestHeaders.mock.calls.length).toBeGreaterThan(
|
||||
callsBefore,
|
||||
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'X-Global', value: 'foo' }),
|
||||
expect.objectContaining({
|
||||
key: 'X-Generated',
|
||||
value: 'generated@example.com',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(generateValue).toHaveBeenCalledWith('email');
|
||||
});
|
||||
|
||||
const lastCallArgs = mockRequestStore.updateRequestHeaders.mock.calls.at(-1)?.[0];
|
||||
it('reinitializes headers when the request method changes', async () => {
|
||||
renderComponent();
|
||||
|
||||
expect(Array.isArray(lastCallArgs)).toBe(true);
|
||||
await nextTick();
|
||||
|
||||
expect(lastCallArgs).toEqual(
|
||||
mockRequestStore.updateRequestHeaders.mockClear();
|
||||
|
||||
setPendingRequest({
|
||||
...mockRequestStore.pendingRequestData!,
|
||||
method: 'PUT', // <- Different method that the original one.
|
||||
headers: [],
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'X-Global', value: 'foo' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-initializes and syncs headers when only endpoint changes', async () => {
|
||||
mountWithPlugins(RequestHeaders);
|
||||
it('reinitializes headers when the request endpoint changes', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Seed initial state
|
||||
mockRequestStore.pendingRequestData = ref({
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
await nextTick();
|
||||
|
||||
mockRequestStore.updateRequestHeaders.mockClear();
|
||||
|
||||
setPendingRequest({
|
||||
...mockRequestStore.pendingRequestData!,
|
||||
endpoint: 'api/accounts',
|
||||
headers: [],
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
const callsBefore = mockRequestStore.updateRequestHeaders.mock.calls.length;
|
||||
|
||||
// Change endpoint only (method remains the same)
|
||||
mockRequestStore.pendingRequestData = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/accounts',
|
||||
headers: [],
|
||||
};
|
||||
await nextTick();
|
||||
|
||||
expect(mockRequestStore.updateRequestHeaders.mock.calls.length).toBeGreaterThan(
|
||||
callsBefore,
|
||||
);
|
||||
|
||||
const args = mockRequestStore.updateRequestHeaders.mock.calls.at(-1)?.[0];
|
||||
expect(Array.isArray(args)).toBe(true);
|
||||
expect(args).toEqual(
|
||||
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'X-Global', value: 'foo' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not re-initialize when endpoint and method are unchanged', async () => {
|
||||
mountWithPlugins(RequestHeaders);
|
||||
it('does not reinitialize when method and endpoint stay the same', async () => {
|
||||
renderComponent();
|
||||
|
||||
mockRequestStore.pendingRequestData = ref({
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
await nextTick();
|
||||
|
||||
mockRequestStore.updateRequestHeaders.mockClear();
|
||||
|
||||
setPendingRequest({
|
||||
...mockRequestStore.pendingRequestData!,
|
||||
headers: [],
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
const callsBefore = mockRequestStore.updateRequestHeaders.mock.calls.length;
|
||||
expect(mockRequestStore.updateRequestHeaders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Re-emit the same values (new object but same endpoint and method)
|
||||
mockRequestStore.pendingRequestData = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
it('merges existing request headers with global ones when changing endpoints', async () => {
|
||||
mockRequestStore.pendingRequestData.headers = [
|
||||
{ key: 'X-Existing', value: '123' },
|
||||
{ key: 'X-Global', value: 'custom' },
|
||||
];
|
||||
|
||||
renderComponent();
|
||||
|
||||
mockRequestStore.updateRequestHeaders.mockClear();
|
||||
|
||||
setPendingRequest({
|
||||
...mockRequestStore.pendingRequestData!,
|
||||
method: 'PUT', // <- Different method that the original one to re-trigger th.
|
||||
headers: [],
|
||||
};
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
// No additional sync should have occurred
|
||||
expect(mockRequestStore.updateRequestHeaders.mock.calls.length).toBe(callsBefore);
|
||||
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'X-Existing', value: '123' }),
|
||||
expect.objectContaining({ key: 'X-Global', value: 'custom' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
38
resources/js/tests/components/base/AppButton.test.ts
Normal file
38
resources/js/tests/components/base/AppButton.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AppButton } from '@/components/base/button';
|
||||
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('AppButton', () => {
|
||||
it('applies default sizing and variant styles', () => {
|
||||
renderWithProviders(AppButton, { slots: { default: 'Submit' } });
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Submit' });
|
||||
|
||||
expect(button.className).toContain('bg-zinc-900');
|
||||
expect(button.className).toContain('h-9');
|
||||
});
|
||||
|
||||
it('applies requested variant styles', () => {
|
||||
renderWithProviders(AppButton, {
|
||||
props: { variant: 'destructive' },
|
||||
slots: { default: 'Delete' },
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Delete' });
|
||||
|
||||
expect(button.className).toContain('bg-red-500');
|
||||
expect(button.className).toContain('dark:bg-red-900');
|
||||
});
|
||||
|
||||
it('merges custom classes with size styles', () => {
|
||||
renderWithProviders(AppButton, {
|
||||
props: { size: 'lg', class: 'tracking-wide' },
|
||||
slots: { default: 'Large' },
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Large' });
|
||||
|
||||
expect(button.className).toContain('h-10');
|
||||
expect(button.className).toContain('tracking-wide');
|
||||
});
|
||||
});
|
||||
56
resources/js/tests/components/base/AppInput.test.ts
Normal file
56
resources/js/tests/components/base/AppInput.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import AppInput from '@/components/base/input/AppInput.vue';
|
||||
import { ValueGeneratorCommandOpenMethod } from '@/interfaces/ui';
|
||||
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const openCommand = vi.fn();
|
||||
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useValueGeneratorStore: () => ({
|
||||
openCommand,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('AppInput', () => {
|
||||
beforeEach(() => {
|
||||
openCommand.mockClear();
|
||||
});
|
||||
|
||||
it('syncs model value updates', async () => {
|
||||
const { user } = renderWithProviders(AppInput, {
|
||||
props: { modelValue: 'initial' },
|
||||
});
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, 'updated');
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe('updated');
|
||||
});
|
||||
|
||||
it('triggers generator command on double shift', async () => {
|
||||
renderWithProviders(AppInput);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
const nowSpy = vi.spyOn(Date, 'now');
|
||||
nowSpy.mockReturnValueOnce(1000).mockReturnValueOnce(1200);
|
||||
|
||||
await fireEvent.keyDown(input, { key: 'Shift' });
|
||||
|
||||
await fireEvent.keyDown(input, { key: 'Shift' });
|
||||
|
||||
expect(openCommand).toHaveBeenCalledWith(
|
||||
input,
|
||||
ValueGeneratorCommandOpenMethod.SHIFT_SHIFT,
|
||||
);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { AppButton } from '@/components/base/button';
|
||||
import { mountWithPlugins } from '@/tests/_utils/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('AppButton Snapshots', () => {
|
||||
it('renders default button snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
slots: { default: 'Default Button' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders destructive variant snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { variant: 'destructive' },
|
||||
slots: { default: 'Delete Button' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders outline variant snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { variant: 'outline' },
|
||||
slots: { default: 'Outline Button' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders large size snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { size: 'lg' },
|
||||
slots: { default: 'Large Button' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders icon size snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { size: 'icon' },
|
||||
slots: { default: '🚀' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders disabled button snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { disabled: true },
|
||||
slots: { default: 'Disabled Button' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders button as link snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { as: 'a', href: 'https://example.com' },
|
||||
slots: { default: 'Link Button' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders button with custom class snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { class: 'custom-button-class' },
|
||||
slots: { default: 'Custom Button' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders all variants snapshot', () => {
|
||||
const variants = [
|
||||
'default',
|
||||
'destructive',
|
||||
'outline',
|
||||
'secondary',
|
||||
'ghost',
|
||||
'link',
|
||||
] as const;
|
||||
|
||||
variants.forEach(variant => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { variant },
|
||||
slots: { default: `${variant} button` },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot(`button-variant-${variant}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all sizes snapshot', () => {
|
||||
const sizes = ['xs', 'sm', 'default', 'lg', 'icon'] as const;
|
||||
|
||||
sizes.forEach(size => {
|
||||
const wrapper = mountWithPlugins(AppButton, {
|
||||
props: { size },
|
||||
slots: { default: `${size} button` },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot(`button-size-${size}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { AppInput } from '@/components/base/input';
|
||||
import { mountWithPlugins } from '@/tests/_utils/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('AppInput Snapshots', () => {
|
||||
it('renders default input snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput);
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders input with value snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { modelValue: 'test value' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders email input snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { type: 'email' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders disabled input snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { disabled: true },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders input with placeholder snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { placeholder: 'Enter your text here' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders input with custom class snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { class: 'custom-input-class' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders file input snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { type: 'file' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders number input snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { type: 'number', modelValue: 42 },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders input with defaultValue snapshot', () => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { defaultValue: 'default text' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders all input types snapshot', () => {
|
||||
const types = [
|
||||
'text',
|
||||
'email',
|
||||
'password',
|
||||
'number',
|
||||
'tel',
|
||||
'url',
|
||||
'search',
|
||||
'file',
|
||||
] as const;
|
||||
|
||||
types.forEach(type => {
|
||||
const wrapper = mountWithPlugins(AppInput, {
|
||||
props: { type },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot(`input-type-${type}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AppButton Snapshots > renders all sizes snapshot > button-size-default 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 px-4 py-2\\">default button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all sizes snapshot > button-size-icon 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 w-9\\">icon button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all sizes snapshot > button-size-lg 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-10 rounded-sm px-8\\">lg button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all sizes snapshot > button-size-sm 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-8 rounded-sm px-3 text-xs\\">sm button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all sizes snapshot > button-size-xs 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-6 text-xs rounded-sm px-2 [&_svg]:size-3\\">xs button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all variants snapshot > button-variant-default 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 px-4 py-2\\">default button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all variants snapshot > button-variant-destructive 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-red-500 text-zinc-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90 h-9 px-4 py-2\\">destructive button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all variants snapshot > button-variant-ghost 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 h-9 px-4 py-2\\">ghost button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all variants snapshot > button-variant-link 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50 h-9 px-4 py-2\\">link button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all variants snapshot > button-variant-outline 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 border border-zinc-200 bg-white shadow-sm hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 h-9 px-4 py-2\\">outline button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders all variants snapshot > button-variant-secondary 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-100 text-zinc-900 shadow-sm hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80 h-9 px-4 py-2\\">secondary button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders button as link snapshot 1`] = `"<a class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 px-4 py-2\\" href=\\"https://example.com\\">Link Button</a>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders button with custom class snapshot 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 px-4 py-2 custom-button-class\\">Custom Button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders default button snapshot 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 px-4 py-2\\">Default Button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders destructive variant snapshot 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-red-500 text-zinc-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90 h-9 px-4 py-2\\">Delete Button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders disabled button snapshot 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 px-4 py-2\\" disabled=\\"\\">Disabled Button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders icon size snapshot 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-9 w-9\\">🚀</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders large size snapshot 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 bg-zinc-900 text-zinc-50 shadow-sm hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90 h-10 rounded-sm px-8\\">Large Button</button>"`;
|
||||
|
||||
exports[`AppButton Snapshots > renders outline variant snapshot 1`] = `"<button class=\\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300 border border-zinc-200 bg-white shadow-sm hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 h-9 px-4 py-2\\">Outline Button</button>"`;
|
||||
@@ -1,35 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-email 1`] = `"<input type=\\"email\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-file 1`] = `"<input type=\\"file\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-number 1`] = `"<input type=\\"number\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-password 1`] = `"<input type=\\"password\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-search 1`] = `"<input type=\\"search\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-tel 1`] = `"<input type=\\"tel\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-text 1`] = `"<input type=\\"text\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders all input types snapshot > input-type-url 1`] = `"<input type=\\"url\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders default input snapshot 1`] = `"<input type=\\"text\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders disabled input snapshot 1`] = `"<input type=\\"text\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\" disabled=\\"\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders email input snapshot 1`] = `"<input type=\\"email\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders file input snapshot 1`] = `"<input type=\\"file\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders input with custom class snapshot 1`] = `"<input type=\\"text\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300 custom-input-class\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders input with defaultValue snapshot 1`] = `"<input type=\\"text\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders input with placeholder snapshot 1`] = `"<input type=\\"text\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\" placeholder=\\"Enter your text here\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders input with value snapshot 1`] = `"<input type=\\"text\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
|
||||
exports[`AppInput Snapshots > renders number input snapshot 1`] = `"<input type=\\"number\\" class=\\"flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300\\">"`;
|
||||
@@ -1,185 +1,199 @@
|
||||
import KeyValueParameters from '@/components/common/KeyValueParameters/KeyValueParameters.vue';
|
||||
import { mountWithPlugins } from '@/tests/_utils/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
|
||||
import { fireEvent, getByTestId } from '@testing-library/vue';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { computed, nextTick, Ref, ref } from 'vue';
|
||||
|
||||
// Mock the composables and stores
|
||||
const mockKeyValueComposable = {
|
||||
parameters: [
|
||||
{
|
||||
id: '1',
|
||||
key: 'test-key',
|
||||
value: 'test-value',
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
deleting: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'another-key',
|
||||
value: 'another-value',
|
||||
enabled: false,
|
||||
type: 'text',
|
||||
deleting: false,
|
||||
},
|
||||
],
|
||||
deletingAll: false,
|
||||
areAllParametersDisabled: false,
|
||||
addNewEmptyParameter: vi.fn(),
|
||||
toggleAllParametersEnabledState: vi.fn(),
|
||||
triggerParameterDeletion: vi.fn(),
|
||||
deleteAllParameters: vi.fn(),
|
||||
isParameterMarkedForDeletion: vi.fn(),
|
||||
};
|
||||
const parameters: Ref<
|
||||
Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
}>
|
||||
> = ref([
|
||||
{
|
||||
id: '1',
|
||||
key: 'test-key',
|
||||
value: 'test-value',
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'another-key',
|
||||
value: 'another-value',
|
||||
enabled: false,
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
|
||||
const mockValueGeneratorStore = {
|
||||
openCommand: vi.fn(),
|
||||
closeCommand: vi.fn(),
|
||||
};
|
||||
const deletingAll = ref(false);
|
||||
const areAllDisabledRef = ref(false);
|
||||
|
||||
const addNewEmptyParameter = vi.fn();
|
||||
const toggleAllParametersEnabledState = vi.fn();
|
||||
const triggerParameterDeletion = vi.fn();
|
||||
const deleteAllParameters = vi.fn();
|
||||
const isParameterMarkedForDeletion = vi.fn(() => false);
|
||||
|
||||
const openCommand = vi.fn();
|
||||
const closeCommand = vi.fn();
|
||||
|
||||
vi.mock('@/composables/ui/useKeyValueParameters', () => ({
|
||||
useKeyValueParameters: () => mockKeyValueComposable,
|
||||
useKeyValueParameters: () => ({
|
||||
parameters,
|
||||
deletingAll,
|
||||
areAllParametersDisabled: computed(() => areAllDisabledRef.value),
|
||||
addNewEmptyParameter,
|
||||
toggleAllParametersEnabledState,
|
||||
triggerParameterDeletion,
|
||||
deleteAllParameters,
|
||||
isParameterMarkedForDeletion,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useValueGeneratorStore: () => mockValueGeneratorStore,
|
||||
}));
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
const componentFactory = (props = {}) =>
|
||||
mountWithPlugins(KeyValueParameters, {
|
||||
props: { modelValue: [], ...props },
|
||||
});
|
||||
|
||||
describe('KeyValueParameters - Rendering', () => {
|
||||
it('renders component container', () => {
|
||||
const wrapper = componentFactory();
|
||||
expect(wrapper.find('[data-testid="kv-container"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders type selector when freeFormTypes is enabled', () => {
|
||||
const wrapper = componentFactory({ freeFormTypes: true });
|
||||
expect(wrapper.find('[data-testid="type-selector"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders parameters list correctly', () => {
|
||||
const wrapper = componentFactory();
|
||||
const rows = wrapper.findAll('[data-testid="parameter-row"]');
|
||||
expect(rows).toHaveLength(2); // based on mock data
|
||||
});
|
||||
|
||||
it('renders header action buttons', () => {
|
||||
const wrapper = componentFactory();
|
||||
expect(wrapper.find('[data-testid="add-button"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="enable-all-button"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="delete-all-button"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('applies red styling to Delete All button when deletingAll is true', () => {
|
||||
mockKeyValueComposable.deletingAll = true;
|
||||
|
||||
const deleteButton = componentFactory().find('[data-testid="delete-all-button"]');
|
||||
expect(deleteButton.classes()).toContain('!text-red-500');
|
||||
expect(deleteButton.classes()).toContain('dark:!text-rose-700');
|
||||
expect(deleteButton.classes()).toContain('hover:text-red-500');
|
||||
expect(deleteButton.classes()).toContain('dark:hover:text-red-700');
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
useValueGeneratorStore: () => ({
|
||||
openCommand,
|
||||
closeCommand,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('KeyValueParameters - Actions', () => {
|
||||
it('calls addNewEmptyParameter when Add button is clicked', async () => {
|
||||
const wrapper = componentFactory();
|
||||
await wrapper.find('[data-testid="add-button"]').trigger('click');
|
||||
expect(wrapper.vm.addNewEmptyParameter).toHaveBeenCalled();
|
||||
const renderComponent = (props: Record<string, unknown> = {}) =>
|
||||
renderWithProviders(KeyValueParameters, {
|
||||
props: {
|
||||
modelValue: [],
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
it('calls toggleAllParametersEnabledState when Enable/Disable All clicked', async () => {
|
||||
const wrapper = componentFactory();
|
||||
await wrapper.find('[data-testid="enable-all-button"]').trigger('click');
|
||||
expect(wrapper.vm.toggleAllParametersEnabledState).toHaveBeenCalled();
|
||||
describe('KeyValueParameters', () => {
|
||||
beforeEach(() => {
|
||||
parameters.value = [
|
||||
{
|
||||
id: '1',
|
||||
key: 'test-key',
|
||||
value: 'test-value',
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'another-key',
|
||||
value: 'another-value',
|
||||
enabled: false,
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
deletingAll.value = false;
|
||||
areAllDisabledRef.value = false;
|
||||
|
||||
addNewEmptyParameter.mockClear();
|
||||
toggleAllParametersEnabledState.mockClear();
|
||||
triggerParameterDeletion.mockClear();
|
||||
deleteAllParameters.mockClear();
|
||||
isParameterMarkedForDeletion.mockReset();
|
||||
openCommand.mockClear();
|
||||
closeCommand.mockClear();
|
||||
});
|
||||
|
||||
it('calls deleteAllParameters when Delete All clicked', async () => {
|
||||
const wrapper = componentFactory();
|
||||
await wrapper.find('[data-testid="delete-all-button"]').trigger('click');
|
||||
expect(wrapper.vm.deleteAllParameters).toHaveBeenCalled();
|
||||
it('renders parameters and header controls', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByTestId('kv-container')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('parameter-row')).toHaveLength(2);
|
||||
expect(screen.getByTestId('add-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('enable-all-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('delete-all-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct text for Enable/Disable All button', () => {
|
||||
mockKeyValueComposable.areAllParametersDisabled = true;
|
||||
expect(componentFactory().text()).toContain('Enable All');
|
||||
it('shows type selector when freeFormTypes enabled', () => {
|
||||
renderComponent({ freeFormTypes: true });
|
||||
|
||||
mockKeyValueComposable.areAllParametersDisabled = false;
|
||||
expect(componentFactory().text()).toContain('Disable All');
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeyValueParameters - Focus/Blur & Generator', () => {
|
||||
it('handles input focus correctly', async () => {
|
||||
const wrapper = componentFactory();
|
||||
const input = wrapper.find('[data-testid="kv-value"]');
|
||||
await input.trigger('focus');
|
||||
expect(wrapper.vm.focusedInputIndex).toBe(0);
|
||||
expect(wrapper.vm.focusedInputRef).toBe(input.element);
|
||||
expect(screen.getAllByTestId('type-selector')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles input blur correctly', async () => {
|
||||
const wrapper = componentFactory();
|
||||
wrapper.vm.focusedInputIndex = 1;
|
||||
wrapper.vm.focusedInputRef = document.createElement('input');
|
||||
const input = wrapper.find('[data-testid="kv-value"]');
|
||||
await input.trigger('blur');
|
||||
expect(wrapper.vm.focusedInputIndex).toBeNull();
|
||||
expect(wrapper.vm.focusedInputRef).toBeNull();
|
||||
it('invokes composable actions for header buttons', async () => {
|
||||
renderComponent();
|
||||
|
||||
await fireEvent.click(screen.getByTestId('add-button'));
|
||||
|
||||
await fireEvent.click(screen.getByTestId('enable-all-button'));
|
||||
|
||||
await fireEvent.click(screen.getByTestId('delete-all-button'));
|
||||
|
||||
expect(addNewEmptyParameter).toHaveBeenCalled();
|
||||
expect(toggleAllParametersEnabledState).toHaveBeenCalled();
|
||||
expect(deleteAllParameters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents blur when focus moves to value generator menu', async () => {
|
||||
const wrapper = componentFactory();
|
||||
it('updates enable button label based on disabled state', async () => {
|
||||
renderComponent();
|
||||
|
||||
const dummyInput = document.createElement('input');
|
||||
expect(screen.getByTestId('enable-all-button')).toHaveTextContent('Disable All');
|
||||
|
||||
wrapper.vm.focusedInputIndex = 0;
|
||||
wrapper.vm.focusedInputRef = dummyInput;
|
||||
|
||||
const mockTarget = document.createElement('div');
|
||||
mockTarget.setAttribute('data-ValueGenerator-focus-hook', '');
|
||||
mockTarget.closest = vi.fn().mockReturnValue(mockTarget);
|
||||
|
||||
const input = wrapper.find('[data-testid="kv-value"]');
|
||||
await input.trigger('blur', { relatedTarget: mockTarget });
|
||||
|
||||
expect(wrapper.vm.focusedInputIndex).toBe(0);
|
||||
expect(wrapper.vm.focusedInputRef).toBe(dummyInput);
|
||||
});
|
||||
|
||||
it('handles generator click correctly', async () => {
|
||||
const wrapper = componentFactory();
|
||||
const input = wrapper.find('[data-testid="kv-value"]');
|
||||
|
||||
wrapper.vm.focusedInputIndex = 0;
|
||||
wrapper.vm.focusedInputRef = input;
|
||||
areAllDisabledRef.value = true;
|
||||
|
||||
await nextTick();
|
||||
|
||||
await wrapper
|
||||
.find('[data-testid="generator-button"]')
|
||||
.trigger('mousedown', { preventDefault: vi.fn() });
|
||||
expect(wrapper.vm.openCommand).toHaveBeenCalledWith(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeyValueParameters - v-model', () => {
|
||||
it('handles v-model updates correctly', async () => {
|
||||
const initialValue = [
|
||||
{ key: 'test', value: 'value', enabled: true, type: 'text' },
|
||||
];
|
||||
const wrapper = componentFactory({ modelValue: initialValue });
|
||||
expect(wrapper.vm.modelRef).toEqual(initialValue);
|
||||
|
||||
const newValue = [
|
||||
...initialValue,
|
||||
{ key: 'new', value: 'new-value', enabled: true, type: 'text' },
|
||||
];
|
||||
await wrapper.setProps({ modelValue: newValue });
|
||||
expect(wrapper.vm.modelRef).toEqual(newValue);
|
||||
expect(screen.getByTestId('enable-all-button')).toHaveTextContent('Enable All');
|
||||
});
|
||||
|
||||
it('displays generator button while value input focused and opens command', async () => {
|
||||
renderComponent();
|
||||
|
||||
const valueInputs = screen.getAllByTestId('kv-value');
|
||||
|
||||
await fireEvent.focus(valueInputs[0]);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const generatorButton = screen.getByTestId('generator-button');
|
||||
|
||||
await fireEvent.mouseDown(generatorButton);
|
||||
|
||||
expect(openCommand).toHaveBeenCalledWith(valueInputs[0]);
|
||||
});
|
||||
|
||||
it('keeps generator open when blur moves into generator palette', async () => {
|
||||
renderComponent();
|
||||
|
||||
const valueInput = screen.getAllByTestId('kv-value')[0];
|
||||
|
||||
await fireEvent.focus(valueInput);
|
||||
|
||||
const relatedTarget = document.createElement('div');
|
||||
relatedTarget.setAttribute('data-ValueGenerator-focus-hook', '');
|
||||
|
||||
await fireEvent.blur(valueInput, { relatedTarget });
|
||||
|
||||
expect(closeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks delete button when parameter flagged for deletion', () => {
|
||||
isParameterMarkedForDeletion
|
||||
.mockReturnValueOnce(true) // <- First Parameter.
|
||||
.mockReturnValueOnce(false); // <- Second Parameter.
|
||||
|
||||
renderComponent();
|
||||
|
||||
const rows = screen.getAllByTestId('parameter-row');
|
||||
|
||||
const firstDeleteIcon = getByTestId(rows[0], 'delete-button').querySelector(
|
||||
'svg',
|
||||
);
|
||||
const secondDeleteIcon = getByTestId(rows[1], 'delete-button').querySelector(
|
||||
'svg',
|
||||
);
|
||||
|
||||
expect(firstDeleteIcon?.classList).toContain('text-rose-500');
|
||||
expect(secondDeleteIcon?.classList ?? '').not.toContain('text-rose-500');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,292 +1,137 @@
|
||||
import ValueGenerator from '@/components/common/ValueGenerator/ValueGenerator.vue';
|
||||
import { mountWithPlugins } from '@/tests/_utils/test-utils';
|
||||
import { testBothThemes } from '@/tests/_utils/themes-test-utils';
|
||||
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { Mock } from '@vitest/spy';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick, Reactive, reactive } from 'vue';
|
||||
import { nextTick, reactive } from 'vue';
|
||||
|
||||
const mockStore: Reactive<{
|
||||
const restoreScrollPosition = vi.fn(() => Promise.resolve());
|
||||
|
||||
const mockStore = reactive<{
|
||||
isCommandOpen: boolean;
|
||||
currentInputRef: HTMLElement | null;
|
||||
generateValue: Mock;
|
||||
closeCommand: Mock;
|
||||
restoreCommandState: Mock;
|
||||
openCommand: Mock;
|
||||
commandState: { recentGenerators: string[] };
|
||||
recentGenerators: string[];
|
||||
}> = reactive({
|
||||
restoreCommandState: Mock;
|
||||
}>({
|
||||
isCommandOpen: false,
|
||||
currentInputRef: null,
|
||||
generateValue: vi.fn(),
|
||||
closeCommand: vi.fn(),
|
||||
restoreCommandState: vi.fn(),
|
||||
openCommand: vi.fn(),
|
||||
commandState: { recentGenerators: [] },
|
||||
recentGenerators: [],
|
||||
restoreCommandState: vi.fn(),
|
||||
});
|
||||
|
||||
const mockComposable = {
|
||||
restoreScrollPosition: vi.fn(),
|
||||
};
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useValueGeneratorStore: () => mockStore,
|
||||
}));
|
||||
|
||||
vi.mock('@/composables', () => ({
|
||||
useTabHorizontalScroll: () => mockComposable,
|
||||
}));
|
||||
|
||||
// Mock DOM methods
|
||||
const mockGetBoundingClientRect = vi.fn(() => ({
|
||||
top: 100,
|
||||
bottom: 120,
|
||||
left: 50,
|
||||
right: 200,
|
||||
width: 150,
|
||||
height: 20,
|
||||
}));
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
value: mockGetBoundingClientRect,
|
||||
return {
|
||||
...actual,
|
||||
useValueGeneratorStore: () => mockStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/ui/useTabHorizontalScroll', () => ({
|
||||
useTabHorizontalScroll: () => ({
|
||||
restoreScrollPosition,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/common/ValueGenerator/ValueGeneratorGeneratorList.vue', () => ({
|
||||
default: {
|
||||
name: 'ValueGeneratorGeneratorList',
|
||||
emits: ['generator-selected'],
|
||||
template:
|
||||
'<button data-testid="trigger-generator" @click="$emit(\'generator-selected\', \'email\')">Generate</button>',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ValueGenerator', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.isCommandOpen = false;
|
||||
mockStore.currentInputRef = null;
|
||||
mockStore.generateValue.mockReset();
|
||||
mockStore.closeCommand.mockReset();
|
||||
mockComposable.restoreScrollPosition.mockReset();
|
||||
mockStore.restoreCommandState.mockReset();
|
||||
});
|
||||
|
||||
it('renders nothing when command is closed', () => {
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
it('does not render overlay when command is closed', () => {
|
||||
renderWithProviders(ValueGenerator);
|
||||
|
||||
expect(wrapper.find('.fixed.inset-0').exists()).toBe(false);
|
||||
expect(screen.queryByTestId('value-generator-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders command interface when open', async () => {
|
||||
it('opens overlay and focuses command input when store toggles', async () => {
|
||||
renderWithProviders(ValueGenerator);
|
||||
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('.fixed.inset-0').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-slot="command-input"]').exists()).toBe(true);
|
||||
const overlay = await screen.findByTestId('value-generator-overlay');
|
||||
|
||||
expect(overlay).toBeInTheDocument();
|
||||
expect(restoreScrollPosition).toHaveBeenCalled();
|
||||
expect(screen.getByPlaceholderText('Search generators...')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('closes command when clicking outside', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
it('closes when backdrop is clicked', async () => {
|
||||
renderWithProviders(ValueGenerator);
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('.fixed.inset-0').trigger('click');
|
||||
const overlay = await screen.findByTestId('value-generator-overlay');
|
||||
|
||||
await fireEvent.click(overlay);
|
||||
|
||||
expect(mockStore.closeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not close command when clicking inside', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
it('propagates generated values to inputs and emits event', async () => {
|
||||
const onValueGenerated = vi.fn();
|
||||
const input = document.createElement('input');
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
await nextTick();
|
||||
mockStore.generateValue.mockReturnValue('generated-value');
|
||||
mockStore.currentInputRef = input;
|
||||
|
||||
await wrapper.find('.absolute.w-full.max-w-md').trigger('click');
|
||||
|
||||
expect(mockStore.closeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes command on escape key', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const command = wrapper.findComponent({ name: 'AppCommand' });
|
||||
await command.trigger('keydown.escape');
|
||||
|
||||
expect(mockStore.closeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calculates command position correctly when input ref is provided', async () => {
|
||||
mockStore.currentInputRef = document.createElement('input');
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const commandContent = wrapper.find('.absolute.w-full.max-w-md');
|
||||
const style = commandContent.attributes('style')!;
|
||||
|
||||
expect(style).toContain('top: 124px');
|
||||
expect(style).toContain('left: 50px');
|
||||
expect(style).toContain('transform: none');
|
||||
});
|
||||
|
||||
it('positions command above input when no space below', async () => {
|
||||
Object.defineProperty(window, 'innerHeight', { value: 200 });
|
||||
|
||||
mockStore.currentInputRef = document.createElement('input');
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const commandContent = wrapper.find('.absolute.w-full.max-w-md');
|
||||
const style = commandContent.attributes('style')!;
|
||||
|
||||
expect(style).toContain('top: -304px');
|
||||
});
|
||||
|
||||
it('uses center position when no input ref', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const commandContent = wrapper.find('.absolute.w-full.max-w-md');
|
||||
const style = commandContent.attributes('style')!;
|
||||
|
||||
expect(style).toContain('top: 50%');
|
||||
expect(style).toContain('left: 50%');
|
||||
expect(style).toContain('transform: translate(-50%, -50%)');
|
||||
});
|
||||
|
||||
it('handles generator selection', async () => {
|
||||
const mockValue = 'generated-value';
|
||||
|
||||
mockStore.generateValue.mockReturnValue(mockValue);
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
|
||||
mockInput.value = '';
|
||||
mockStore.currentInputRef = mockInput;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const generatorList = wrapper.findComponent({
|
||||
name: 'ValueGeneratorGeneratorList',
|
||||
renderWithProviders(ValueGenerator, {
|
||||
props: { onValueGenerated },
|
||||
});
|
||||
|
||||
await generatorList.vm.$emit('generator-selected', 'test-generator');
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
expect(mockStore.generateValue).toHaveBeenCalledWith('test-generator');
|
||||
expect(mockInput.value).toBe(mockValue);
|
||||
expect(wrapper.emitted('valueGenerated')?.[0]).toEqual([mockValue]);
|
||||
await nextTick();
|
||||
|
||||
const trigger = await screen.findByTestId('trigger-generator');
|
||||
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
expect(mockStore.generateValue).toHaveBeenCalledWith('email');
|
||||
expect(input.value).toBe('generated-value');
|
||||
expect(onValueGenerated).toHaveBeenCalledWith('generated-value');
|
||||
expect(mockStore.closeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not focus when command is closed', async () => {
|
||||
mockStore.isCommandOpen = false;
|
||||
mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockComposable.restoreScrollPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip('focuses command input when command opens', async () => {
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
it('closes command when escape is pressed inside command container', async () => {
|
||||
renderWithProviders(ValueGenerator);
|
||||
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
await nextTick();
|
||||
|
||||
// TODO [Test] Make this test works. Problem: the following is not passing.
|
||||
await expect
|
||||
.poll(() => wrapper.element.querySelector('[data-slot="command-input"]'), {
|
||||
timeout: 300,
|
||||
})
|
||||
.toHaveFocus();
|
||||
});
|
||||
|
||||
it('renders all child components', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'AppCommand' }).exists()).toBe(true);
|
||||
|
||||
expect(wrapper.findComponent({ name: 'AppCommandInput' }).exists()).toBe(true);
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'ValueGeneratorCommandKeepAlive' }).exists(),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'ValueGeneratorCategoryFilters' }).exists(),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'ValueGeneratorGeneratorList' }).exists(),
|
||||
).toBe(true);
|
||||
|
||||
expect(wrapper.findComponent({ name: 'ValueGeneratorFooter' }).exists()).toBe(
|
||||
true,
|
||||
const focusHookContainer = await screen.findByTestId(
|
||||
'value-generator-focus-hook',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies correct CSS classes', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
await fireEvent.keyDown(focusHookContainer, { key: 'Escape' });
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const overlay = wrapper.find('.fixed.inset-0');
|
||||
expect(overlay.classes()).toContain('fixed');
|
||||
expect(overlay.classes()).toContain('inset-0');
|
||||
expect(overlay.classes()).toContain('z-50');
|
||||
|
||||
const commandContent = wrapper.find('.absolute.w-full.max-w-md');
|
||||
expect(commandContent.classes()).toContain('absolute');
|
||||
expect(commandContent.classes()).toContain('w-full');
|
||||
expect(commandContent.classes()).toContain('max-w-md');
|
||||
});
|
||||
|
||||
describe('Dark Theme Support', () => {
|
||||
testBothThemes(ValueGenerator, wrapper => {
|
||||
expect(wrapper.find('.fixed.inset-0').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders command interface with proper theming when open', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const commandContent = wrapper.find('.absolute.w-full.max-w-md');
|
||||
expect(commandContent.exists()).toBe(true);
|
||||
|
||||
const command = wrapper.findComponent({ name: 'AppCommand' });
|
||||
expect(command.classes()).toContain('rounded-lg');
|
||||
expect(command.classes()).toContain('border');
|
||||
expect(command.classes()).toContain('shadow-md');
|
||||
});
|
||||
|
||||
it('maintains consistent behavior across themes', async () => {
|
||||
mockStore.isCommandOpen = true;
|
||||
|
||||
const wrapper = mountWithPlugins(ValueGenerator);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('.fixed.inset-0').exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'AppCommand' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'AppCommandInput' }).exists()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
expect(mockStore.closeCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,213 +1,51 @@
|
||||
import RequestBuilder from '@/components/domain/Client/Request/RequestBuilder.vue';
|
||||
import { mountWithPlugins } from '@/tests/_utils/test-utils';
|
||||
import { VueWrapper } from '@vue/test-utils';
|
||||
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
// Mock the child components
|
||||
vi.mock('@/components/domain/Client/Request', () => ({
|
||||
RequestBuilderEndpoint: {
|
||||
name: 'RequestBuilderEndpoint',
|
||||
template:
|
||||
'<div data-testid="request-builder-endpoint">Request Builder Endpoint</div>',
|
||||
template: '<div data-testid="request-builder-endpoint">Endpoint</div>',
|
||||
},
|
||||
RequestParameters: {
|
||||
name: 'RequestParameters',
|
||||
template: '<div data-testid="request-parameters">Request Parameters</div>',
|
||||
template: '<div data-testid="request-parameters">Parameters Panel</div>',
|
||||
},
|
||||
RequestBody: {
|
||||
name: 'RequestBody',
|
||||
template: '<div data-testid="request-body">Request Body</div>',
|
||||
template: '<div data-testid="request-body">Body Panel</div>',
|
||||
},
|
||||
RequestAuthorization: {
|
||||
name: 'RequestAuthorization',
|
||||
template: '<div data-testid="request-authorization">Request Authorization</div>',
|
||||
template: '<div data-testid="request-authorization">Authorization Panel</div>',
|
||||
},
|
||||
RequestHeaders: {
|
||||
name: 'RequestHeaders',
|
||||
template: '<div data-testid="request-headers">Request Headers</div>',
|
||||
template: '<div data-testid="request-headers">Headers Panel</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('RequestBuilder', () => {
|
||||
it('renders the main container with correct classes', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
it('renders endpoint selector and body tab by default', () => {
|
||||
renderWithProviders(RequestBuilder);
|
||||
|
||||
const container = wrapper.find('[data-testid="request-builder-root"]');
|
||||
expect(container.exists()).toBe(true);
|
||||
expect(screen.getByTestId('request-builder-endpoint')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('request-body')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders with custom class prop', () => {
|
||||
const customClass = 'custom-request-builder';
|
||||
const wrapper = mountWithPlugins(RequestBuilder, {
|
||||
props: { class: customClass },
|
||||
});
|
||||
it('switches between panels when different tabs are selected', async () => {
|
||||
const { user } = renderWithProviders(RequestBuilder);
|
||||
|
||||
const container = wrapper.find('[data-testid="request-builder-root"]');
|
||||
expect(container.classes()).toContain(customClass);
|
||||
});
|
||||
await user.click(screen.getByRole('tab', { name: 'Parameters' }));
|
||||
|
||||
it('renders RequestBuilderEndpoint component', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
expect(screen.getByTestId('request-parameters')).toBeVisible();
|
||||
|
||||
const endpoint = wrapper.findComponent({
|
||||
name: 'RequestBuilderEndpoint',
|
||||
});
|
||||
expect(endpoint.exists()).toBe(true);
|
||||
expect(endpoint.classes()).toContain('h-toolbar');
|
||||
expect(endpoint.classes()).toContain('border-b');
|
||||
});
|
||||
await user.click(screen.getByRole('tab', { name: 'Authorization' }));
|
||||
|
||||
it('renders AppTabs with correct configuration', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
expect(screen.getByTestId('request-authorization')).toBeVisible();
|
||||
|
||||
const tabs = wrapper.findComponent({ name: 'AppTabs' });
|
||||
expect(tabs.exists()).toBe(true);
|
||||
expect(tabs.props('defaultValue')).toBe('body');
|
||||
expect(tabs.classes()).toContain('mt-0');
|
||||
expect(tabs.classes()).toContain('flex');
|
||||
expect(tabs.classes()).toContain('overflow-hidden');
|
||||
expect(tabs.classes()).toContain('flex-1');
|
||||
expect(tabs.classes()).toContain('flex-col');
|
||||
});
|
||||
await user.click(screen.getByRole('tab', { name: 'Headers' }));
|
||||
|
||||
it('renders tabs list with correct structure', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
|
||||
const tabsList = wrapper.findComponent({ name: 'AppTabsList' });
|
||||
expect(tabsList.exists()).toBe(true);
|
||||
expect(tabsList.classes()).toContain('h-toolbar');
|
||||
expect(tabsList.classes()).toContain('px-panel');
|
||||
expect(tabsList.classes()).toContain('rounded-none');
|
||||
});
|
||||
|
||||
it('renders all tab triggers with correct labels', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
|
||||
const tabTriggers = wrapper.findAllComponents({
|
||||
name: 'AppTabsTrigger',
|
||||
});
|
||||
expect(tabTriggers).toHaveLength(4);
|
||||
|
||||
const expectedLabels = ['Parameters', 'Body', 'Authorization', 'Headers'];
|
||||
const expectedValues = ['parameters', 'body', 'authorization', 'headers'];
|
||||
|
||||
tabTriggers.forEach((trigger, index) => {
|
||||
expect(trigger.props('label')).toBe(expectedLabels[index]);
|
||||
expect(trigger.props('value')).toBe(expectedValues[index]);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all tab content components', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
|
||||
const tabContents = wrapper.findAllComponents({
|
||||
name: 'AppTabsContent',
|
||||
});
|
||||
expect(tabContents).toHaveLength(4);
|
||||
|
||||
const expectedValues = ['parameters', 'body', 'authorization', 'headers'];
|
||||
|
||||
tabContents.forEach((content, index) => {
|
||||
expect(content.props('value')).toBe(expectedValues[index]);
|
||||
expect(content.classes()).toContain('mt-0');
|
||||
expect(content.classes()).toContain('max-h-full');
|
||||
expect(content.classes()).toContain('min-h-0');
|
||||
expect(content.classes()).toContain('flex');
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'Parameters', expectedComponent: 'RequestParameters' },
|
||||
{ label: 'Body', expectedComponent: 'RequestBody' },
|
||||
{ label: 'Authorization', expectedComponent: 'RequestAuthorization' },
|
||||
{ label: 'Headers', expectedComponent: 'RequestHeaders' },
|
||||
])(
|
||||
'renders $expectedComponent in parameters tab',
|
||||
async ({ label, expectedComponent }) => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
|
||||
const tabTrigger = wrapper
|
||||
.findAllComponents({ name: 'AppTabsTrigger' })
|
||||
.find((element: VueWrapper) => element.attributes()['label'] === label);
|
||||
|
||||
await (tabTrigger as VueWrapper).trigger('mousedown');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(tabTrigger?.attributes()['data-state']).toEqual('active');
|
||||
|
||||
const actualComponent = wrapper.findComponent({
|
||||
name: expectedComponent,
|
||||
});
|
||||
expect(actualComponent.exists()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('has correct tab container styling', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
|
||||
const tabContainer = wrapper.find('[data-testid="app-tabs-container"]');
|
||||
expect(tabContainer.exists()).toBe(true);
|
||||
|
||||
const container = wrapper.find('[data-testid="request-builder-root"]');
|
||||
expect(container.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards asChild prop correctly', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder, {
|
||||
props: { asChild: true },
|
||||
});
|
||||
|
||||
// The component should render normally even with asChild prop
|
||||
expect(wrapper.find('[data-testid="request-builder-root"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('handles missing props gracefully', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder, {
|
||||
props: {},
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="request-builder-root"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'RequestBuilderEndpoint' }).exists()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('maintains proper component hierarchy', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
|
||||
// Check the main structure
|
||||
const mainContainer = wrapper.find('[data-testid="request-builder-root"]');
|
||||
expect(mainContainer.exists()).toBe(true);
|
||||
|
||||
// Check that endpoint is a direct child
|
||||
const endpoint = mainContainer.findComponent({
|
||||
name: 'RequestBuilderEndpoint',
|
||||
});
|
||||
expect(endpoint.exists()).toBe(true);
|
||||
|
||||
// Check that tabs container is a direct child
|
||||
const tabs = mainContainer.findComponent({ name: 'AppTabs' });
|
||||
expect(tabs.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders all components without errors', () => {
|
||||
const wrapper = mountWithPlugins(RequestBuilder);
|
||||
|
||||
// All main components should be present
|
||||
expect(wrapper.findComponent({ name: 'RequestBuilderEndpoint' }).exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.findComponent({ name: 'AppTabs' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'AppTabsList' }).exists()).toBe(true);
|
||||
expect(wrapper.findAllComponents({ name: 'AppTabsTrigger' })).toHaveLength(4);
|
||||
expect(wrapper.findAllComponents({ name: 'AppTabsContent' })).toHaveLength(4);
|
||||
expect(wrapper.findComponent({ name: 'RequestParameters' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'RequestBody' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'RequestAuthorization' }).exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.findComponent({ name: 'RequestHeaders' }).exists()).toBe(true);
|
||||
expect(screen.getByTestId('request-headers')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,138 +1,146 @@
|
||||
import ResponseStatus from '@/components/domain/Client/Response/ResponseStatus/ResponseStatus.vue';
|
||||
import { STATUS } from '@/interfaces/http';
|
||||
import { mountWithPlugins } from '@/tests/_utils/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||
import { nextTick, Reactive, reactive } from 'vue';
|
||||
|
||||
// Mock child to expose received props for assertions
|
||||
vi.mock(
|
||||
'@/components/domain/Client/Response/ResponseStatus/ResponseStatusCode.vue',
|
||||
() => ({
|
||||
default: {
|
||||
name: 'ResponseStatusCode',
|
||||
props: ['status', 'response'],
|
||||
template:
|
||||
'<div data-testid="status-code" :data-status="status">{{ status }}</div>',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock stores used via '@/stores'
|
||||
const mockRequestStore = reactive({
|
||||
pendingRequestData: computed(() => mockPendingRequest.value),
|
||||
const mockRequestStore: Reactive<{
|
||||
pendingRequestData: object | null;
|
||||
cancelCurrentRequest: MockedFunction<unknown>;
|
||||
}> = reactive({
|
||||
pendingRequestData: null,
|
||||
cancelCurrentRequest: vi.fn(),
|
||||
});
|
||||
|
||||
const mockHistoryStore = reactive({
|
||||
lastLog: ref<any>(null), // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockRequestsHistoryStore: Reactive<{
|
||||
lastLog: object | null;
|
||||
}> = reactive({
|
||||
lastLog: null,
|
||||
});
|
||||
|
||||
const mockPendingRequest = ref<any>(null); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useRequestStore: () => mockRequestStore,
|
||||
useRequestsHistoryStore: () => mockHistoryStore,
|
||||
}));
|
||||
return {
|
||||
...actual,
|
||||
useRequestStore: () => mockRequestStore,
|
||||
useRequestsHistoryStore: () => mockRequestsHistoryStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ResponseStatus', () => {
|
||||
beforeEach(() => {
|
||||
mockPendingRequest.value = null;
|
||||
mockHistoryStore.lastLog = ref(null);
|
||||
mockRequestStore.cancelCurrentRequest.mockReset();
|
||||
mockRequestStore.pendingRequestData = null;
|
||||
mockRequestsHistoryStore.lastLog = null;
|
||||
mockRequestStore.cancelCurrentRequest.mockClear();
|
||||
});
|
||||
|
||||
it('shows PENDING status and cancel button while processing', () => {
|
||||
mockPendingRequest.value = { isProcessing: true, durationInMs: 1234 };
|
||||
const wrapper = mountWithPlugins(ResponseStatus);
|
||||
it('shows pending status and cancel option while processing', async () => {
|
||||
mockRequestStore.pendingRequestData = { isProcessing: true, durationInMs: 1234 };
|
||||
|
||||
const statusCode = wrapper.get('[data-testid="status-code"]');
|
||||
expect(statusCode.attributes()['data-status']).toBe(String(STATUS.PENDING));
|
||||
renderWithProviders(ResponseStatus);
|
||||
|
||||
// Cancel button visible
|
||||
const cancel = wrapper.find('button');
|
||||
expect(cancel.exists()).toBe(true);
|
||||
expect(screen.queryByTestId('response-badge')).toBeNull();
|
||||
|
||||
expect(screen.getByTestId('pending-request-spinner')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows EMPTY when no last response and not processing', () => {
|
||||
mockPendingRequest.value = { isProcessing: false, durationInMs: 0 };
|
||||
const wrapper = mountWithPlugins(ResponseStatus);
|
||||
|
||||
const statusCode = wrapper.get('[data-testid="status-code"]');
|
||||
expect(statusCode.attributes()['data-status']).toBe(String(STATUS.EMPTY));
|
||||
});
|
||||
|
||||
it('derives status from last response when available', () => {
|
||||
mockPendingRequest.value = {
|
||||
it('shows empty status when nothing processed yet', async () => {
|
||||
mockRequestStore.pendingRequestData = {
|
||||
isProcessing: false,
|
||||
durationInMs: 0,
|
||||
wasExecuted: true,
|
||||
};
|
||||
mockHistoryStore.lastLog = ref({ response: { status: 201, sizeInBytes: 1024 } });
|
||||
const wrapper = mountWithPlugins(ResponseStatus);
|
||||
|
||||
const statusCode = wrapper.get('[data-testid="status-code"]');
|
||||
expect(statusCode.attributes()['data-status']).toBe('201');
|
||||
|
||||
expect(wrapper.text()).toMatch(/1.02kB/);
|
||||
});
|
||||
|
||||
it('resets size to 0 when request not executed (new endpoint)', () => {
|
||||
mockPendingRequest.value = {
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
};
|
||||
mockHistoryStore.lastLog = ref({ response: { sizeInBytes: 12345 } });
|
||||
const wrapper = mountWithPlugins(ResponseStatus);
|
||||
|
||||
// Expects "0B" formatting from pretty-bytes with { space: false }
|
||||
expect(wrapper.text()).toMatch(/0B/);
|
||||
});
|
||||
|
||||
it('uses pending duration when processing, otherwise last log duration', () => {
|
||||
// Processing case
|
||||
mockPendingRequest.value = {
|
||||
isProcessing: true,
|
||||
durationInMs: 1500,
|
||||
wasExecuted: false,
|
||||
};
|
||||
mockHistoryStore.lastLog = ref({ durationInMs: 9999 });
|
||||
let wrapper = mountWithPlugins(ResponseStatus);
|
||||
expect(wrapper.text()).toMatch(/1\.50s/);
|
||||
|
||||
// Completed case
|
||||
mockPendingRequest.value = {
|
||||
renderWithProviders(ResponseStatus);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(screen.getByTestId('response-status-text')).toHaveTextContent(
|
||||
String(STATUS.EMPTY),
|
||||
);
|
||||
});
|
||||
|
||||
it('derives status details from last successful log', async () => {
|
||||
mockRequestStore.pendingRequestData = {
|
||||
isProcessing: false,
|
||||
durationInMs: 2500,
|
||||
wasExecuted: true,
|
||||
};
|
||||
mockHistoryStore.lastLog = ref({
|
||||
|
||||
mockRequestsHistoryStore.lastLog = {
|
||||
durationInMs: 3000,
|
||||
response: { timestamp: 1700000000, status: STATUS.SUCCESS },
|
||||
});
|
||||
wrapper = mountWithPlugins(ResponseStatus);
|
||||
expect(wrapper.text()).toMatch(/2\.50s/); // <- it prirotizes the one from the pending request.
|
||||
response: {
|
||||
statusCode: 201,
|
||||
statusText: 'Created',
|
||||
sizeInBytes: 4096,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(ResponseStatus);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(screen.getByTestId('response-status-badge')).toHaveTextContent(
|
||||
'201 - Created',
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('response-status-size')).toHaveTextContent('4.1kB');
|
||||
|
||||
expect(screen.getByTestId('response-status-duration')).toHaveTextContent('3.00s');
|
||||
});
|
||||
|
||||
it('shows readable time text when last response exists', () => {
|
||||
mockPendingRequest.value = { isProcessing: false, durationInMs: 0 };
|
||||
mockHistoryStore.lastLog = ref({
|
||||
response: { timestamp: Math.floor(Date.now() / 1000) },
|
||||
});
|
||||
const wrapper = mountWithPlugins(ResponseStatus);
|
||||
it('resets size to zero when request was not executed', async () => {
|
||||
mockRequestStore.pendingRequestData = {
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
};
|
||||
|
||||
// The content is time-ago text; assert non-empty text region where it renders
|
||||
const small = wrapper.find('small');
|
||||
expect(small.exists()).toBe(true);
|
||||
expect((small.text() ?? '').length).toBeGreaterThan(0);
|
||||
mockRequestsHistoryStore.lastLog = {
|
||||
response: { sizeInBytes: 12345, timestamp: Math.floor(Date.now() / 1000) },
|
||||
};
|
||||
|
||||
renderWithProviders(ResponseStatus);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(screen.getByText(/0B/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows relative timestamp when last log exists', async () => {
|
||||
mockRequestStore.pendingRequestData = {
|
||||
isProcessing: false,
|
||||
durationInMs: 0,
|
||||
wasExecuted: true,
|
||||
};
|
||||
|
||||
mockRequestsHistoryStore.lastLog = {
|
||||
response: { timestamp: Math.floor(Date.now() / 1000) },
|
||||
};
|
||||
|
||||
renderWithProviders(ResponseStatus);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const timestamp = screen.getByText(
|
||||
(content, element) => element?.tagName === 'SMALL',
|
||||
);
|
||||
expect(timestamp.textContent?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('cancels request when cancel button clicked', async () => {
|
||||
mockPendingRequest.value = { isProcessing: true, durationInMs: 0 };
|
||||
const wrapper = mountWithPlugins(ResponseStatus);
|
||||
mockRequestStore.pendingRequestData = { isProcessing: true, durationInMs: 0 };
|
||||
|
||||
renderWithProviders(ResponseStatus);
|
||||
|
||||
await nextTick();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
const btn = wrapper.get('button');
|
||||
await btn.trigger('click');
|
||||
expect(mockRequestStore.cancelCurrentRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import ResponseViewer from '@/components/domain/Client/Response/ResponseViewer.vue';
|
||||
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
vi.mock('@/components/domain/Client/Response/ResponseStatus/ResponseStatus.vue', () => ({
|
||||
default: {
|
||||
name: 'ResponseStatus',
|
||||
template: '<div data-testid="response-status">Status</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/domain/Client/Response/ResponseViewerEmptyState.vue', () => ({
|
||||
default: {
|
||||
name: 'ResponseViewerEmptyState',
|
||||
template: '<div data-testid="response-empty">Empty</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/domain/Client/Response/ResponseViewerErrorState.vue', () => ({
|
||||
default: {
|
||||
name: 'ResponseViewerError',
|
||||
props: ['error'],
|
||||
template: '<div data-testid="response-error">{{ error.message }}</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/domain/Client/Response/ResponseViewerResponse.vue', () => ({
|
||||
default: {
|
||||
name: 'ResponseViewerResponse',
|
||||
template: '<div data-testid="response-content">Response</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
const mockRequestHistoryStore = reactive({
|
||||
logs: [],
|
||||
lastLog: null,
|
||||
});
|
||||
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRequestsHistoryStore: () => mockRequestHistoryStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ResponseViewer', () => {
|
||||
beforeEach(() => {
|
||||
mockRequestHistoryStore.logs = [];
|
||||
});
|
||||
|
||||
it('renders empty state when no logs available', () => {
|
||||
renderWithProviders(ResponseViewer);
|
||||
|
||||
expect(screen.getByTestId('response-status')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('response-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error component when last log contains error', () => {
|
||||
mockRequestHistoryStore.logs = [{ error: { message: 'Something went wrong' } }];
|
||||
mockRequestHistoryStore.lastLog = mockRequestHistoryStore.logs[0];
|
||||
|
||||
renderWithProviders(ResponseViewer);
|
||||
|
||||
expect(screen.getByTestId('response-error')).toHaveTextContent(
|
||||
'Something went wrong',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders response component when last log has response', () => {
|
||||
mockRequestHistoryStore.logs = [{ response: { status: 200 } }];
|
||||
mockRequestHistoryStore.lastLog = mockRequestHistoryStore.logs[0];
|
||||
|
||||
renderWithProviders(ResponseViewer);
|
||||
|
||||
expect(screen.getByTestId('response-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
228
resources/js/tests/composables/useGeneratorSearch.test.ts
Normal file
228
resources/js/tests/composables/useGeneratorSearch.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useGeneratorSearch } from '@/composables/data/useGeneratorSearch';
|
||||
import { ValueGenerator } from '@/interfaces/ui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const mockGenerators: ValueGenerator[] = [
|
||||
{
|
||||
id: 'email',
|
||||
name: 'Email Generator',
|
||||
description: 'Generates random email addresses',
|
||||
category: { id: 'string', name: 'String' },
|
||||
generate: () => 'test@example.com',
|
||||
},
|
||||
{
|
||||
id: 'uuid',
|
||||
name: 'UUID Generator',
|
||||
description: 'Generates UUID v4',
|
||||
category: { id: 'string', name: 'String' },
|
||||
generate: () => '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
name: 'Number Generator',
|
||||
description: 'Generates random numbers',
|
||||
category: { id: 'number', name: 'Number' },
|
||||
generate: () => 42,
|
||||
},
|
||||
];
|
||||
|
||||
describe('useGeneratorSearch', () => {
|
||||
describe('initialization', () => {
|
||||
it('initializes with empty search query and no category filter', () => {
|
||||
const { searchQuery, selectedCategory, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
expect(searchQuery.value).toBe('');
|
||||
expect(selectedCategory.value).toBeNull();
|
||||
expect(filteredGenerators.value).toEqual(mockGenerators);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text search filtering', () => {
|
||||
it('filters generators by name case-insensitively', () => {
|
||||
const { setSearchQuery, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('email');
|
||||
|
||||
expect(filteredGenerators.value).toHaveLength(1);
|
||||
expect(filteredGenerators.value[0].id).toBe('email');
|
||||
});
|
||||
|
||||
it('filters generators by description case-insensitively', () => {
|
||||
const { setSearchQuery, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('uuid');
|
||||
|
||||
expect(filteredGenerators.value).toHaveLength(1);
|
||||
expect(filteredGenerators.value[0].id).toBe('uuid');
|
||||
});
|
||||
|
||||
it('returns empty array when no generators match search query', () => {
|
||||
const { setSearchQuery, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('nonexistent');
|
||||
|
||||
expect(filteredGenerators.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches partial text in name or description', () => {
|
||||
const { setSearchQuery, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('generator');
|
||||
|
||||
expect(filteredGenerators.value).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('clears search filter when query is empty', () => {
|
||||
const { setSearchQuery, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('email');
|
||||
expect(filteredGenerators.value).toHaveLength(1);
|
||||
|
||||
setSearchQuery('');
|
||||
expect(filteredGenerators.value).toEqual(mockGenerators);
|
||||
});
|
||||
});
|
||||
|
||||
describe('category filtering', () => {
|
||||
it('filters generators by selected category', () => {
|
||||
const { setSelectedCategory, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSelectedCategory('string');
|
||||
|
||||
expect(filteredGenerators.value).toHaveLength(2);
|
||||
expect(filteredGenerators.value.every(g => g.category.id === 'string')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array when no generators match category', () => {
|
||||
const { setSelectedCategory, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSelectedCategory('nonexistent');
|
||||
|
||||
expect(filteredGenerators.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears category filter when set to null', () => {
|
||||
const { setSelectedCategory, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSelectedCategory('string');
|
||||
expect(filteredGenerators.value).toHaveLength(2);
|
||||
|
||||
setSelectedCategory(null);
|
||||
expect(filteredGenerators.value).toEqual(mockGenerators);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined filtering', () => {
|
||||
it('applies both search query and category filter simultaneously', () => {
|
||||
const { setSearchQuery, setSelectedCategory, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('generator');
|
||||
setSelectedCategory('string');
|
||||
|
||||
expect(filteredGenerators.value).toHaveLength(2);
|
||||
expect(filteredGenerators.value.every(g => g.category.id === 'string')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array when filters exclude all generators', () => {
|
||||
const { setSearchQuery, setSelectedCategory, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('email');
|
||||
setSelectedCategory('number');
|
||||
|
||||
expect(filteredGenerators.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter management', () => {
|
||||
it('clears all filters when clearFilters is called', () => {
|
||||
const {
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
clearFilters,
|
||||
filteredGenerators,
|
||||
} = useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('email');
|
||||
setSelectedCategory('string');
|
||||
|
||||
clearFilters();
|
||||
|
||||
expect(filteredGenerators.value).toEqual(mockGenerators);
|
||||
});
|
||||
|
||||
it('correctly identifies when filters are active', () => {
|
||||
const {
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
hasActiveFilters,
|
||||
clearFilters,
|
||||
} = useGeneratorSearch(mockGenerators);
|
||||
|
||||
expect(hasActiveFilters.value).toBe(false);
|
||||
|
||||
setSearchQuery('test');
|
||||
expect(hasActiveFilters.value).toBe(true);
|
||||
|
||||
clearFilters();
|
||||
setSelectedCategory('string');
|
||||
expect(hasActiveFilters.value).toBe(true);
|
||||
|
||||
clearFilters();
|
||||
expect(hasActiveFilters.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty generators array', () => {
|
||||
const { filteredGenerators } = useGeneratorSearch([]);
|
||||
|
||||
expect(filteredGenerators.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles search query with special characters', () => {
|
||||
const generatorsWithSpecialChars: ValueGenerator[] = [
|
||||
{
|
||||
id: 'special',
|
||||
name: 'Email@Generator',
|
||||
description: 'Test (with) special-chars',
|
||||
category: { id: 'string', name: 'String' },
|
||||
generate: () => 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const { setSearchQuery, filteredGenerators } = useGeneratorSearch(
|
||||
generatorsWithSpecialChars,
|
||||
);
|
||||
|
||||
setSearchQuery('@');
|
||||
expect(filteredGenerators.value).toHaveLength(1);
|
||||
|
||||
setSearchQuery('(');
|
||||
expect(filteredGenerators.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles very long search queries', () => {
|
||||
const { setSearchQuery, filteredGenerators } =
|
||||
useGeneratorSearch(mockGenerators);
|
||||
|
||||
setSearchQuery('a'.repeat(1000));
|
||||
|
||||
expect(filteredGenerators.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/stores', () => ({
|
||||
}));
|
||||
|
||||
const defaultPendingRequest = {
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
method: 'POST' as const,
|
||||
queryParameters: [],
|
||||
authorization: {},
|
||||
@@ -61,7 +61,7 @@ describe('useHttpClient', () => {
|
||||
|
||||
const request: PendingRequest = {
|
||||
...defaultPendingRequest,
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
authorization: {
|
||||
type: AuthorizationType.None,
|
||||
},
|
||||
@@ -82,7 +82,7 @@ describe('useHttpClient', () => {
|
||||
|
||||
const request: PendingRequest = {
|
||||
...defaultPendingRequest,
|
||||
endpoint: '///api/users',
|
||||
endpoint: '//api/users',
|
||||
authorization: {
|
||||
type: AuthorizationType.None,
|
||||
},
|
||||
@@ -96,7 +96,7 @@ describe('useHttpClient', () => {
|
||||
it('should create relay payload correctly', async () => {
|
||||
const request: PendingRequest = {
|
||||
...defaultPendingRequest,
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
authorization: { type: AuthorizationType.Bearer, value: 'abc123' },
|
||||
body: {
|
||||
POST: {
|
||||
@@ -168,7 +168,7 @@ describe('useHttpClient', () => {
|
||||
|
||||
const request: PendingRequest = {
|
||||
...defaultPendingRequest,
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
authorization: { type: AuthorizationType.Bearer, value: 'abc123' },
|
||||
};
|
||||
|
||||
@@ -449,18 +449,18 @@ describe('useHttpClient', () => {
|
||||
expect(isExecuting.value).toBe(false);
|
||||
});
|
||||
|
||||
it.skip('should handle body memoization correctly', () => {
|
||||
it('extracts correct body payload based on request method', async () => {
|
||||
const { executeRequest } = useHttpClient();
|
||||
|
||||
const request: PendingRequest = {
|
||||
...defaultPendingRequest,
|
||||
method: 'POST',
|
||||
authorization: {
|
||||
type: AuthorizationType.None,
|
||||
},
|
||||
body: {
|
||||
POST: {
|
||||
json: JSON.stringify({ name: 'John' }),
|
||||
'plain-text': 'foobar',
|
||||
},
|
||||
PUT: {
|
||||
json: JSON.stringify({ name: 'Jane' }),
|
||||
@@ -480,10 +480,10 @@ describe('useHttpClient', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
executeRequest(request);
|
||||
await executeRequest(request);
|
||||
|
||||
// The body should be extracted correctly based on method and payloadType
|
||||
// TODO [Test] this test is incomplete, assert memoization properly.
|
||||
expect(mockedAxios.post).toHaveBeenCalled();
|
||||
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
expect(formDataCall).toBeInstanceOf(FormData);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useRequestAuthorization } from '@/composables/request/useRequestAuthorization';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const pendingRequestData = reactive({
|
||||
authorization: { type: AuthorizationType.None, value: null },
|
||||
});
|
||||
|
||||
const updateAuthorization = vi.fn();
|
||||
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRequestStore: () => ({
|
||||
pendingRequestData,
|
||||
updateAuthorization,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useRequestAuthorization', () => {
|
||||
beforeEach(() => {
|
||||
pendingRequestData.authorization = { type: AuthorizationType.None, value: null };
|
||||
updateAuthorization.mockClear();
|
||||
});
|
||||
|
||||
it('initializes with current request authorization', () => {
|
||||
setActivePinia(createPinia());
|
||||
|
||||
const { authorization } = useRequestAuthorization();
|
||||
|
||||
expect(authorization.value.type).toBe(AuthorizationType.None);
|
||||
});
|
||||
|
||||
it('switches between authorization types and restores cached state', () => {
|
||||
setActivePinia(createPinia());
|
||||
|
||||
const {
|
||||
authorization,
|
||||
updateAuthorizationType,
|
||||
updateCurrentAuthorizationValue,
|
||||
} = useRequestAuthorization();
|
||||
|
||||
updateAuthorizationType(AuthorizationType.Bearer);
|
||||
updateCurrentAuthorizationValue('token');
|
||||
expect(authorization.value).toEqual({
|
||||
type: AuthorizationType.Bearer,
|
||||
value: 'token',
|
||||
});
|
||||
|
||||
updateAuthorizationType(AuthorizationType.Basic);
|
||||
expect(authorization.value.type).toBe(AuthorizationType.Basic);
|
||||
|
||||
updateAuthorizationType(AuthorizationType.Bearer);
|
||||
expect(authorization.value).toEqual({
|
||||
type: AuthorizationType.Bearer,
|
||||
value: 'token',
|
||||
});
|
||||
});
|
||||
|
||||
it('persists authorization back to the request store', () => {
|
||||
setActivePinia(createPinia());
|
||||
|
||||
updateAuthorization.mockClear();
|
||||
|
||||
const {
|
||||
saveAuthorizationToStore,
|
||||
updateAuthorizationType,
|
||||
updateCurrentAuthorizationValue,
|
||||
} = useRequestAuthorization();
|
||||
|
||||
updateAuthorizationType(AuthorizationType.Bearer);
|
||||
updateCurrentAuthorizationValue('token');
|
||||
|
||||
saveAuthorizationToStore();
|
||||
|
||||
expect(updateAuthorization).toHaveBeenCalledWith({
|
||||
type: AuthorizationType.Bearer,
|
||||
value: 'token',
|
||||
});
|
||||
});
|
||||
});
|
||||
121
resources/js/tests/composables/useRequestBody.test.ts
Normal file
121
resources/js/tests/composables/useRequestBody.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useRequestBody } from '@/composables/request/useRequestBody';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, reactive } from 'vue';
|
||||
|
||||
const requestStore = reactive<{
|
||||
pendingRequestData: PendingRequest | null;
|
||||
}>({
|
||||
pendingRequestData: null,
|
||||
});
|
||||
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRequestStore: () => requestStore,
|
||||
};
|
||||
});
|
||||
|
||||
const payloadMocks = vi.hoisted(() => ({
|
||||
generatePlaceholderPayload: vi.fn(() => ({ placeholder: true })),
|
||||
generateRandomPayload: vi.fn(() => ({ random: true })),
|
||||
serializeSchemaPayload: vi.fn(() => '{"serialized":true}'),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/payload', () => payloadMocks);
|
||||
|
||||
const createPendingRequest = (): PendingRequest => ({
|
||||
method: 'POST',
|
||||
endpoint: 'api/users',
|
||||
headers: [],
|
||||
body: {},
|
||||
payloadType: RequestBodyTypeEnum.JSON,
|
||||
schema: {
|
||||
shape: { properties: { name: { type: 'string' } } },
|
||||
extractionErrors: null,
|
||||
},
|
||||
queryParameters: [],
|
||||
authorization: { type: AuthorizationType.None },
|
||||
supportedRoutes: [],
|
||||
routeDefinition: null,
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
});
|
||||
|
||||
describe('useRequestBody', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
requestStore.pendingRequestData = createPendingRequest();
|
||||
payloadMocks.generatePlaceholderPayload.mockClear();
|
||||
payloadMocks.generateRandomPayload.mockClear();
|
||||
payloadMocks.serializeSchemaPayload.mockClear();
|
||||
});
|
||||
|
||||
const runComposable = () => {
|
||||
let composable: ReturnType<typeof useRequestBody>;
|
||||
|
||||
effectScope().run(() => {
|
||||
composable = useRequestBody();
|
||||
});
|
||||
|
||||
// @ts-expect-error composable assigned within scope
|
||||
return composable as ReturnType<typeof useRequestBody>;
|
||||
};
|
||||
|
||||
it('generates placeholder payload when none memoized', () => {
|
||||
const composable = runComposable();
|
||||
|
||||
composable.payloadType.value = RequestBodyTypeEnum.JSON;
|
||||
const payload = composable.generateCurrentPayload();
|
||||
|
||||
expect(payloadMocks.generatePlaceholderPayload).toHaveBeenCalled();
|
||||
expect(payloadMocks.serializeSchemaPayload).toHaveBeenCalled();
|
||||
expect(payload).toBe('{"serialized":true}');
|
||||
});
|
||||
|
||||
it('hydrates payload from memoized body when available', () => {
|
||||
const pending = requestStore.pendingRequestData!;
|
||||
pending.body = {
|
||||
POST: {
|
||||
[RequestBodyTypeEnum.JSON]: '{"cached":true}',
|
||||
},
|
||||
};
|
||||
|
||||
const composable = runComposable();
|
||||
|
||||
composable.payloadType.value = RequestBodyTypeEnum.JSON;
|
||||
const payload = composable.generateCurrentPayload();
|
||||
|
||||
expect(payload).toBe('{"cached":true}');
|
||||
});
|
||||
|
||||
it('updates content-type header when payload type changes', async () => {
|
||||
const composable = runComposable();
|
||||
|
||||
composable.payloadType.value = RequestBodyTypeEnum.JSON;
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
requestStore.pendingRequestData?.headers.find(
|
||||
header => header.key === 'content-type',
|
||||
)?.value,
|
||||
).toBe('application/json');
|
||||
});
|
||||
|
||||
it('autofills payload using random generator', () => {
|
||||
const composable = runComposable();
|
||||
|
||||
composable.payloadType.value = RequestBodyTypeEnum.JSON;
|
||||
composable.autofill();
|
||||
|
||||
expect(payloadMocks.generateRandomPayload).toHaveBeenCalled();
|
||||
expect(payloadMocks.serializeSchemaPayload).toHaveBeenCalled();
|
||||
expect(composable.payload.value).toBe('{"serialized":true}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useResponsiveResizable } from '@/composables/ui/useResponsiveResizable';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const resizeObserverMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useResizeObserver: resizeObserverMock,
|
||||
}));
|
||||
|
||||
describe('useResponsiveResizable', () => {
|
||||
beforeEach(() => {
|
||||
resizeObserverMock.mockClear();
|
||||
});
|
||||
|
||||
it('computes layout direction based on current width', () => {
|
||||
const element = ref({
|
||||
$el: {
|
||||
contentRect: {
|
||||
width: 800,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { thresholds } = useResponsiveResizable([600, 1000], element);
|
||||
|
||||
expect(thresholds[0].value).toBe('horizontal');
|
||||
expect(thresholds[1].value).toBe('vertical');
|
||||
|
||||
const callback = resizeObserverMock.mock.calls[0][1];
|
||||
|
||||
callback([{ contentRect: { width: 500 } }]);
|
||||
|
||||
expect(thresholds[0].value).toBe('vertical');
|
||||
expect(thresholds[1].value).toBe('vertical');
|
||||
});
|
||||
});
|
||||
420
resources/js/tests/composables/useRouteStatistics.test.ts
Normal file
420
resources/js/tests/composables/useRouteStatistics.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useRouteStatistics } from '@/composables/data/useRouteStatistics';
|
||||
import { RouteDefinition } from '@/interfaces/routes/routes';
|
||||
import { useRoutesStore } from '@/stores';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useRoutesStore: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockRoutesStore = {
|
||||
routes: null as {
|
||||
[key: string]: Array<{ resource: string; routes: RouteDefinition[] }>;
|
||||
} | null,
|
||||
};
|
||||
|
||||
describe('useRouteStatistics', () => {
|
||||
beforeEach(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
vi.mocked(useRoutesStore).mockReturnValue(mockRoutesStore as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
mockRoutesStore.routes = null;
|
||||
});
|
||||
|
||||
describe('routeStatistics computed', () => {
|
||||
it('returns zero statistics when no routes exist', () => {
|
||||
mockRoutesStore.routes = null;
|
||||
|
||||
const { routeStatistics } = useRouteStatistics();
|
||||
|
||||
expect(routeStatistics.value).toEqual({
|
||||
total: 0,
|
||||
withErrors: 0,
|
||||
withoutErrors: 0,
|
||||
errorRate: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero statistics when routes object is empty', () => {
|
||||
mockRoutesStore.routes = {};
|
||||
|
||||
const { routeStatistics } = useRouteStatistics();
|
||||
|
||||
expect(routeStatistics.value).toEqual({
|
||||
total: 0,
|
||||
withErrors: 0,
|
||||
withoutErrors: 0,
|
||||
errorRate: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates statistics correctly for routes without errors', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { routeStatistics } = useRouteStatistics();
|
||||
|
||||
expect(routeStatistics.value).toEqual({
|
||||
total: 2,
|
||||
withErrors: 0,
|
||||
withoutErrors: 2,
|
||||
errorRate: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates statistics correctly for routes with errors', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: 'Schema error',
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { routeStatistics } = useRouteStatistics();
|
||||
|
||||
expect(routeStatistics.value).toEqual({
|
||||
total: 2,
|
||||
withErrors: 1,
|
||||
withoutErrors: 1,
|
||||
errorRate: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates error rate correctly with rounding', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: 'Error',
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { routeStatistics } = useRouteStatistics();
|
||||
|
||||
expect(routeStatistics.value.errorRate).toBe(33);
|
||||
});
|
||||
|
||||
it('handles multiple versions correctly', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
v2: [
|
||||
{
|
||||
resource: 'posts',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/posts',
|
||||
shortEndpoint: 'api/posts',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: 'Error',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { routeStatistics } = useRouteStatistics();
|
||||
|
||||
expect(routeStatistics.value).toEqual({
|
||||
total: 2,
|
||||
withErrors: 1,
|
||||
withoutErrors: 1,
|
||||
errorRate: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayableRoutesWithErrors computed', () => {
|
||||
it('returns empty array when no routes exist', () => {
|
||||
mockRoutesStore.routes = null;
|
||||
|
||||
const { displayableRoutesWithErrors } = useRouteStatistics();
|
||||
|
||||
expect(displayableRoutesWithErrors.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no routes have errors', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { displayableRoutesWithErrors } = useRouteStatistics();
|
||||
|
||||
expect(displayableRoutesWithErrors.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns routes with errors including resource and version metadata', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: { 'x-name': 'root', 'x-required': true },
|
||||
extractionErrors: 'Schema extraction failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { displayableRoutesWithErrors } = useRouteStatistics();
|
||||
|
||||
expect(displayableRoutesWithErrors.value).toHaveLength(1);
|
||||
expect(displayableRoutesWithErrors.value[0]).toMatchObject({
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
resource: 'users',
|
||||
version: 'v1',
|
||||
schema: {
|
||||
shape: { 'x-name': 'root' },
|
||||
extractionErrors: 'Schema extraction failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out routes without errors across multiple versions', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: 'Error',
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
v2: [
|
||||
{
|
||||
resource: 'posts',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/posts',
|
||||
shortEndpoint: 'api/posts',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: 'Another error',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { displayableRoutesWithErrors } = useRouteStatistics();
|
||||
|
||||
expect(displayableRoutesWithErrors.value).toHaveLength(2);
|
||||
expect(displayableRoutesWithErrors.value[0].version).toBe('v1');
|
||||
expect(displayableRoutesWithErrors.value[1].version).toBe('v2');
|
||||
});
|
||||
|
||||
it('handles empty string extraction errors as valid errors', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [
|
||||
{
|
||||
resource: 'users',
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { displayableRoutesWithErrors } = useRouteStatistics();
|
||||
|
||||
expect(displayableRoutesWithErrors.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useTabHorizontalScroll } from '@/composables/ui/useTabHorizontalScroll';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
|
||||
const scrollMocks = vi.hoisted(() => ({
|
||||
getScrollBounds: vi.fn(() => ({ current: 50 })),
|
||||
getMaskVisibility: vi.fn(() => ({ showLeftMask: true, showRightMask: false })),
|
||||
getElementVisibility: vi.fn(() => ({ isFullyVisible: false })),
|
||||
calculateScrollToElement: vi.fn(() => 120),
|
||||
}));
|
||||
|
||||
const debounceMock = vi.hoisted(() => vi.fn((fn: () => void) => fn));
|
||||
|
||||
vi.mock('@/utils/scroll', () => scrollMocks);
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDebounceFn: debounceMock,
|
||||
}));
|
||||
|
||||
describe('useTabHorizontalScroll', () => {
|
||||
const runComposable = () => {
|
||||
let composable: ReturnType<typeof useTabHorizontalScroll>;
|
||||
|
||||
effectScope().run(() => {
|
||||
composable = useTabHorizontalScroll({
|
||||
MASK_WIDTH: 20,
|
||||
SCROLL_THRESHOLD: 30,
|
||||
SCROLL_PADDING: 10,
|
||||
ANIMATION_DURATION: 0,
|
||||
DEBOUNCE_DELAY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-expect-error assigned above
|
||||
return composable as ReturnType<typeof useTabHorizontalScroll>;
|
||||
};
|
||||
|
||||
it('updates mask visibility and saves scroll position', () => {
|
||||
const composable = runComposable();
|
||||
const container = {
|
||||
scrollLeft: 0,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
composable.scrollContainer.value = container;
|
||||
composable.updateScrollMasks();
|
||||
|
||||
expect(scrollMocks.getScrollBounds).toHaveBeenCalledWith(container, 30);
|
||||
expect(scrollMocks.getMaskVisibility).toHaveBeenCalled();
|
||||
expect(composable.showLeftMask.value).toBe(true);
|
||||
expect(composable.showRightMask.value).toBe(false);
|
||||
});
|
||||
|
||||
it('scrolls tab into view when not visible', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const composable = runComposable();
|
||||
const scrollTo = vi.fn();
|
||||
|
||||
composable.scrollContainer.value = {
|
||||
scrollTo,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
const button = document.createElement('button');
|
||||
|
||||
composable.scrollTabIntoView(button);
|
||||
|
||||
expect(scrollMocks.getElementVisibility).toHaveBeenCalled();
|
||||
expect(scrollMocks.calculateScrollToElement).toHaveBeenCalled();
|
||||
expect(scrollTo).toHaveBeenCalledWith({ left: 120, behavior: 'smooth' });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('restores saved scroll position', async () => {
|
||||
const composable = runComposable();
|
||||
const container = {
|
||||
scrollLeft: 0,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
composable.scrollContainer.value = container;
|
||||
composable.updateScrollMasks();
|
||||
|
||||
composable.scrollContainer.value!.scrollLeft = 0;
|
||||
|
||||
await composable.restoreScrollPosition();
|
||||
|
||||
expect(container.scrollLeft).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,8 @@ const mockRoutesStore: {
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
shortEndpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-required': false,
|
||||
@@ -31,8 +31,8 @@ const mockRoutesStore: {
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
endpoint: '/api/users',
|
||||
shortEndpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-required': false,
|
||||
@@ -92,18 +92,7 @@ describe('MainPage', () => {
|
||||
mockRoutesStore.hasExtractionError = false;
|
||||
});
|
||||
|
||||
it('renders the main page layout', () => {
|
||||
const wrapper = componentFactory();
|
||||
|
||||
expect(wrapper.find('.flex.h-screen.max-h-screen.overflow-hidden').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wrapper.findComponent({ name: 'AppResizablePanelGroup' }).exists()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders RouteExplorer in the first panel', () => {
|
||||
it('renders RouteExplorer with routes data', () => {
|
||||
const wrapper = componentFactory();
|
||||
|
||||
const routeExplorer = wrapper.findComponent({ name: 'RouteExplorer' });
|
||||
@@ -119,9 +108,8 @@ describe('MainPage', () => {
|
||||
expect(wrapper.findComponent({ name: 'ResponseViewer' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders RouteExtractorExceptionRenderer when there is an extraction error', () => {
|
||||
it('renders RouteExtractorExceptionRenderer instead of request/response components when extraction error exists', () => {
|
||||
mockRoutesStore.hasExtractionError = true;
|
||||
|
||||
mockRoutesStore.routeExtractorException = {
|
||||
exception: {
|
||||
message: 'Extraction failed',
|
||||
@@ -134,9 +122,7 @@ describe('MainPage', () => {
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'RouteExtractorExceptionRenderer' }).exists(),
|
||||
).toBe(true);
|
||||
|
||||
expect(wrapper.findComponent({ name: 'RequestBuilder' }).exists()).toBe(false);
|
||||
|
||||
expect(wrapper.findComponent({ name: 'ResponseViewer' }).exists()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -168,7 +154,7 @@ describe('MainPage', () => {
|
||||
expect(mockRoutesStore.initializeRoutes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders resizable panels with correct configuration', () => {
|
||||
it('configures resizable panels with correct sizing constraints', () => {
|
||||
const wrapper = componentFactory();
|
||||
|
||||
const panelGroup = wrapper.findComponent({
|
||||
@@ -179,36 +165,15 @@ describe('MainPage', () => {
|
||||
expect(panelGroup.props('direction')).toBe('vertical');
|
||||
|
||||
const panels = wrapper.findAllComponents({ name: 'AppResizablePanel' });
|
||||
expect(panels).toHaveLength(4); // RouteExplorer, Client Group<RequestBuilder, ResponseViewer>
|
||||
expect(panels).toHaveLength(4);
|
||||
|
||||
// First panel (RouteExplorer)
|
||||
expect(panels[0].props('minSize')).toBe(15);
|
||||
expect(panels[0].props('defaultSize')).toBe(20);
|
||||
|
||||
// Second panel (Client group)
|
||||
expect(panels[1].props('minSize')).toBe(60);
|
||||
expect(panels[1].props('defaultSize')).toBe(80);
|
||||
|
||||
// Third panel (RequestBuilder)
|
||||
expect(panels[2].props('minSize')).toBe(30);
|
||||
expect(panels[2].props('defaultSize')).toBe(50);
|
||||
|
||||
// Fourth panel (ResponseViewer)
|
||||
expect(panels[2].props('minSize')).toBe(30);
|
||||
expect(panels[2].props('defaultSize')).toBe(50);
|
||||
});
|
||||
|
||||
it('renders resizable handles between panels', () => {
|
||||
const wrapper = componentFactory();
|
||||
|
||||
const handles = wrapper.findAllComponents({
|
||||
name: 'AppResizableHandle',
|
||||
});
|
||||
|
||||
expect(handles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles empty routes data', () => {
|
||||
it('handles null routes data gracefully', () => {
|
||||
mockRoutesStore.routes = null;
|
||||
const wrapper = componentFactory();
|
||||
|
||||
@@ -217,29 +182,12 @@ describe('MainPage', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('handles routes with empty groups', () => {
|
||||
mockRoutesStore.routes = {
|
||||
v1: [],
|
||||
v2: [],
|
||||
};
|
||||
|
||||
it('reactively updates UI when extraction error state changes', async () => {
|
||||
const wrapper = componentFactory();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'RouteExplorer' }).props('routes')).toEqual({
|
||||
v1: [],
|
||||
v2: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles component re-rendering when store state changes', async () => {
|
||||
const wrapper = componentFactory();
|
||||
|
||||
// Initially no error
|
||||
expect(wrapper.findComponent({ name: 'RequestBuilder' }).exists()).toBe(true);
|
||||
|
||||
// Simulate extraction error
|
||||
mockRoutesStore.hasExtractionError = true;
|
||||
|
||||
mockRoutesStore.routeExtractorException = {
|
||||
exception: {
|
||||
message: 'Error',
|
||||
@@ -249,9 +197,9 @@ describe('MainPage', () => {
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Should now show error renderer instead of request/response components
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'RouteExtractorExceptionRenderer' }).exists(),
|
||||
).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'RequestBuilder' }).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
53
resources/js/tests/stores/useConfigStore.test.ts
Normal file
53
resources/js/tests/stores/useConfigStore.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useConfigStore } from '@/stores/core/useConfigStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Nimbus?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
describe('useConfigStore', () => {
|
||||
const originalNimbus = window.Nimbus;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.Nimbus = originalNimbus;
|
||||
});
|
||||
|
||||
it('reads configuration from Nimbus global', () => {
|
||||
window.Nimbus = {
|
||||
apiBaseUrl: 'https://example.com',
|
||||
basePath: '/nimbus',
|
||||
isVersioned: true,
|
||||
headers: JSON.stringify([{ header: 'X-Test', type: 'raw', value: '123' }]),
|
||||
currentUser: JSON.stringify({ id: 99 }),
|
||||
};
|
||||
|
||||
const store = useConfigStore();
|
||||
|
||||
expect(store.apiUrl).toBe('https://example.com');
|
||||
expect(store.appBasePath).toBe('/nimbus');
|
||||
expect(store.headers).toEqual([{ header: 'X-Test', type: 'raw', value: '123' }]);
|
||||
expect(store.isVersioned).toBe(true);
|
||||
expect(store.isLoggedIn).toBe(true);
|
||||
expect(store.userId).toBe(99);
|
||||
});
|
||||
|
||||
it('falls back to defaults when Nimbus undefined', () => {
|
||||
window.Nimbus = undefined;
|
||||
|
||||
const store = useConfigStore();
|
||||
|
||||
expect(store.apiUrl).toBe('http://localhost');
|
||||
expect(store.appBasePath).toBe('');
|
||||
expect(store.headers).toEqual([]);
|
||||
expect(store.isVersioned).toBe(false);
|
||||
expect(store.isLoggedIn).toBe(false);
|
||||
expect(store.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
52
resources/js/tests/stores/useGeneratorCommandStore.test.ts
Normal file
52
resources/js/tests/stores/useGeneratorCommandStore.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ValueGeneratorCommandOpenMethod } from '@/interfaces/ui';
|
||||
import { useGeneratorCommandStore } from '@/stores/generators/useGeneratorCommandStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('useGeneratorCommandStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('opens and closes command while tracking input refs', () => {
|
||||
const store = useGeneratorCommandStore();
|
||||
const input = document.createElement('input');
|
||||
|
||||
store.openCommand(input, ValueGeneratorCommandOpenMethod.SHIFT_SHIFT);
|
||||
|
||||
expect(store.isCommandOpen).toBe(true);
|
||||
expect(store.currentInputRef).toBe(input);
|
||||
expect(store.wasOpenedViaShiftShift).toBe(true);
|
||||
|
||||
store.closeCommand();
|
||||
|
||||
expect(store.isCommandOpen).toBe(false);
|
||||
expect(store.currentInputRef).toBeNull();
|
||||
});
|
||||
|
||||
it('updates command state and maintains recent generators cap', () => {
|
||||
const store = useGeneratorCommandStore();
|
||||
|
||||
store.setSearchQuery('email');
|
||||
store.setSelectedCategory('strings');
|
||||
store.addToRecentGenerators('uuid');
|
||||
store.addToRecentGenerators('email');
|
||||
store.addToRecentGenerators('uuid'); // promotes existing entry
|
||||
|
||||
expect(store.commandState.searchQuery).toBe('email');
|
||||
expect(store.commandState.selectedCategory).toBe('strings');
|
||||
expect(store.commandState.recentGenerators).toEqual(['uuid', 'email']);
|
||||
});
|
||||
|
||||
it('restores prior command search when command has no query', () => {
|
||||
const store = useGeneratorCommandStore();
|
||||
|
||||
store.setSearchQuery('date');
|
||||
|
||||
const commandInstance = { filterState: { search: '' } };
|
||||
|
||||
store.restoreCommandState(commandInstance);
|
||||
|
||||
expect(commandInstance.filterState.search).toBe('date');
|
||||
});
|
||||
});
|
||||
159
resources/js/tests/stores/useRequestBuilderStore.test.ts
Normal file
159
resources/js/tests/stores/useRequestBuilderStore.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { PendingRequest, RequestBodyTypeEnum, RequestHeader } from '@/interfaces/http';
|
||||
import { RouteDefinition } from '@/interfaces/routes';
|
||||
import { useRequestBuilderStore } from '@/stores/request/useRequestBuilderStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const preferences = reactive({
|
||||
autoRefreshRoutes: true,
|
||||
maxHistoryLogs: 100,
|
||||
theme: 'system' as const,
|
||||
defaultRequestBodyType: -1 as RequestBodyTypeEnum | -1,
|
||||
defaultAuthorizationType: AuthorizationType.CurrentUser,
|
||||
});
|
||||
|
||||
const apiUrl = 'https://api.example.com';
|
||||
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useSettingsStore: () => ({
|
||||
preferences,
|
||||
}),
|
||||
useConfigStore: () => ({
|
||||
apiUrl,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const baseRoute: RouteDefinition = {
|
||||
method: 'GET',
|
||||
endpoint: 'users',
|
||||
shortEndpoint: 'users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': true,
|
||||
properties: {
|
||||
name: {
|
||||
'x-name': 'name',
|
||||
'x-required': false,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe('useRequestBuilderStore', () => {
|
||||
const createStore = () => {
|
||||
setActivePinia(createPinia());
|
||||
|
||||
return useRequestBuilderStore();
|
||||
};
|
||||
|
||||
it('initializes pending request data with defaults', () => {
|
||||
const store = createStore();
|
||||
|
||||
store.initializeRequest(baseRoute, [baseRoute]);
|
||||
|
||||
const pending = store.pendingRequestData as PendingRequest;
|
||||
|
||||
expect(pending.method).toBe('GET');
|
||||
expect(pending.endpoint).toBe('users');
|
||||
expect(pending.payloadType).toBe(RequestBodyTypeEnum.JSON);
|
||||
expect(pending.schema).toMatchObject(baseRoute.schema);
|
||||
expect(pending.authorization).toEqual({
|
||||
type: AuthorizationType.CurrentUser,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches schema and payload when method changes', () => {
|
||||
const store = createStore();
|
||||
|
||||
const postRoute: RouteDefinition = {
|
||||
...baseRoute,
|
||||
method: 'POST',
|
||||
schema: {
|
||||
shape: {},
|
||||
extractionErrors: null,
|
||||
},
|
||||
};
|
||||
|
||||
store.initializeRequest(baseRoute, [baseRoute, postRoute]);
|
||||
|
||||
store.updateRequestMethod('POST');
|
||||
|
||||
const pending = store.pendingRequestData as PendingRequest;
|
||||
|
||||
expect(pending.method).toBe('POST');
|
||||
expect(pending.payloadType).toBe(RequestBodyTypeEnum.EMPTY);
|
||||
expect(pending.schema).toMatchObject(postRoute.schema);
|
||||
});
|
||||
|
||||
it('falls back to empty payload when route definition missing', () => {
|
||||
const store = createStore();
|
||||
|
||||
store.initializeRequest(baseRoute, [baseRoute]);
|
||||
|
||||
store.updateRequestMethod('DELETE');
|
||||
|
||||
const pending = store.pendingRequestData as PendingRequest;
|
||||
|
||||
expect(pending.payloadType).toBe(RequestBodyTypeEnum.EMPTY);
|
||||
});
|
||||
|
||||
it('updates headers, body, query parameters, and authorization', () => {
|
||||
const store = createStore();
|
||||
|
||||
store.initializeRequest(baseRoute, [baseRoute]);
|
||||
|
||||
const headers: RequestHeader[] = [
|
||||
{ key: 'Content-Type', value: 'application/json' },
|
||||
];
|
||||
const body: PendingRequest['body'] = {
|
||||
GET: { [RequestBodyTypeEnum.JSON]: '{}' },
|
||||
};
|
||||
const params = [{ key: 'page', value: '1' }];
|
||||
const auth = { type: AuthorizationType.Bearer, value: 'token' };
|
||||
|
||||
store.updateRequestHeaders(headers);
|
||||
store.updateRequestBody(body);
|
||||
store.updateQueryParameters(params);
|
||||
store.updateAuthorization(auth);
|
||||
|
||||
const pending = store.pendingRequestData as PendingRequest;
|
||||
|
||||
expect(pending.headers).toEqual(headers);
|
||||
expect(pending.body).toEqual(body);
|
||||
expect(pending.queryParameters).toEqual(params);
|
||||
expect(pending.authorization).toEqual(auth);
|
||||
});
|
||||
|
||||
it('builds request url using config base path', () => {
|
||||
const store = createStore();
|
||||
|
||||
store.initializeRequest(baseRoute, [baseRoute]);
|
||||
|
||||
const pending = store.pendingRequestData as PendingRequest;
|
||||
|
||||
pending.queryParameters = [{ key: 'page', value: '1' }];
|
||||
|
||||
expect(store.getRequestUrl(pending)).toBe('https://api.example.com/users?page=1');
|
||||
});
|
||||
|
||||
it('resets pending request state', () => {
|
||||
const store = createStore();
|
||||
|
||||
store.initializeRequest(baseRoute, [baseRoute]);
|
||||
|
||||
store.resetRequest();
|
||||
|
||||
expect(store.pendingRequestData).toBeNull();
|
||||
});
|
||||
});
|
||||
143
resources/js/tests/stores/useRequestExecutorStore.test.ts
Normal file
143
resources/js/tests/stores/useRequestExecutorStore.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { useRequestExecutorStore } from '@/stores/request/useRequestExecutorStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const executeRequest = vi.fn();
|
||||
const cancelCurrentRequest = vi.fn();
|
||||
|
||||
const requestUtilsMocks = vi.hoisted(() => ({
|
||||
createRequestTimer: vi.fn(() => ({
|
||||
stop: vi.fn(() => 1500),
|
||||
})),
|
||||
generateSuccessRequestLog: vi.fn(() => ({ type: 'success' })),
|
||||
generateErrorRequestLog: vi.fn(() => ({ type: 'error' })),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/request/useHttpClient', () => ({
|
||||
useHttpClient: () => ({
|
||||
executeRequest,
|
||||
cancelCurrentRequest,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockRequestsHistoryStore = reactive({
|
||||
addLog: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRequestsHistoryStore: () => mockRequestsHistoryStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/request', () => requestUtilsMocks);
|
||||
|
||||
const request: PendingRequest = {
|
||||
method: 'GET',
|
||||
endpoint: 'users',
|
||||
headers: [],
|
||||
body: {},
|
||||
payloadType: RequestBodyTypeEnum.EMPTY,
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
queryParameters: [],
|
||||
authorization: { type: AuthorizationType.None },
|
||||
supportedRoutes: [],
|
||||
routeDefinition: {
|
||||
method: 'GET',
|
||||
endpoint: 'users',
|
||||
shortEndpoint: 'users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
};
|
||||
|
||||
describe('useRequestExecutorStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockRequestsHistoryStore.addLog.mockClear();
|
||||
executeRequest.mockReset();
|
||||
cancelCurrentRequest.mockClear();
|
||||
requestUtilsMocks.createRequestTimer.mockClear();
|
||||
requestUtilsMocks.generateSuccessRequestLog.mockClear();
|
||||
requestUtilsMocks.generateErrorRequestLog.mockClear();
|
||||
});
|
||||
|
||||
it('prevents execution when request invalid', async () => {
|
||||
const store = useRequestExecutorStore();
|
||||
|
||||
expect(store.canExecute(null)).toBe(false);
|
||||
|
||||
const invalid = { ...request, endpoint: ' ' };
|
||||
|
||||
expect(store.canExecute(invalid as PendingRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it('logs successful executions with generated log entry', async () => {
|
||||
const store = useRequestExecutorStore();
|
||||
|
||||
executeRequest.mockResolvedValue({
|
||||
duration: 2000,
|
||||
response: { status: 200 },
|
||||
});
|
||||
|
||||
await store.executeRequestWithTiming({ ...request });
|
||||
|
||||
expect(requestUtilsMocks.createRequestTimer).toHaveBeenCalled();
|
||||
expect(requestUtilsMocks.generateSuccessRequestLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
2000,
|
||||
{ status: 200 },
|
||||
);
|
||||
expect(mockRequestsHistoryStore.addLog).toHaveBeenCalledWith({ type: 'success' });
|
||||
});
|
||||
|
||||
it('skips logging when request is cancelled', async () => {
|
||||
const store = useRequestExecutorStore();
|
||||
|
||||
executeRequest.mockResolvedValue(null);
|
||||
|
||||
await store.executeRequestWithTiming({ ...request });
|
||||
|
||||
expect(mockRequestsHistoryStore.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs errors using error log factory', async () => {
|
||||
const store = useRequestExecutorStore();
|
||||
|
||||
executeRequest.mockRejectedValue({ message: 'boom' });
|
||||
|
||||
await store.executeRequestWithTiming({ ...request });
|
||||
|
||||
expect(requestUtilsMocks.generateErrorRequestLog).toHaveBeenCalled();
|
||||
|
||||
expect(mockRequestsHistoryStore.addLog).toHaveBeenCalledWith({ type: 'error' });
|
||||
});
|
||||
|
||||
it('cancels current request via http client', () => {
|
||||
const store = useRequestExecutorStore();
|
||||
|
||||
store.cancelCurrentRequest();
|
||||
|
||||
expect(cancelCurrentRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe('useRequestStore', () => {
|
||||
it('should delegate pendingRequestData to builder store', () => {
|
||||
const mockRequestData = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
headers: [],
|
||||
body: {},
|
||||
payloadType: RequestBodyTypeEnum.EMPTY,
|
||||
@@ -113,8 +113,8 @@ describe('useRequestStore', () => {
|
||||
it('should initialize request and reset execution', () => {
|
||||
const mockRoute: RouteDefinition = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
shortEndpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
@@ -137,8 +137,8 @@ describe('useRequestStore', () => {
|
||||
it('should no-op when route method and endpoint are unchanged', () => {
|
||||
const sameRoute: RouteDefinition = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
shortEndpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
@@ -150,7 +150,7 @@ describe('useRequestStore', () => {
|
||||
|
||||
mockBuilderStore.pendingRequestData = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
};
|
||||
|
||||
const supported = [sameRoute];
|
||||
@@ -164,8 +164,8 @@ describe('useRequestStore', () => {
|
||||
it('should reinitialize when method changes but endpoint stays the same', () => {
|
||||
const route: RouteDefinition = {
|
||||
method: 'POST',
|
||||
endpoint: '/api/users',
|
||||
shortEndpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
@@ -177,7 +177,7 @@ describe('useRequestStore', () => {
|
||||
|
||||
mockBuilderStore.pendingRequestData = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
};
|
||||
|
||||
const supported = [route];
|
||||
@@ -194,8 +194,8 @@ describe('useRequestStore', () => {
|
||||
it('should reinitialize when endpoint changes but method stays the same', () => {
|
||||
const route: RouteDefinition = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/accounts',
|
||||
shortEndpoint: '/api/accounts',
|
||||
endpoint: 'api/accounts',
|
||||
shortEndpoint: 'api/accounts',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
@@ -207,7 +207,7 @@ describe('useRequestStore', () => {
|
||||
|
||||
mockBuilderStore.pendingRequestData = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
};
|
||||
|
||||
const supported = [route];
|
||||
@@ -277,7 +277,7 @@ describe('useRequestStore', () => {
|
||||
it('should execute current request when pendingRequestData exists', () => {
|
||||
const mockRequestData = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
headers: [],
|
||||
body: {},
|
||||
payloadType: RequestBodyTypeEnum.EMPTY,
|
||||
@@ -342,8 +342,8 @@ describe('useRequestStore', () => {
|
||||
it('should handle complete request lifecycle', () => {
|
||||
const mockRoute: RouteDefinition = {
|
||||
method: 'POST',
|
||||
endpoint: '/api/users',
|
||||
shortEndpoint: '/api/users',
|
||||
endpoint: 'api/users',
|
||||
shortEndpoint: 'api/users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
@@ -369,7 +369,7 @@ describe('useRequestStore', () => {
|
||||
// Execute request
|
||||
mockBuilderStore.pendingRequestData = {
|
||||
method: 'PUT',
|
||||
endpoint: '/api/users/1',
|
||||
endpoint: 'api/users/1',
|
||||
};
|
||||
|
||||
store.executeCurrentRequest();
|
||||
|
||||
44
resources/js/tests/stores/useRequestsHistoryStore.test.ts
Normal file
44
resources/js/tests/stores/useRequestsHistoryStore.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { RequestLog } from '@/interfaces/history/logs';
|
||||
import { useSettingsStore } from '@/stores/core/useSettingsStore';
|
||||
import { useRequestsHistoryStore } from '@/stores/request/useRequestsHistoryStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('useRequestsHistoryStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
const settings = useSettingsStore();
|
||||
settings.updatePreference('maxHistoryLogs', 2);
|
||||
});
|
||||
|
||||
it('adds logs and enforces max history size', () => {
|
||||
const store = useRequestsHistoryStore();
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const logs: RequestLog[] = [
|
||||
{ durationInMs: 10, isProcessing: false, request: {} as any },
|
||||
{ durationInMs: 20, isProcessing: false, request: {} as any },
|
||||
{ durationInMs: 30, isProcessing: false, request: {} as any },
|
||||
];
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
logs.forEach(log => store.addLog(log));
|
||||
|
||||
expect(store.allLogs).toHaveLength(2);
|
||||
expect(store.lastLog?.durationInMs).toBe(30);
|
||||
expect(store.totalRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('clears logs when requested', () => {
|
||||
const store = useRequestsHistoryStore();
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
store.addLog({ durationInMs: 10, isProcessing: false, request: {} as any });
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
store.clearLogs();
|
||||
|
||||
expect(store.allLogs).toEqual([]);
|
||||
expect(store.lastLog).toBeNull();
|
||||
});
|
||||
});
|
||||
52
resources/js/tests/stores/useSettingsStore.test.ts
Normal file
52
resources/js/tests/stores/useSettingsStore.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { RequestBodyTypeEnum } from '@/interfaces/http';
|
||||
import { useSettingsStore } from '@/stores/core/useSettingsStore';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const STORAGE_KEY = 'nimbus-user-preferences';
|
||||
|
||||
describe('useSettingsStore', () => {
|
||||
it('loads stored preferences when available', () => {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
autoRefreshRoutes: false,
|
||||
maxHistoryLogs: 50,
|
||||
theme: 'dark',
|
||||
defaultRequestBodyType: RequestBodyTypeEnum.JSON,
|
||||
defaultAuthorizationType: AuthorizationType.Bearer,
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useSettingsStore();
|
||||
|
||||
expect(store.preferences.autoRefreshRoutes).toBe(false);
|
||||
expect(store.preferences.maxHistoryLogs).toBe(50);
|
||||
expect(store.preferences.theme).toBe('dark');
|
||||
expect(store.preferences.defaultRequestBodyType).toBe(RequestBodyTypeEnum.JSON);
|
||||
expect(store.preferences.defaultAuthorizationType).toBe(AuthorizationType.Bearer);
|
||||
});
|
||||
|
||||
it('persists preference changes automatically', async () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.updatePreference('theme', 'dark');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY)).theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('resets preferences to defaults', async () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.updatePreference('theme', 'dark');
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
store.resetPreferences();
|
||||
|
||||
expect(store.preferences.theme).toBe('system');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ValueGenerator } from '@/interfaces/ui';
|
||||
import { useValueGeneratorDefinitionsStore } from '@/stores/generators/useValueGeneratorDefinitionsStore';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const generatorFixtures = vi.hoisted(() => ({
|
||||
allValueGenerators: [
|
||||
{
|
||||
id: 'email',
|
||||
name: 'Email Generator',
|
||||
description: 'Generates email',
|
||||
category: { id: 'string', name: 'String' },
|
||||
generate: () => 'test@example.com',
|
||||
},
|
||||
{
|
||||
id: 'uuid',
|
||||
name: 'UUID Generator',
|
||||
description: 'Generates UUID',
|
||||
category: { id: 'string', name: 'String' },
|
||||
generate: () => '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
name: 'Number Generator',
|
||||
description: 'Generates number',
|
||||
category: { id: 'number', name: 'Number' },
|
||||
generate: () => 42,
|
||||
},
|
||||
] as ValueGenerator[],
|
||||
generatorCategories: [
|
||||
{ id: 'string', name: 'String' },
|
||||
{ id: 'number', name: 'Number' },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('@/config/generators', () => generatorFixtures);
|
||||
|
||||
const mockGenerators = generatorFixtures.allValueGenerators;
|
||||
const mockCategories = generatorFixtures.generatorCategories;
|
||||
|
||||
describe('useValueGeneratorDefinitionsStore', () => {
|
||||
let store: ReturnType<typeof useValueGeneratorDefinitionsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = useValueGeneratorDefinitionsStore();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('initializes with generators from config', () => {
|
||||
expect(store.generators).toEqual(mockGenerators);
|
||||
});
|
||||
|
||||
it('initializes with categories from config', () => {
|
||||
expect(store.categories).toEqual(mockCategories);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGeneratorById', () => {
|
||||
it('returns generator when found by id', () => {
|
||||
const generator = store.getGeneratorById('email');
|
||||
|
||||
expect(generator).toEqual(mockGenerators[0]);
|
||||
});
|
||||
|
||||
it('returns undefined when generator not found', () => {
|
||||
const generator = store.getGeneratorById('nonexistent');
|
||||
|
||||
expect(generator).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGeneratorsByCategory', () => {
|
||||
it('returns all generators for specified category', () => {
|
||||
const generators = store.getGeneratorsByCategory('string');
|
||||
|
||||
expect(generators).toHaveLength(2);
|
||||
expect(generators.every(g => g.category.id === 'string')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array when no generators match category', () => {
|
||||
const generators = store.getGeneratorsByCategory('nonexistent');
|
||||
|
||||
expect(generators).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllCategories', () => {
|
||||
it('returns all categories', () => {
|
||||
const categories = store.getAllCategories();
|
||||
|
||||
expect(categories).toEqual(mockCategories);
|
||||
});
|
||||
});
|
||||
});
|
||||
147
resources/js/tests/stores/useValueGeneratorStore.test.ts
Normal file
147
resources/js/tests/stores/useValueGeneratorStore.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { ValueGenerator } from '@/interfaces/ui';
|
||||
import { useValueGeneratorStore } from '@/stores/generators/useValueGeneratorStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
const generators: ValueGenerator[] = [
|
||||
{
|
||||
id: 'uuid',
|
||||
name: 'UUID',
|
||||
description: 'Generates UUID',
|
||||
category: { id: 'strings', name: 'Strings' },
|
||||
generate: vi.fn(() => 'uuid-value'),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: 'Email',
|
||||
description: 'Generates email',
|
||||
category: { id: 'strings', name: 'Strings' },
|
||||
generate: vi.fn(() => 'email@example.com'),
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [{ id: 'strings', name: 'Strings' }];
|
||||
|
||||
const commandStore = reactive({
|
||||
isCommandOpen: false,
|
||||
currentInputRef: null as HTMLElement | null,
|
||||
commandState: {
|
||||
recentGenerators: [] as string[],
|
||||
},
|
||||
openCommand: vi.fn(),
|
||||
closeCommand: vi.fn(),
|
||||
setSearchQuery: vi.fn(),
|
||||
setSelectedCategory: vi.fn(),
|
||||
addToRecentGenerators: vi.fn(id => {
|
||||
commandStore.commandState.recentGenerators = [
|
||||
id,
|
||||
...commandStore.commandState.recentGenerators.filter(
|
||||
existing => existing !== id,
|
||||
),
|
||||
].slice(0, 2);
|
||||
}),
|
||||
restoreCommandState: vi.fn(),
|
||||
wasOpenedViaShiftShift: false,
|
||||
});
|
||||
|
||||
const filteredGenerators = ref(generators);
|
||||
const setSearchQuery = vi.fn();
|
||||
const setSelectedCategory = vi.fn();
|
||||
|
||||
vi.mock('@/stores/generators/useValueGeneratorDefinitionsStore', () => ({
|
||||
useValueGeneratorDefinitionsStore: () => ({
|
||||
generators,
|
||||
categories,
|
||||
getGeneratorById: (id: string) =>
|
||||
generators.find(generator => generator.id === id),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/generators/useGeneratorCommandStore', () => ({
|
||||
useGeneratorCommandStore: () => commandStore,
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/data/useGeneratorSearch', () => ({
|
||||
useGeneratorSearch: () => ({
|
||||
filteredGenerators,
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
searchQuery: ref(''),
|
||||
selectedCategory: ref(null),
|
||||
clearFilters: vi.fn(),
|
||||
hasActiveFilters: computed(() => false),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useValueGeneratorStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
commandStore.isCommandOpen = false;
|
||||
commandStore.currentInputRef = null;
|
||||
commandStore.commandState.recentGenerators = [];
|
||||
commandStore.openCommand.mockClear();
|
||||
commandStore.closeCommand.mockClear();
|
||||
commandStore.setSearchQuery.mockClear();
|
||||
commandStore.setSelectedCategory.mockClear();
|
||||
commandStore.addToRecentGenerators.mockClear();
|
||||
setSearchQuery.mockClear();
|
||||
setSelectedCategory.mockClear();
|
||||
generators.forEach(generator => (generator.generate as vi.Mock).mockClear());
|
||||
});
|
||||
|
||||
it('generates value and records generator usage', () => {
|
||||
const store = useValueGeneratorStore();
|
||||
|
||||
const value = store.generateValue('uuid');
|
||||
|
||||
expect(value).toBe('uuid-value');
|
||||
expect(commandStore.addToRecentGenerators).toHaveBeenCalledWith('uuid');
|
||||
});
|
||||
|
||||
it('proxies command interactions', () => {
|
||||
const store = useValueGeneratorStore();
|
||||
const input = document.createElement('input');
|
||||
|
||||
store.openCommand(input);
|
||||
store.closeCommand();
|
||||
|
||||
expect(commandStore.openCommand).toHaveBeenCalledWith(input, expect.anything());
|
||||
expect(commandStore.closeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mirrors command open state from command store', async () => {
|
||||
const store = useValueGeneratorStore();
|
||||
|
||||
commandStore.isCommandOpen = true;
|
||||
commandStore.currentInputRef = document.createElement('input');
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.isCommandOpen).toBe(true);
|
||||
expect(store.currentInputRef).toBe(commandStore.currentInputRef);
|
||||
});
|
||||
|
||||
it('synchronizes search state with command store and composable', () => {
|
||||
const store = useValueGeneratorStore();
|
||||
|
||||
store.setSearchQuery('email');
|
||||
store.setSelectedCategory('strings');
|
||||
|
||||
expect(commandStore.setSearchQuery).toHaveBeenCalledWith('email');
|
||||
expect(setSearchQuery).toHaveBeenCalledWith('email');
|
||||
expect(commandStore.setSelectedCategory).toHaveBeenCalledWith('strings');
|
||||
expect(setSelectedCategory).toHaveBeenCalledWith('strings');
|
||||
});
|
||||
|
||||
it('exposes recent generators resolved to generator definitions', () => {
|
||||
const store = useValueGeneratorStore();
|
||||
|
||||
commandStore.commandState.recentGenerators = ['email', 'uuid'];
|
||||
|
||||
expect(store.recentGenerators.map(generator => generator.id)).toEqual([
|
||||
'email',
|
||||
'uuid',
|
||||
]);
|
||||
});
|
||||
});
|
||||
75
resources/js/tests/utils/generateCurlCommand.test.ts
Normal file
75
resources/js/tests/utils/generateCurlCommand.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
|
||||
import { generateCurlCommand } from '@/utils/request';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const requestBase: PendingRequest = {
|
||||
method: 'POST',
|
||||
endpoint: 'users',
|
||||
headers: [
|
||||
{ key: 'Authorization', value: 'Bearer token' },
|
||||
{ key: 'Accept', value: 'application/json' },
|
||||
],
|
||||
body: {
|
||||
POST: {
|
||||
[RequestBodyTypeEnum.JSON]: JSON.stringify({ name: 'Jane' }),
|
||||
},
|
||||
},
|
||||
payloadType: RequestBodyTypeEnum.JSON,
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
queryParameters: [{ key: 'page', value: '1' }],
|
||||
authorization: { type: AuthorizationType.Bearer, value: 'token' },
|
||||
supportedRoutes: [],
|
||||
routeDefinition: {
|
||||
method: 'POST',
|
||||
endpoint: 'users',
|
||||
shortEndpoint: 'users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
};
|
||||
|
||||
describe('generateCurlCommand', () => {
|
||||
it('builds curl command with method, headers, and body', () => {
|
||||
const { command, hasSpecialAuth } = generateCurlCommand(
|
||||
requestBase,
|
||||
'https://api.example.com',
|
||||
);
|
||||
|
||||
expect(command).toContain('curl');
|
||||
expect(command).toContain('-X POST');
|
||||
expect(command).toContain('"https://api.example.com/users?page=1"');
|
||||
expect(command).toContain('-H "Authorization: Bearer token"');
|
||||
expect(command).toContain('-H "Accept: application/json"');
|
||||
expect(command).toContain('{"name":"Jane"}');
|
||||
expect(hasSpecialAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('flags special authorization types', () => {
|
||||
const request = {
|
||||
...requestBase,
|
||||
authorization: { type: AuthorizationType.Impersonate, value: 1 },
|
||||
};
|
||||
|
||||
const { hasSpecialAuth } = generateCurlCommand(
|
||||
request,
|
||||
'https://api.example.com',
|
||||
);
|
||||
|
||||
expect(hasSpecialAuth).toBe(true);
|
||||
});
|
||||
});
|
||||
118
resources/js/tests/utils/request-utils.test.ts
Normal file
118
resources/js/tests/utils/request-utils.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { RouteDefinition } from '@/interfaces';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
|
||||
import {
|
||||
createRequestTimer,
|
||||
generateErrorRequestLog,
|
||||
generateSuccessRequestLog,
|
||||
getDefaultPayloadTypeForRoute,
|
||||
} from '@/utils/request';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('request-utils', () => {
|
||||
it('selects JSON payload when schema has properties', () => {
|
||||
const type = getDefaultPayloadTypeForRoute({
|
||||
method: 'POST',
|
||||
endpoint: 'users',
|
||||
shortEndpoint: 'users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
properties: {
|
||||
name: {
|
||||
'x-name': 'name',
|
||||
'x-required': false,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
} as RouteDefinition);
|
||||
|
||||
expect(type).toBe(RequestBodyTypeEnum.JSON);
|
||||
});
|
||||
|
||||
it('selects empty payload when schema has no properties', () => {
|
||||
const type = getDefaultPayloadTypeForRoute({
|
||||
method: 'GET',
|
||||
endpoint: 'users',
|
||||
shortEndpoint: 'users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
} as RouteDefinition);
|
||||
|
||||
expect(type).toBe(RequestBodyTypeEnum.EMPTY);
|
||||
});
|
||||
|
||||
it('builds success and error request logs', () => {
|
||||
const request = {
|
||||
method: 'GET',
|
||||
endpoint: 'users',
|
||||
headers: [],
|
||||
queryParameters: [],
|
||||
payloadType: RequestBodyTypeEnum.EMPTY,
|
||||
body: {},
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
properties: {},
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
authorization: {
|
||||
type: AuthorizationType.None,
|
||||
},
|
||||
supportedRoutes: [],
|
||||
routeDefinition: {
|
||||
method: 'GET',
|
||||
endpoint: 'users',
|
||||
shortEndpoint: 'users',
|
||||
schema: {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
},
|
||||
},
|
||||
} as PendingRequest;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const success = generateSuccessRequestLog(request, 1200, {
|
||||
status: 200,
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
expect(success.durationInMs).toBe(1200);
|
||||
expect(success.response).toEqual({ status: 200 });
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const error = generateErrorRequestLog(request, {
|
||||
message: 'fail',
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
expect(error.error).toEqual({ message: 'fail' });
|
||||
});
|
||||
|
||||
it('tracks elapsed time with createRequestTimer', () => {
|
||||
vi.useFakeTimers();
|
||||
const callback = vi.fn();
|
||||
const timer = createRequestTimer(callback);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalled();
|
||||
|
||||
const elapsed = timer.stop();
|
||||
expect(elapsed).toBeGreaterThanOrEqual(100);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user