mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-16 16:30:41 +01:00
Compare commits
1 Commits
feat/sqlco
...
test/split
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f456aa07b0 |
@@ -3,6 +3,7 @@ import type { Config } from '@jest/types';
|
|||||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||||
|
|
||||||
const config: Config.InitialOptions = {
|
const config: Config.InitialOptions = {
|
||||||
|
maxWorkers: '50%',
|
||||||
silent: true,
|
silent: true,
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
import ROUTES from 'constants/routes';
|
/**
|
||||||
import history from 'lib/history';
|
* Login - Authentication & Form Tests
|
||||||
|
*
|
||||||
|
* Split from Login.test.tsx for better parallelization.
|
||||||
|
* Tests password auth, callback auth, URL params, warnings, form state, and edge cases.
|
||||||
|
*/
|
||||||
import { rest, server } from 'mocks-server/server';
|
import { rest, server } from 'mocks-server/server';
|
||||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||||
import { ErrorV2 } from 'types/api';
|
|
||||||
import { Info } from 'types/api/v1/version/get';
|
|
||||||
import { SessionsContext } from 'types/api/v2/sessions/context/get';
|
import { SessionsContext } from 'types/api/v2/sessions/context/get';
|
||||||
import { Token } from 'types/api/v2/sessions/email_password/post';
|
|
||||||
|
|
||||||
import Login from '../index';
|
import Login from '../index';
|
||||||
|
import {
|
||||||
|
CALLBACK_AUTHN_URL,
|
||||||
|
EMAIL_PASSWORD_ENDPOINT,
|
||||||
|
mockEmailPasswordResponse,
|
||||||
|
mockMultiOrgWithWarning,
|
||||||
|
mockOrgWithWarning,
|
||||||
|
mockSingleOrgCallbackAuth,
|
||||||
|
mockSingleOrgPasswordAuth,
|
||||||
|
mockVersionSetupCompleted,
|
||||||
|
PASSWORD_AUTHN_EMAIL,
|
||||||
|
SESSIONS_CONTEXT_ENDPOINT,
|
||||||
|
VERSION_ENDPOINT,
|
||||||
|
} from './Login.test-utils';
|
||||||
|
|
||||||
const VERSION_ENDPOINT = '*/api/v1/version';
|
// =============================================================================
|
||||||
const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
|
// MOCKS
|
||||||
const CALLBACK_AUTHN_ORG = 'callback_authn_org';
|
// =============================================================================
|
||||||
const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
|
|
||||||
const PASSWORD_AUTHN_ORG = 'password_authn_org';
|
|
||||||
const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
|
|
||||||
|
|
||||||
jest.mock('lib/history', () => ({
|
jest.mock('lib/history', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
@@ -26,102 +37,13 @@ jest.mock('lib/history', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
// =============================================================================
|
||||||
typeof history.push
|
// TESTS
|
||||||
>;
|
// =============================================================================
|
||||||
|
|
||||||
// Mock data
|
describe('Login - Authentication & Form', () => {
|
||||||
const mockVersionSetupCompleted: Info = {
|
|
||||||
setupCompleted: true,
|
|
||||||
ee: 'Y',
|
|
||||||
version: '0.25.0',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockVersionSetupIncomplete: Info = {
|
|
||||||
setupCompleted: false,
|
|
||||||
ee: 'Y',
|
|
||||||
version: '0.25.0',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSingleOrgPasswordAuth: SessionsContext = {
|
|
||||||
exists: true,
|
|
||||||
orgs: [
|
|
||||||
{
|
|
||||||
id: 'org-1',
|
|
||||||
name: 'Test Organization',
|
|
||||||
authNSupport: {
|
|
||||||
password: [{ provider: 'email_password' }],
|
|
||||||
callback: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSingleOrgCallbackAuth: SessionsContext = {
|
|
||||||
exists: true,
|
|
||||||
orgs: [
|
|
||||||
{
|
|
||||||
id: 'org-1',
|
|
||||||
name: 'Test Organization',
|
|
||||||
authNSupport: {
|
|
||||||
password: [],
|
|
||||||
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockMultiOrgMixedAuth: SessionsContext = {
|
|
||||||
exists: true,
|
|
||||||
orgs: [
|
|
||||||
{
|
|
||||||
id: 'org-1',
|
|
||||||
name: PASSWORD_AUTHN_ORG,
|
|
||||||
authNSupport: {
|
|
||||||
password: [{ provider: 'email_password' }],
|
|
||||||
callback: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'org-2',
|
|
||||||
name: CALLBACK_AUTHN_ORG,
|
|
||||||
authNSupport: {
|
|
||||||
password: [],
|
|
||||||
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrgWithWarning: SessionsContext = {
|
|
||||||
exists: true,
|
|
||||||
orgs: [
|
|
||||||
{
|
|
||||||
id: 'org-1',
|
|
||||||
name: 'Warning Organization',
|
|
||||||
authNSupport: {
|
|
||||||
password: [{ provider: 'email_password' }],
|
|
||||||
callback: [],
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
code: 'ORG_WARNING',
|
|
||||||
message: 'Organization has limited access',
|
|
||||||
url: 'https://example.com/warning',
|
|
||||||
errors: [{ message: 'Contact admin for full access' }],
|
|
||||||
} as ErrorV2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEmailPasswordResponse: Token = {
|
|
||||||
accessToken: 'mock-access-token',
|
|
||||||
refreshToken: 'mock-refresh-token',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Login Component', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||||
res(
|
res(
|
||||||
@@ -136,269 +58,6 @@ describe('Login Component', () => {
|
|||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Initial Render', () => {
|
|
||||||
it('renders login form with email input and next button', () => {
|
|
||||||
const { getByTestId, getByPlaceholderText } = render(<Login />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(getByTestId('email')).toBeInTheDocument();
|
|
||||||
expect(getByTestId('initiate_login')).toBeInTheDocument();
|
|
||||||
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows loading state when version data is being fetched', () => {
|
|
||||||
server.use(
|
|
||||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
|
||||||
res(
|
|
||||||
ctx.delay(100),
|
|
||||||
ctx.status(200),
|
|
||||||
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getByTestId } = render(<Login />);
|
|
||||||
|
|
||||||
expect(getByTestId('initiate_login')).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Setup Check', () => {
|
|
||||||
it('redirects to signup when setup is not completed', async () => {
|
|
||||||
server.use(
|
|
||||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
|
||||||
res(
|
|
||||||
ctx.status(200),
|
|
||||||
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Login />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays on login page when setup is completed', async () => {
|
|
||||||
render(<Login />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles version API error gracefully', async () => {
|
|
||||||
server.use(
|
|
||||||
rest.get(VERSION_ENDPOINT, (req, res, ctx) =>
|
|
||||||
res(ctx.status(500), ctx.json({ error: 'Server error' })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Login />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Session Context Fetching', () => {
|
|
||||||
it('fetches session context on next button click and enables password', async () => {
|
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
|
||||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getByTestId } = render(<Login />);
|
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
|
||||||
const input = getByTestId('email');
|
|
||||||
expect(input).not.toBeDisabled();
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
|
||||||
|
|
||||||
const nextButton = await waitFor(() => {
|
|
||||||
const button = getByTestId('initiate_login');
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
return button;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByTestId('password')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles session context API errors', async () => {
|
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
|
||||||
res(
|
|
||||||
ctx.status(500),
|
|
||||||
ctx.json({
|
|
||||||
error: {
|
|
||||||
code: 'internal_server',
|
|
||||||
message: 'couldnt fetch the sessions context',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getByTestId, getByText } = render(<Login />);
|
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
|
||||||
const input = getByTestId('email');
|
|
||||||
expect(input).not.toBeDisabled();
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
|
||||||
|
|
||||||
const nextButton = await waitFor(() => {
|
|
||||||
const button = getByTestId('initiate_login');
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
return button;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('auto-selects organization when only one exists', async () => {
|
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
|
||||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getByTestId } = render(<Login />);
|
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
|
||||||
const input = getByTestId('email');
|
|
||||||
expect(input).not.toBeDisabled();
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
|
||||||
|
|
||||||
const nextButton = await waitFor(() => {
|
|
||||||
const button = getByTestId('initiate_login');
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
return button;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Should show password field directly (no org selection needed)
|
|
||||||
expect(getByTestId('password')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Organization Selection', () => {
|
|
||||||
it('shows organization dropdown when multiple orgs exist', async () => {
|
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
|
||||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getByTestId, getByText } = render(<Login />);
|
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
|
||||||
const input = getByTestId('email');
|
|
||||||
expect(input).not.toBeDisabled();
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
|
||||||
|
|
||||||
const nextButton = await waitFor(() => {
|
|
||||||
const button = getByTestId('initiate_login');
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
return button;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByText('Organization Name')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
await screen.findByRole('combobox');
|
|
||||||
|
|
||||||
// Click on the dropdown to reveal the options
|
|
||||||
await user.click(screen.getByRole('combobox'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates selected organization on dropdown change', async () => {
|
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
|
||||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Login />);
|
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
|
||||||
const input = screen.getByTestId('email');
|
|
||||||
expect(input).not.toBeDisabled();
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
|
||||||
|
|
||||||
const nextButton = await waitFor(() => {
|
|
||||||
const button = screen.getByTestId('initiate_login');
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
return button;
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await screen.findByRole('combobox');
|
|
||||||
|
|
||||||
// Select CALLBACK_AUTHN_ORG
|
|
||||||
await user.click(screen.getByRole('combobox'));
|
|
||||||
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
|
|
||||||
|
|
||||||
await screen.findByRole('button', { name: /sign in with sso/i });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Password Authentication', () => {
|
describe('Password Authentication', () => {
|
||||||
it('shows password field when password auth is supported', async () => {
|
it('shows password field when password auth is supported', async () => {
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
@@ -411,7 +70,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
const { getByTestId, getByText } = render(<Login />);
|
const { getByTestId, getByText } = render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = getByTestId('email');
|
const input = getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -448,7 +106,6 @@ describe('Login Component', () => {
|
|||||||
initialRoute: '/login?password=Y',
|
initialRoute: '/login?password=Y',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = getByTestId('email');
|
const input = getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -466,7 +123,6 @@ describe('Login Component', () => {
|
|||||||
await user.click(nextButton);
|
await user.click(nextButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Should show password field even for SSO org due to password=Y override
|
|
||||||
expect(getByTestId('password')).toBeInTheDocument();
|
expect(getByTestId('password')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -484,7 +140,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
const { getByTestId, queryByTestId } = render(<Login />);
|
const { getByTestId, queryByTestId } = render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = getByTestId('email');
|
const input = getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -510,10 +165,7 @@ describe('Login Component', () => {
|
|||||||
it('redirects to callback URL on button click', async () => {
|
it('redirects to callback URL on button click', async () => {
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
// Mock window.location.href
|
const mockLocation = { href: 'http://localhost/' };
|
||||||
const mockLocation = {
|
|
||||||
href: 'http://localhost/',
|
|
||||||
};
|
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
value: mockLocation,
|
value: mockLocation,
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -527,7 +179,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
const { getByTestId, queryByTestId } = render(<Login />);
|
const { getByTestId, queryByTestId } = render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = getByTestId('email');
|
const input = getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -552,7 +203,6 @@ describe('Login Component', () => {
|
|||||||
const callbackButton = getByTestId('callback_authn_submit');
|
const callbackButton = getByTestId('callback_authn_submit');
|
||||||
await user.click(callbackButton);
|
await user.click(callbackButton);
|
||||||
|
|
||||||
// Check that window.location.href was set to the callback URL
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.location.href).toBe(CALLBACK_AUTHN_URL);
|
expect(window.location.href).toBe(CALLBACK_AUTHN_URL);
|
||||||
});
|
});
|
||||||
@@ -567,7 +217,7 @@ describe('Login Component', () => {
|
|||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||||
),
|
),
|
||||||
rest.post('*/api/v2/sessions/email_password', async (_, res, ctx) =>
|
rest.post(EMAIL_PASSWORD_ENDPOINT, async (_, res, ctx) =>
|
||||||
res(
|
res(
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
ctx.json({ status: 'success', data: mockEmailPasswordResponse }),
|
ctx.json({ status: 'success', data: mockEmailPasswordResponse }),
|
||||||
@@ -577,7 +227,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
const { getByTestId } = render(<Login />);
|
const { getByTestId } = render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = getByTestId('email');
|
const input = getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -604,8 +253,6 @@ describe('Login Component', () => {
|
|||||||
await user.type(passwordInput, 'testpassword');
|
await user.type(passwordInput, 'testpassword');
|
||||||
await user.click(loginButton);
|
await user.click(loginButton);
|
||||||
|
|
||||||
// do not test for the request paramters here. Reference: https://mswjs.io/docs/best-practices/avoid-request-assertions
|
|
||||||
// rather test for the effects of the request
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(localStorage.getItem('AUTH_TOKEN')).toBe('mock-access-token');
|
expect(localStorage.getItem('AUTH_TOKEN')).toBe('mock-access-token');
|
||||||
});
|
});
|
||||||
@@ -618,7 +265,7 @@ describe('Login Component', () => {
|
|||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||||
),
|
),
|
||||||
rest.post('*/api/v2/sessions/email_password', (_, res, ctx) =>
|
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
|
||||||
res(
|
res(
|
||||||
ctx.status(401),
|
ctx.status(401),
|
||||||
ctx.json({
|
ctx.json({
|
||||||
@@ -634,7 +281,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
const { getByTestId, getByText } = render(<Login />);
|
const { getByTestId, getByText } = render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = getByTestId('email');
|
const input = getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -708,14 +354,13 @@ describe('Login Component', () => {
|
|||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json({ data: mockOrgWithWarning })),
|
res(ctx.status(200), ctx.json({ data: mockOrgWithWarning })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = screen.getByTestId('email');
|
const input = screen.getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -742,23 +387,6 @@ describe('Login Component', () => {
|
|||||||
it('shows warning modal when a warning org is selected among multiple orgs', async () => {
|
it('shows warning modal when a warning org is selected among multiple orgs', async () => {
|
||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
// Mock multiple orgs including one with a warning
|
|
||||||
const mockMultiOrgWithWarning = {
|
|
||||||
orgs: [
|
|
||||||
{ id: 'org1', name: 'Org 1' },
|
|
||||||
{
|
|
||||||
id: 'org2',
|
|
||||||
name: 'Org 2',
|
|
||||||
warning: {
|
|
||||||
code: 'ORG_WARNING',
|
|
||||||
message: 'Organization has limited access',
|
|
||||||
url: 'https://example.com/warning',
|
|
||||||
errors: [{ message: 'Contact admin for full access' }],
|
|
||||||
} as ErrorV2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgWithWarning })),
|
res(ctx.status(200), ctx.json({ data: mockMultiOrgWithWarning })),
|
||||||
@@ -767,7 +395,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
const { getByTestId } = render(<Login />);
|
const { getByTestId } = render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = getByTestId('email');
|
const input = getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -786,9 +413,8 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
await screen.findByRole('combobox');
|
await screen.findByRole('combobox');
|
||||||
|
|
||||||
// Select the organization with a warning
|
|
||||||
await user.click(screen.getByRole('combobox'));
|
await user.click(screen.getByRole('combobox'));
|
||||||
await user.click(screen.getByText('Org 2'));
|
await user.click(screen.getByText('Warning Organization'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -803,7 +429,7 @@ describe('Login Component', () => {
|
|||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(
|
res(
|
||||||
ctx.delay(100),
|
ctx.delay(100),
|
||||||
ctx.status(200),
|
ctx.status(200),
|
||||||
@@ -814,7 +440,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = screen.getByTestId('email');
|
const input = screen.getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -831,7 +456,6 @@ describe('Login Component', () => {
|
|||||||
|
|
||||||
await user.click(nextButton);
|
await user.click(nextButton);
|
||||||
|
|
||||||
// Button should be disabled during API call
|
|
||||||
expect(nextButton).toBeDisabled();
|
expect(nextButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -839,17 +463,15 @@ describe('Login Component', () => {
|
|||||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
// Initially shows "Next" button
|
|
||||||
expect(screen.getByTestId('initiate_login')).toBeInTheDocument();
|
expect(screen.getByTestId('initiate_login')).toBeInTheDocument();
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = screen.getByTestId('email');
|
const input = screen.getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -867,7 +489,6 @@ describe('Login Component', () => {
|
|||||||
await user.click(nextButton);
|
await user.click(nextButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Should show "Sign in with Password" button for password auth
|
|
||||||
expect(screen.getByTestId('password_authn_submit')).toBeInTheDocument();
|
expect(screen.getByTestId('password_authn_submit')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('initiate_login')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('initiate_login')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -884,14 +505,13 @@ describe('Login Component', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json({ data: mockNoOrgs })),
|
res(ctx.status(200), ctx.json({ data: mockNoOrgs })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = screen.getByTestId('email');
|
const input = screen.getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -909,7 +529,6 @@ describe('Login Component', () => {
|
|||||||
await user.click(nextButton);
|
await user.click(nextButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Should not show any auth method buttons
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByTestId('password_authn_submit'),
|
screen.queryByTestId('password_authn_submit'),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@@ -937,14 +556,13 @@ describe('Login Component', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json({ data: mockNoAuthSupport })),
|
res(ctx.status(200), ctx.json({ data: mockNoAuthSupport })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
// Wait for version API to complete (email input becomes enabled)
|
|
||||||
const emailInput = await waitFor(() => {
|
const emailInput = await waitFor(() => {
|
||||||
const input = screen.getByTestId('email');
|
const input = screen.getByTestId('email');
|
||||||
expect(input).not.toBeDisabled();
|
expect(input).not.toBeDisabled();
|
||||||
@@ -962,7 +580,6 @@ describe('Login Component', () => {
|
|||||||
await user.click(nextButton);
|
await user.click(nextButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Should not show any auth method buttons
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByTestId('password_authn_submit'),
|
screen.queryByTestId('password_authn_submit'),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
241
frontend/src/container/Login/__tests__/Login.session.test.tsx
Normal file
241
frontend/src/container/Login/__tests__/Login.session.test.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Login - Session Context & Organization Tests
|
||||||
|
*
|
||||||
|
* Split from Login.test.tsx for better parallelization.
|
||||||
|
* Tests session context fetching and organization selection.
|
||||||
|
*/
|
||||||
|
import { rest, server } from 'mocks-server/server';
|
||||||
|
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import Login from '../index';
|
||||||
|
import {
|
||||||
|
CALLBACK_AUTHN_ORG,
|
||||||
|
mockMultiOrgMixedAuth,
|
||||||
|
mockSingleOrgPasswordAuth,
|
||||||
|
mockVersionSetupCompleted,
|
||||||
|
PASSWORD_AUTHN_EMAIL,
|
||||||
|
PASSWORD_AUTHN_ORG,
|
||||||
|
SESSIONS_CONTEXT_ENDPOINT,
|
||||||
|
VERSION_ENDPOINT,
|
||||||
|
} from './Login.test-utils';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
jest.mock('lib/history', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
push: jest.fn(),
|
||||||
|
location: {
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('Login - Session Context & Organization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
server.use(
|
||||||
|
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session Context Fetching', () => {
|
||||||
|
it('fetches session context on next button click and enables password', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Login />);
|
||||||
|
|
||||||
|
const emailInput = await waitFor(() => {
|
||||||
|
const input = getByTestId('email');
|
||||||
|
expect(input).not.toBeDisabled();
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||||
|
|
||||||
|
const nextButton = await waitFor(() => {
|
||||||
|
const button = getByTestId('initiate_login');
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByTestId('password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles session context API errors', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(
|
||||||
|
ctx.status(500),
|
||||||
|
ctx.json({
|
||||||
|
error: {
|
||||||
|
code: 'internal_server',
|
||||||
|
message: 'couldnt fetch the sessions context',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getByTestId, getByText } = render(<Login />);
|
||||||
|
|
||||||
|
const emailInput = await waitFor(() => {
|
||||||
|
const input = getByTestId('email');
|
||||||
|
expect(input).not.toBeDisabled();
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||||
|
|
||||||
|
const nextButton = await waitFor(() => {
|
||||||
|
const button = getByTestId('initiate_login');
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-selects organization when only one exists', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Login />);
|
||||||
|
|
||||||
|
const emailInput = await waitFor(() => {
|
||||||
|
const input = getByTestId('email');
|
||||||
|
expect(input).not.toBeDisabled();
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||||
|
|
||||||
|
const nextButton = await waitFor(() => {
|
||||||
|
const button = getByTestId('initiate_login');
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByTestId('password')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Organization Selection', () => {
|
||||||
|
it('shows organization dropdown when multiple orgs exist', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getByTestId, getByText } = render(<Login />);
|
||||||
|
|
||||||
|
const emailInput = await waitFor(() => {
|
||||||
|
const input = getByTestId('email');
|
||||||
|
expect(input).not.toBeDisabled();
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||||
|
|
||||||
|
const nextButton = await waitFor(() => {
|
||||||
|
const button = getByTestId('initiate_login');
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Organization Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await screen.findByRole('combobox');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('combobox'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates selected organization on dropdown change', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Login />);
|
||||||
|
|
||||||
|
const emailInput = await waitFor(() => {
|
||||||
|
const input = screen.getByTestId('email');
|
||||||
|
expect(input).not.toBeDisabled();
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||||
|
|
||||||
|
const nextButton = await waitFor(() => {
|
||||||
|
const button = screen.getByTestId('initiate_login');
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await screen.findByRole('combobox');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('combobox'));
|
||||||
|
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: /sign in with sso/i });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
127
frontend/src/container/Login/__tests__/Login.setup.test.tsx
Normal file
127
frontend/src/container/Login/__tests__/Login.setup.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Login - Initial Render & Setup Tests
|
||||||
|
*
|
||||||
|
* Split from Login.test.tsx for better parallelization.
|
||||||
|
* Tests initial render, loading states, and setup validation.
|
||||||
|
*/
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import history from 'lib/history';
|
||||||
|
import { rest, server } from 'mocks-server/server';
|
||||||
|
import { render, screen, waitFor } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import Login from '../index';
|
||||||
|
import {
|
||||||
|
mockVersionSetupCompleted,
|
||||||
|
mockVersionSetupIncomplete,
|
||||||
|
VERSION_ENDPOINT,
|
||||||
|
} from './Login.test-utils';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
jest.mock('lib/history', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
push: jest.fn(),
|
||||||
|
location: {
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||||
|
typeof history.push
|
||||||
|
>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('Login - Initial Render & Setup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
server.use(
|
||||||
|
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initial Render', () => {
|
||||||
|
it('renders login form with email input and next button', () => {
|
||||||
|
const { getByTestId, getByPlaceholderText } = render(<Login />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(getByTestId('email')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('initiate_login')).toBeInTheDocument();
|
||||||
|
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state when version data is being fetched', () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(
|
||||||
|
ctx.delay(100),
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Login />);
|
||||||
|
|
||||||
|
expect(getByTestId('initiate_login')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Setup Check', () => {
|
||||||
|
it('redirects to signup when setup is not completed', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Login />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays on login page when setup is completed', async () => {
|
||||||
|
render(<Login />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles version API error gracefully', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(500), ctx.json({ error: 'Server error' })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Login />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
172
frontend/src/container/Login/__tests__/Login.test-utils.ts
Normal file
172
frontend/src/container/Login/__tests__/Login.test-utils.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Shared test utilities for Login tests.
|
||||||
|
* Extract common mocks, data, and setup to avoid duplication across split test files.
|
||||||
|
*/
|
||||||
|
import { rest, server } from 'mocks-server/server';
|
||||||
|
import { Info } from 'types/api/v1/version/get';
|
||||||
|
import { SessionsContext } from 'types/api/v2/sessions/context/get';
|
||||||
|
import { Token } from 'types/api/v2/sessions/email_password/post';
|
||||||
|
import { ErrorV2 } from 'types/api';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const VERSION_ENDPOINT = '*/api/v1/version';
|
||||||
|
export const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
|
||||||
|
export const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password';
|
||||||
|
export const CALLBACK_AUTHN_ORG = 'callback_authn_org';
|
||||||
|
export const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
|
||||||
|
export const PASSWORD_AUTHN_ORG = 'password_authn_org';
|
||||||
|
export const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK DATA
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const mockVersionSetupCompleted: Info = {
|
||||||
|
setupCompleted: true,
|
||||||
|
ee: 'Y',
|
||||||
|
version: '0.25.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockVersionSetupIncomplete: Info = {
|
||||||
|
setupCompleted: false,
|
||||||
|
ee: 'Y',
|
||||||
|
version: '0.25.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockSingleOrgPasswordAuth: SessionsContext = {
|
||||||
|
exists: true,
|
||||||
|
orgs: [
|
||||||
|
{
|
||||||
|
id: 'org-1',
|
||||||
|
name: 'Test Organization',
|
||||||
|
authNSupport: {
|
||||||
|
password: [{ provider: 'email_password' }],
|
||||||
|
callback: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockSingleOrgCallbackAuth: SessionsContext = {
|
||||||
|
exists: true,
|
||||||
|
orgs: [
|
||||||
|
{
|
||||||
|
id: 'org-1',
|
||||||
|
name: 'Test Organization',
|
||||||
|
authNSupport: {
|
||||||
|
password: [],
|
||||||
|
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockMultiOrgMixedAuth: SessionsContext = {
|
||||||
|
exists: true,
|
||||||
|
orgs: [
|
||||||
|
{
|
||||||
|
id: 'org-1',
|
||||||
|
name: PASSWORD_AUTHN_ORG,
|
||||||
|
authNSupport: {
|
||||||
|
password: [{ provider: 'email_password' }],
|
||||||
|
callback: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'org-2',
|
||||||
|
name: CALLBACK_AUTHN_ORG,
|
||||||
|
authNSupport: {
|
||||||
|
password: [],
|
||||||
|
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockOrgWithWarning: SessionsContext = {
|
||||||
|
exists: true,
|
||||||
|
orgs: [
|
||||||
|
{
|
||||||
|
id: 'org-1',
|
||||||
|
name: 'Warning Organization',
|
||||||
|
authNSupport: {
|
||||||
|
password: [{ provider: 'email_password' }],
|
||||||
|
callback: [],
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
code: 'ORG_WARNING',
|
||||||
|
message: 'Organization has limited access',
|
||||||
|
url: 'https://example.com/warning',
|
||||||
|
errors: [{ message: 'Contact admin for full access' }],
|
||||||
|
} as ErrorV2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockMultiOrgWithWarning: SessionsContext = {
|
||||||
|
exists: true,
|
||||||
|
orgs: [
|
||||||
|
{
|
||||||
|
id: 'org-1',
|
||||||
|
name: 'Normal Organization',
|
||||||
|
authNSupport: {
|
||||||
|
password: [{ provider: 'email_password' }],
|
||||||
|
callback: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'org-2',
|
||||||
|
name: 'Warning Organization',
|
||||||
|
authNSupport: {
|
||||||
|
password: [{ provider: 'email_password' }],
|
||||||
|
callback: [],
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
code: 'ORG_WARNING',
|
||||||
|
message: 'Organization has limited access',
|
||||||
|
url: 'https://example.com/warning',
|
||||||
|
errors: [{ message: 'Contact admin for full access' }],
|
||||||
|
} as ErrorV2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockEmailPasswordResponse: Token = {
|
||||||
|
accessToken: 'mock-access-token',
|
||||||
|
refreshToken: 'mock-refresh-token',
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK SETUP HELPERS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function setupVersionEndpoint(
|
||||||
|
data: Info = mockVersionSetupCompleted,
|
||||||
|
): void {
|
||||||
|
server.use(
|
||||||
|
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ data, status: 'success' })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupSessionContextEndpoint(data: SessionsContext): void {
|
||||||
|
server.use(
|
||||||
|
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ data })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupEmailPasswordEndpoint(
|
||||||
|
data: Token = mockEmailPasswordResponse,
|
||||||
|
): void {
|
||||||
|
server.use(
|
||||||
|
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({ data })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
|
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
|
||||||
import { IAppContext, IUser } from 'providers/App/types';
|
import { IAppContext } from 'providers/App/types';
|
||||||
|
import { getAppContextMockMinimal } from 'tests/test-utils';
|
||||||
import { Channels } from 'types/api/channels/getAll';
|
import { Channels } from 'types/api/channels/getAll';
|
||||||
|
|
||||||
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
|
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
|
||||||
@@ -78,49 +79,14 @@ export function getUseRoutingPoliciesMockData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getAppContextMockMinimal from 'tests/test-utils' directly.
|
||||||
|
* This is a backwards-compatible wrapper that will be removed in a future version.
|
||||||
|
*/
|
||||||
export function getAppContextMockState(
|
export function getAppContextMockState(
|
||||||
overrides?: Partial<IUser>,
|
overrides?: Partial<IAppContext['user']>,
|
||||||
): IAppContext {
|
): IAppContext {
|
||||||
return {
|
return getAppContextMockMinimal(overrides);
|
||||||
user: {
|
|
||||||
accessJwt: 'some-token',
|
|
||||||
refreshJwt: 'some-refresh-token',
|
|
||||||
id: 'some-user-id',
|
|
||||||
email: 'user@signoz.io',
|
|
||||||
displayName: 'John Doe',
|
|
||||||
createdAt: 1732544623,
|
|
||||||
organization: 'Nightswatch',
|
|
||||||
orgId: 'does-not-matter-id',
|
|
||||||
role: 'ADMIN',
|
|
||||||
...overrides,
|
|
||||||
},
|
|
||||||
activeLicense: null,
|
|
||||||
trialInfo: null,
|
|
||||||
featureFlags: null,
|
|
||||||
orgPreferences: null,
|
|
||||||
userPreferences: null,
|
|
||||||
isLoggedIn: false,
|
|
||||||
org: null,
|
|
||||||
isFetchingUser: false,
|
|
||||||
isFetchingActiveLicense: false,
|
|
||||||
isFetchingFeatureFlags: false,
|
|
||||||
isFetchingOrgPreferences: false,
|
|
||||||
userFetchError: undefined,
|
|
||||||
activeLicenseFetchError: null,
|
|
||||||
featureFlagsFetchError: undefined,
|
|
||||||
orgPreferencesFetchError: undefined,
|
|
||||||
changelog: null,
|
|
||||||
showChangelogModal: false,
|
|
||||||
activeLicenseRefetch: jest.fn(),
|
|
||||||
updateUser: jest.fn(),
|
|
||||||
updateOrgPreferences: jest.fn(),
|
|
||||||
updateUserPreferenceInContext: jest.fn(),
|
|
||||||
updateOrg: jest.fn(),
|
|
||||||
updateChangelog: jest.fn(),
|
|
||||||
toggleChangelogModal: jest.fn(),
|
|
||||||
versionData: null,
|
|
||||||
hasEditPermission: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockLocation(pathname: string): jest.Mock {
|
export function mockLocation(pathname: string): jest.Mock {
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ import { Span } from 'types/api/trace/getTraceV2';
|
|||||||
import { formatEpochTimestamp } from 'utils/timeUtils';
|
import { formatEpochTimestamp } from 'utils/timeUtils';
|
||||||
|
|
||||||
import Attributes from './Attributes/Attributes';
|
import Attributes from './Attributes/Attributes';
|
||||||
import { RelatedSignalsViews } from './constants';
|
import {
|
||||||
|
RelatedSignalsViews,
|
||||||
|
SPAN_PERCENTILE_INITIAL_DELAY_MS,
|
||||||
|
} from './constants';
|
||||||
import EventAttribute from './Events/components/EventAttribute';
|
import EventAttribute from './Events/components/EventAttribute';
|
||||||
import Events from './Events/Events';
|
import Events from './Events/Events';
|
||||||
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
||||||
@@ -410,7 +413,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setInitialWaitCompleted(true);
|
setInitialWaitCompleted(true);
|
||||||
}, 2000); // 2-second delay
|
}, SPAN_PERCENTILE_INITIAL_DELAY_MS);
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
// clean the old state around span percentile data
|
// clean the old state around span percentile data
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import { Span } from 'types/api/trace/getTraceV2';
|
|||||||
|
|
||||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||||
|
|
||||||
|
// Mock delay constant for faster tests
|
||||||
|
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||||
|
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||||
|
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock external dependencies
|
// Mock external dependencies
|
||||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||||
const mockNotifications = {
|
const mockNotifications = {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||||
|
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
import { server } from 'mocks-server/server';
|
import { server } from 'mocks-server/server';
|
||||||
@@ -6,6 +8,13 @@ import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
|||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||||
|
import {
|
||||||
|
mockSpanPercentileResponse,
|
||||||
|
mockUserPreferenceResponse,
|
||||||
|
} from './SpanDetailsDrawer.test-utils';
|
||||||
|
|
||||||
|
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||||
|
const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||||
import {
|
import {
|
||||||
expectedHostOnlyMetadata,
|
expectedHostOnlyMetadata,
|
||||||
expectedInfraMetadata,
|
expectedInfraMetadata,
|
||||||
@@ -22,6 +31,21 @@ import {
|
|||||||
} from './infraMetricsTestData';
|
} from './infraMetricsTestData';
|
||||||
|
|
||||||
// Mock external dependencies
|
// Mock external dependencies
|
||||||
|
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||||
|
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||||
|
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('react-router-dom'),
|
...jest.requireActual('react-router-dom'),
|
||||||
useLocation: (): { pathname: string } => ({
|
useLocation: (): { pathname: string } => ({
|
||||||
@@ -172,6 +196,10 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
|||||||
mockWindowOpen.mockClear();
|
mockWindowOpen.mockClear();
|
||||||
mockUpdateAllQueriesOperators.mockClear();
|
mockUpdateAllQueriesOperators.mockClear();
|
||||||
|
|
||||||
|
// Setup default mocks for percentile APIs to avoid delays
|
||||||
|
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
|
||||||
|
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
|
||||||
|
|
||||||
// Setup API call tracking for infra metrics
|
// Setup API call tracking for infra metrics
|
||||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||||
apiCallHistory.push(query);
|
apiCallHistory.push(query);
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* SpanDetailsDrawer - Logs Tests
|
||||||
|
*
|
||||||
|
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||||
|
* Tests logs tab display, API queries, navigation, and highlighting.
|
||||||
|
*/
|
||||||
|
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||||
|
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expectedAfterFilterExpression,
|
||||||
|
expectedBeforeFilterExpression,
|
||||||
|
expectedSpanFilterExpression,
|
||||||
|
expectedTraceOnlyFilterExpression,
|
||||||
|
mockAfterLogsResponse,
|
||||||
|
mockBeforeLogsResponse,
|
||||||
|
mockSpanLogsResponse,
|
||||||
|
} from './mockData';
|
||||||
|
import {
|
||||||
|
ApiCallHistory,
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
clearAllMocks,
|
||||||
|
createApiCallHistory,
|
||||||
|
mockSafeNavigate,
|
||||||
|
mockSpanPercentileResponse,
|
||||||
|
mockUpdateAllQueriesOperators,
|
||||||
|
mockUserPreferenceResponse,
|
||||||
|
mockWindowOpen,
|
||||||
|
renderSpanDetailsDrawer,
|
||||||
|
setupLogsApiMock,
|
||||||
|
setupSpanDetailsDrawerMocks,
|
||||||
|
} from './SpanDetailsDrawer.test-utils';
|
||||||
|
|
||||||
|
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||||
|
const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK SETUP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||||
|
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||||
|
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string; search: string } => ({
|
||||||
|
pathname: ROUTES.TRACE_DETAIL,
|
||||||
|
search: 'trace_id=test-trace-id',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||||
|
GetMetricQueryRange: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'components/Logs/RawLogView',
|
||||||
|
() =>
|
||||||
|
function MockRawLogView({
|
||||||
|
data,
|
||||||
|
onLogClick,
|
||||||
|
isHighlighted,
|
||||||
|
helpTooltip,
|
||||||
|
}: {
|
||||||
|
data: any;
|
||||||
|
onLogClick: (data: any, event: React.MouseEvent) => void;
|
||||||
|
isHighlighted: boolean;
|
||||||
|
helpTooltip: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`raw-log-${data.id}`}
|
||||||
|
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||||
|
title={helpTooltip}
|
||||||
|
onClick={(e): void => onLogClick?.(data, e)}
|
||||||
|
>
|
||||||
|
<div>{data.body}</div>
|
||||||
|
<div>{data.timestamp}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||||
|
PreferenceContextProvider: ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SpanDetailsDrawer - Logs', () => {
|
||||||
|
let apiCallHistory: ApiCallHistory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
clearAllMocks();
|
||||||
|
setupSpanDetailsDrawerMocks();
|
||||||
|
|
||||||
|
// Setup percentile API mocks to avoid delays
|
||||||
|
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
|
||||||
|
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
|
||||||
|
|
||||||
|
apiCallHistory = createApiCallHistory();
|
||||||
|
setupLogsApiMock(
|
||||||
|
apiCallHistory,
|
||||||
|
mockSpanLogsResponse,
|
||||||
|
mockBeforeLogsResponse,
|
||||||
|
mockAfterLogsResponse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display logs tab in right sidebar when span is selected', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
expect(logsButton).toBeInTheDocument();
|
||||||
|
expect(logsButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should open related logs view when logs tab is clicked',
|
||||||
|
async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
await user.click(logsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('overlay-scrollbar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('raw-log-context-log-before'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should make 3 API queries when logs tab is opened',
|
||||||
|
async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
await user.click(logsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
span_logs: spanQuery,
|
||||||
|
before_logs: beforeQuery,
|
||||||
|
after_logs: afterQuery,
|
||||||
|
trace_only_logs: traceOnlyQuery,
|
||||||
|
} = apiCallHistory;
|
||||||
|
|
||||||
|
expect((spanQuery as any).query.builder.queryData[0].filter.expression).toBe(
|
||||||
|
expectedSpanFilterExpression,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(beforeQuery as any).query.builder.queryData[0].filter.expression,
|
||||||
|
).toBe(expectedBeforeFilterExpression);
|
||||||
|
expect(
|
||||||
|
(afterQuery as any).query.builder.queryData[0].filter.expression,
|
||||||
|
).toBe(expectedAfterFilterExpression);
|
||||||
|
|
||||||
|
if (traceOnlyQuery) {
|
||||||
|
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||||
|
expectedTraceOnlyFilterExpression,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should use correct timestamp ordering for different query types',
|
||||||
|
async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
await user.click(logsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
span_logs: spanQuery,
|
||||||
|
before_logs: beforeQuery,
|
||||||
|
after_logs: afterQuery,
|
||||||
|
} = apiCallHistory;
|
||||||
|
|
||||||
|
expect((spanQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(beforeQuery as any).query.builder.queryData[0].orderBy[0].order,
|
||||||
|
).toBe('desc');
|
||||||
|
expect((afterQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
|
||||||
|
'asc',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should navigate to logs explorer with span filters when span log is clicked',
|
||||||
|
async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
await user.click(logsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||||
|
await user.click(spanLog);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||||
|
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||||
|
|
||||||
|
expect(urlParams.get(QueryParams.activeLogId)).toBe('"span-log-1"');
|
||||||
|
expect(urlParams.get(QueryParams.startTime)).toBe('1640994900000');
|
||||||
|
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000');
|
||||||
|
|
||||||
|
const compositeQuery = JSON.parse(
|
||||||
|
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||||
|
);
|
||||||
|
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
|
||||||
|
"trace_id = 'test-trace-id'",
|
||||||
|
);
|
||||||
|
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should navigate to logs explorer with trace filter when context log is clicked',
|
||||||
|
async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
await user.click(logsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('raw-log-context-log-before'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextLog = screen.getByTestId('raw-log-context-log-before');
|
||||||
|
await user.click(contextLog);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||||
|
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||||
|
|
||||||
|
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
|
||||||
|
|
||||||
|
const compositeQuery = JSON.parse(
|
||||||
|
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||||
|
);
|
||||||
|
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
|
||||||
|
"trace_id = 'test-trace-id'",
|
||||||
|
);
|
||||||
|
expect(compositeQuery.builder.queryData[0].filter.expression).not.toContain(
|
||||||
|
'span_id',
|
||||||
|
);
|
||||||
|
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should always open logs explorer in new tab regardless of click type',
|
||||||
|
async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
await user.click(logsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||||
|
await user.click(spanLog);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should display span logs as highlighted and context logs as regular',
|
||||||
|
async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||||
|
await user.click(logsButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('raw-log-context-log-before'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const spanLog1 = screen.getByTestId('raw-log-span-log-1');
|
||||||
|
const spanLog2 = screen.getByTestId('raw-log-span-log-2');
|
||||||
|
expect(spanLog1).toHaveClass('log-highlighted');
|
||||||
|
expect(spanLog2).toHaveClass('log-highlighted');
|
||||||
|
expect(spanLog1).toHaveAttribute(
|
||||||
|
'title',
|
||||||
|
'This log belongs to the current span',
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextLogBefore = screen.getByTestId('raw-log-context-log-before');
|
||||||
|
const contextLogAfter = screen.getByTestId('raw-log-context-log-after');
|
||||||
|
expect(contextLogBefore).toHaveClass('log-context');
|
||||||
|
expect(contextLogAfter).toHaveClass('log-context');
|
||||||
|
expect(contextLogBefore).not.toHaveAttribute('title');
|
||||||
|
},
|
||||||
|
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
/**
|
||||||
|
* SpanDetailsDrawer - Span Percentile Tests
|
||||||
|
*
|
||||||
|
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||||
|
* Tests percentile display, expansion, time range selection, and resource attributes.
|
||||||
|
*/
|
||||||
|
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||||
|
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||||
|
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
userEvent,
|
||||||
|
waitFor,
|
||||||
|
} from 'tests/test-utils';
|
||||||
|
import { SuccessResponseV2 } from 'types/api';
|
||||||
|
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
|
||||||
|
|
||||||
|
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||||
|
import { mockEmptyLogsResponse, mockSpan } from './mockData';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPED MOCKS (defined before jest.mock for proper hoisting)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||||
|
const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||||
|
const mockSafeNavigate = jest.fn();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// JEST MOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||||
|
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||||
|
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string; search: string } => ({
|
||||||
|
pathname: '/trace',
|
||||||
|
search: 'trace_id=test-trace-id',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
aggregateOperator: 'noop',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
expression: 'A',
|
||||||
|
disabled: false,
|
||||||
|
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||||
|
groupBy: [],
|
||||||
|
limit: null,
|
||||||
|
having: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
},
|
||||||
|
queryType: 'builder',
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||||
|
GetMetricQueryRange: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||||
|
PreferenceContextProvider: ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK DATA
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const mockSpanPercentileResponse = {
|
||||||
|
httpStatusCode: 200 as const,
|
||||||
|
data: {
|
||||||
|
percentiles: {
|
||||||
|
p50: 500000000,
|
||||||
|
p90: 1000000000,
|
||||||
|
p95: 1500000000,
|
||||||
|
p99: 2000000000,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
percentile: 75.5,
|
||||||
|
description: 'This span is in the 75th percentile',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserPreferenceResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
httpStatusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Success',
|
||||||
|
data: {
|
||||||
|
name: 'span_percentile_resource_attributes',
|
||||||
|
description: 'Resource attributes for span percentile calculation',
|
||||||
|
valueType: 'array',
|
||||||
|
defaultValue: [],
|
||||||
|
value: ['service.name', 'name', 'http.method'],
|
||||||
|
allowedValues: [],
|
||||||
|
allowedScopes: [],
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSpanPercentileErrorResponse = {
|
||||||
|
httpStatusCode: 500,
|
||||||
|
data: null,
|
||||||
|
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const P75_TEXT = 'p75';
|
||||||
|
const SPAN_PERCENTILE_TEXT = 'Span Percentile';
|
||||||
|
const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER = 'Search resource attributes';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RENDER HELPER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const mockQueryBuilderContextValue = {
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stagedQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
panelType: 'list',
|
||||||
|
redirectWithQuery: jest.fn(),
|
||||||
|
handleRunQuery: jest.fn(),
|
||||||
|
handleStageQuery: jest.fn(),
|
||||||
|
resetQuery: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSpanDetailsDrawer(): void {
|
||||||
|
render(
|
||||||
|
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||||
|
<SpanDetailsDrawer
|
||||||
|
isSpanDetailsDocked={false}
|
||||||
|
setIsSpanDetailsDocked={jest.fn()}
|
||||||
|
selectedSpan={mockSpan}
|
||||||
|
traceStartTime={1640995200000}
|
||||||
|
traceEndTime={1640995260000}
|
||||||
|
/>
|
||||||
|
</QueryBuilderContext.Provider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SpanDetailsDrawer - Span Percentile Functionality', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockSafeNavigate.mockClear();
|
||||||
|
mockUpdateAllQueriesOperators.mockClear();
|
||||||
|
|
||||||
|
// Setup default mocks
|
||||||
|
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
|
||||||
|
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
|
||||||
|
|
||||||
|
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
|
||||||
|
Promise.resolve(mockEmptyLogsResponse),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display span percentile value after successful API call', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call API with correct parameters', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetSpanPercentiles).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetSpanPercentiles).toHaveBeenCalledWith({
|
||||||
|
start: expect.any(Number),
|
||||||
|
end: expect.any(Number),
|
||||||
|
spanDuration: mockSpan.durationNano,
|
||||||
|
serviceName: mockSpan.serviceName,
|
||||||
|
name: mockSpan.name,
|
||||||
|
resourceAttributes: expect.any(Object),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle user preference loading', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetUserPreference).toHaveBeenCalledWith({
|
||||||
|
name: 'span_percentile_resource_attributes',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading spinner while fetching percentile data', async () => {
|
||||||
|
mockGetSpanPercentiles.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(mockSpanPercentileResponse), 1000);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const spinnerContainer = document.querySelector(
|
||||||
|
'.loading-spinner-container',
|
||||||
|
);
|
||||||
|
expect(spinnerContainer).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API error gracefully', async () => {
|
||||||
|
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileErrorResponse);
|
||||||
|
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display percentile value when API returns non-200 status', async () => {
|
||||||
|
mockGetSpanPercentiles.mockResolvedValue({
|
||||||
|
httpStatusCode: 500 as const,
|
||||||
|
data: null,
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getSpanPercentiles>>);
|
||||||
|
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty percentile data gracefully', async () => {
|
||||||
|
mockGetSpanPercentiles.mockResolvedValue({
|
||||||
|
httpStatusCode: 200,
|
||||||
|
data: {
|
||||||
|
percentiles: {},
|
||||||
|
position: {
|
||||||
|
percentile: 0,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('p0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display tooltip with correct content', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
fireEvent.mouseEnter(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/out of the distribution/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/evaluated for 1 hour\(s\) since the span start time/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Click to learn more')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expand percentile details when percentile value is clicked', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
fireEvent.click(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/out of the distribution for this resource/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display percentile table with correct values', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
fireEvent.click(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Percentile')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('p50')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('p90')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('p95')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('p99')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(P75_TEXT)).toHaveLength(3);
|
||||||
|
expect(screen.getAllByText(/this span/i).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow time range selection and trigger API call', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
fireEvent.click(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeRangeSelector = screen.getByRole('combobox');
|
||||||
|
expect(timeRangeSelector).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/1.*hour/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
start: expect.any(Number),
|
||||||
|
end: expect.any(Number),
|
||||||
|
spanDuration: mockSpan.durationNano,
|
||||||
|
serviceName: mockSpan.serviceName,
|
||||||
|
name: mockSpan.name,
|
||||||
|
resourceAttributes: expect.any(Object),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show resource attributes selector when plus icon is clicked', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
fireEvent.click(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const plusIcon = screen.getByTestId('plus-icon');
|
||||||
|
fireEvent.click(plusIcon);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter resource attributes based on search query', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
fireEvent.click(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const plusIcon = screen.getByTestId('plus-icon');
|
||||||
|
fireEvent.click(plusIcon);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(
|
||||||
|
SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER,
|
||||||
|
);
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'http' } });
|
||||||
|
|
||||||
|
expect(screen.getAllByText('http.method').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(SPAN_ATTRIBUTES.HTTP_URL).length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText('http.status_code').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle resource attribute selection and trigger API call', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
fireEvent.click(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const plusIcon = screen.getByTestId('plus-icon');
|
||||||
|
fireEvent.click(plusIcon);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpMethodCheckbox = screen.getByRole('checkbox', {
|
||||||
|
name: /http\.method/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(httpMethodCheckbox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
resourceAttributes: expect.objectContaining({
|
||||||
|
'http.method': 'GET',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close resource attributes selector when check icon is clicked', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentileValue = screen.getByText(P75_TEXT);
|
||||||
|
await user.click(percentileValue);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const plusIcon = screen.getByTestId('plus-icon');
|
||||||
|
await user.click(plusIcon);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkIcon = screen.getByTestId('check-icon');
|
||||||
|
await user.click(checkIcon);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* SpanDetailsDrawer - Search Visibility Tests
|
||||||
|
*
|
||||||
|
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||||
|
* Tests search functionality in the attributes tab.
|
||||||
|
*/
|
||||||
|
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { fireEvent, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import { mockEmptyLogsResponse } from './mockData';
|
||||||
|
import {
|
||||||
|
clearAllMocks,
|
||||||
|
mockSafeNavigate,
|
||||||
|
mockUpdateAllQueriesOperators,
|
||||||
|
renderSpanDetailsDrawer,
|
||||||
|
SEARCH_PLACEHOLDER,
|
||||||
|
setupSpanDetailsDrawerMocks,
|
||||||
|
} from './SpanDetailsDrawer.test-utils';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK SETUP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||||
|
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||||
|
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock external dependencies
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string; search: string } => ({
|
||||||
|
pathname: '/trace',
|
||||||
|
search: 'trace_id=test-trace-id',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||||
|
GetMetricQueryRange: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock getSpanPercentiles API
|
||||||
|
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock getUserPreference API
|
||||||
|
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock PreferenceContextProvider
|
||||||
|
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||||
|
PreferenceContextProvider: ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SpanDetailsDrawer - Search Visibility User Flows', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
clearAllMocks();
|
||||||
|
setupSpanDetailsDrawerMocks();
|
||||||
|
|
||||||
|
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
|
||||||
|
Promise.resolve(mockEmptyLogsResponse),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Journey 1: Default Search Visibility
|
||||||
|
|
||||||
|
it('should display search visible by default when user opens span details', () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// User sees search input in the Attributes tab by default
|
||||||
|
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||||
|
expect(searchInput).toBeInTheDocument();
|
||||||
|
expect(searchInput).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter attributes when user types in search', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// User sees all attributes initially
|
||||||
|
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SPAN_ATTRIBUTES.HTTP_URL)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('http.status_code')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User types "method" in search
|
||||||
|
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||||
|
await user.type(searchInput, 'method');
|
||||||
|
|
||||||
|
// User sees only matching attributes
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(SPAN_ATTRIBUTES.HTTP_URL)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('http.status_code')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Journey 2: Search Toggle & Focus Management
|
||||||
|
|
||||||
|
it('should hide search when user clicks search icon', () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// User sees search initially
|
||||||
|
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User clicks search icon to hide search
|
||||||
|
const tabBar = screen.getByRole('tablist');
|
||||||
|
const searchIcon = tabBar.querySelector('.search-icon');
|
||||||
|
if (searchIcon) {
|
||||||
|
fireEvent.click(searchIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search is now hidden
|
||||||
|
expect(
|
||||||
|
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show and focus search when user clicks search icon again', () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// User clicks search icon to hide
|
||||||
|
const tabBar = screen.getByRole('tablist');
|
||||||
|
const searchIcon = tabBar.querySelector('.search-icon');
|
||||||
|
if (searchIcon) {
|
||||||
|
fireEvent.click(searchIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search is hidden
|
||||||
|
expect(
|
||||||
|
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// User clicks search icon again to show
|
||||||
|
if (searchIcon) {
|
||||||
|
fireEvent.click(searchIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search appears and receives focus
|
||||||
|
const searchInput = screen.getByPlaceholderText(
|
||||||
|
SEARCH_PLACEHOLDER,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(searchInput).toBeInTheDocument();
|
||||||
|
expect(searchInput).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* SpanDetailsDrawer - Status Message Truncation Tests
|
||||||
|
*
|
||||||
|
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||||
|
* Tests status message display and expandable popover functionality.
|
||||||
|
*/
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||||
|
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||||
|
import {
|
||||||
|
mockEmptyLogsResponse,
|
||||||
|
mockSpanWithLongStatusMessage,
|
||||||
|
mockSpanWithShortStatusMessage,
|
||||||
|
} from './mockData';
|
||||||
|
import {
|
||||||
|
clearAllMocks,
|
||||||
|
mockQueryBuilderContextValue,
|
||||||
|
mockSafeNavigate,
|
||||||
|
mockUpdateAllQueriesOperators,
|
||||||
|
setupSpanDetailsDrawerMocks,
|
||||||
|
} from './SpanDetailsDrawer.test-utils';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK SETUP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||||
|
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||||
|
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string; search: string } => ({
|
||||||
|
pathname: '/trace',
|
||||||
|
search: 'trace_id=test-trace-id',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||||
|
GetMetricQueryRange: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'container/SpanDetailsDrawer/Events/components/AttributeWithExpandablePopover',
|
||||||
|
() =>
|
||||||
|
function AttributeWithExpandablePopover({
|
||||||
|
attributeKey,
|
||||||
|
attributeValue,
|
||||||
|
onExpand,
|
||||||
|
}: {
|
||||||
|
attributeKey: string;
|
||||||
|
attributeValue: string;
|
||||||
|
onExpand: (title: string, content: string) => void;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="attribute-container" key={attributeKey}>
|
||||||
|
<div className="attribute-key">{attributeKey}</div>
|
||||||
|
<div className="wrapper">
|
||||||
|
<div className="attribute-value">{attributeValue}</div>
|
||||||
|
<div data-testid="popover-content">
|
||||||
|
<pre>{attributeValue}</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(): void => onExpand(attributeKey, attributeValue)}
|
||||||
|
>
|
||||||
|
Expand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||||
|
PreferenceContextProvider: ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SpanDetailsDrawer - Status Message Truncation User Flows', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
clearAllMocks();
|
||||||
|
setupSpanDetailsDrawerMocks();
|
||||||
|
|
||||||
|
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
|
||||||
|
Promise.resolve(mockEmptyLogsResponse),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display expandable popover with Expand button for long status message', () => {
|
||||||
|
render(
|
||||||
|
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||||
|
<SpanDetailsDrawer
|
||||||
|
isSpanDetailsDocked={false}
|
||||||
|
setIsSpanDetailsDocked={jest.fn()}
|
||||||
|
selectedSpan={mockSpanWithLongStatusMessage}
|
||||||
|
traceStartTime={1640995200000}
|
||||||
|
traceEndTime={1640995260000}
|
||||||
|
/>
|
||||||
|
</QueryBuilderContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// User sees status message label
|
||||||
|
expect(screen.getByText('status message')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User sees the status message value
|
||||||
|
const statusMessageElements = screen.getAllByText(
|
||||||
|
mockSpanWithLongStatusMessage.statusMessage,
|
||||||
|
);
|
||||||
|
expect(statusMessageElements.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// User sees Expand button in popover
|
||||||
|
const expandButton = screen.getByRole('button', { name: /expand/i });
|
||||||
|
expect(expandButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open modal with full status message when user clicks Expand button', async () => {
|
||||||
|
render(
|
||||||
|
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||||
|
<SpanDetailsDrawer
|
||||||
|
isSpanDetailsDocked={false}
|
||||||
|
setIsSpanDetailsDocked={jest.fn()}
|
||||||
|
selectedSpan={mockSpanWithLongStatusMessage}
|
||||||
|
traceStartTime={1640995200000}
|
||||||
|
traceEndTime={1640995260000}
|
||||||
|
/>
|
||||||
|
</QueryBuilderContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// User clicks the Expand button
|
||||||
|
const expandButton = screen.getByRole('button', { name: /expand/i });
|
||||||
|
await fireEvent.click(expandButton);
|
||||||
|
|
||||||
|
// User sees modal with the full status message content
|
||||||
|
await waitFor(() => {
|
||||||
|
const modalTitle = document.querySelector('.ant-modal-title');
|
||||||
|
expect(modalTitle).toBeInTheDocument();
|
||||||
|
expect(modalTitle?.textContent).toBe('status message');
|
||||||
|
const preElement = document.querySelector(
|
||||||
|
'.attribute-with-expandable-popover__full-view',
|
||||||
|
);
|
||||||
|
expect(preElement).toBeInTheDocument();
|
||||||
|
expect(preElement?.textContent).toBe(
|
||||||
|
mockSpanWithLongStatusMessage.statusMessage,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display short status message as simple text without popover', () => {
|
||||||
|
render(
|
||||||
|
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||||
|
<SpanDetailsDrawer
|
||||||
|
isSpanDetailsDocked={false}
|
||||||
|
setIsSpanDetailsDocked={jest.fn()}
|
||||||
|
selectedSpan={mockSpanWithShortStatusMessage}
|
||||||
|
traceStartTime={1640995200000}
|
||||||
|
traceEndTime={1640995260000}
|
||||||
|
/>
|
||||||
|
</QueryBuilderContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// User sees status message label and value
|
||||||
|
expect(screen.getByText('status message')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(mockSpanWithShortStatusMessage.statusMessage),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User hovers over the status message value
|
||||||
|
const statusMessageValue = screen.getByText(
|
||||||
|
mockSpanWithShortStatusMessage.statusMessage,
|
||||||
|
);
|
||||||
|
fireEvent.mouseEnter(statusMessageValue);
|
||||||
|
|
||||||
|
// No Expand button should appear
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /expand/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Shared test utilities for SpanDetailsDrawer tests.
|
||||||
|
* Extract common mocks, setup, and render helpers to avoid duplication across split test files.
|
||||||
|
*/
|
||||||
|
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||||
|
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||||
|
import { render } from 'tests/test-utils';
|
||||||
|
import { SuccessResponseV2 } from 'types/api';
|
||||||
|
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
|
||||||
|
|
||||||
|
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||||
|
import { mockEmptyLogsResponse, mockSpan } from './mockData';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPED MOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||||
|
export const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||||
|
export const mockSafeNavigate = jest.fn();
|
||||||
|
export const mockWindowOpen = jest.fn();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK SETUP (call in beforeAll or at module level)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function setupSpanDetailsDrawerMocks(): void {
|
||||||
|
// Mock window.open
|
||||||
|
Object.defineProperty(window, 'open', {
|
||||||
|
writable: true,
|
||||||
|
value: mockWindowOpen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK UPDATE OPERATORS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
aggregateOperator: 'noop',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
expression: 'A',
|
||||||
|
disabled: false,
|
||||||
|
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||||
|
groupBy: [],
|
||||||
|
limit: null,
|
||||||
|
having: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
},
|
||||||
|
queryType: 'builder',
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// QUERY BUILDER CONTEXT MOCK
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const mockQueryBuilderContextValue = {
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stagedQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
panelType: 'list',
|
||||||
|
redirectWithQuery: jest.fn(),
|
||||||
|
handleRunQuery: jest.fn(),
|
||||||
|
handleStageQuery: jest.fn(),
|
||||||
|
resetQuery: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RENDER HELPER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface RenderSpanDetailsDrawerProps {
|
||||||
|
selectedSpan?: typeof mockSpan;
|
||||||
|
traceStartTime?: number;
|
||||||
|
traceEndTime?: number;
|
||||||
|
isSpanDetailsDocked?: boolean;
|
||||||
|
setIsSpanDetailsDocked?: jest.Mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderSpanDetailsDrawer = (
|
||||||
|
props: RenderSpanDetailsDrawerProps = {},
|
||||||
|
): void => {
|
||||||
|
const {
|
||||||
|
selectedSpan = mockSpan,
|
||||||
|
traceStartTime = 1640995200000,
|
||||||
|
traceEndTime = 1640995260000,
|
||||||
|
isSpanDetailsDocked = false,
|
||||||
|
setIsSpanDetailsDocked = jest.fn(),
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||||
|
<SpanDetailsDrawer
|
||||||
|
isSpanDetailsDocked={isSpanDetailsDocked}
|
||||||
|
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
||||||
|
selectedSpan={selectedSpan}
|
||||||
|
traceStartTime={traceStartTime}
|
||||||
|
traceEndTime={traceEndTime}
|
||||||
|
/>
|
||||||
|
</QueryBuilderContext.Provider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const CI_SENSITIVE_LOGS_TEST_TIMEOUT = 15000;
|
||||||
|
export const P75_TEXT = 'p75';
|
||||||
|
export const SPAN_PERCENTILE_TEXT = 'Span Percentile';
|
||||||
|
export const SEARCH_PLACEHOLDER = 'Search for attribute...';
|
||||||
|
export const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER =
|
||||||
|
'Search resource attributes';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MOCK DATA FOR PERCENTILES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const mockSpanPercentileResponse = {
|
||||||
|
httpStatusCode: 200 as const,
|
||||||
|
data: {
|
||||||
|
percentiles: {
|
||||||
|
p50: 500000000,
|
||||||
|
p90: 1000000000,
|
||||||
|
p95: 1500000000,
|
||||||
|
p99: 2000000000,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
percentile: 75.5,
|
||||||
|
description: 'This span is in the 75th percentile',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockUserPreferenceResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
httpStatusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Success',
|
||||||
|
data: {
|
||||||
|
name: 'span_percentile_resource_attributes',
|
||||||
|
description: 'Resource attributes for span percentile calculation',
|
||||||
|
valueType: 'array',
|
||||||
|
defaultValue: [],
|
||||||
|
value: ['service.name', 'name', 'http.method'],
|
||||||
|
allowedValues: [],
|
||||||
|
allowedScopes: [],
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockSpanPercentileErrorResponse = {
|
||||||
|
httpStatusCode: 500,
|
||||||
|
data: null,
|
||||||
|
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMMON BEFOREEACH SETUP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ApiCallHistory {
|
||||||
|
span_logs: any;
|
||||||
|
before_logs: any;
|
||||||
|
after_logs: any;
|
||||||
|
trace_only_logs: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiCallHistory(): ApiCallHistory {
|
||||||
|
return {
|
||||||
|
span_logs: null,
|
||||||
|
before_logs: null,
|
||||||
|
after_logs: null,
|
||||||
|
trace_only_logs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupLogsApiMock(
|
||||||
|
apiCallHistory: ApiCallHistory,
|
||||||
|
mockSpanLogsResponse: any,
|
||||||
|
mockBeforeLogsResponse: any,
|
||||||
|
mockAfterLogsResponse: any,
|
||||||
|
): void {
|
||||||
|
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||||
|
const filterExpression = (query as any)?.query?.builder?.queryData?.[0]
|
||||||
|
?.filter?.expression;
|
||||||
|
|
||||||
|
if (!filterExpression) {
|
||||||
|
return Promise.resolve(mockEmptyLogsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterExpression.includes('span_id')) {
|
||||||
|
apiCallHistory.span_logs = query;
|
||||||
|
return Promise.resolve(mockSpanLogsResponse);
|
||||||
|
}
|
||||||
|
if (filterExpression.includes('id <')) {
|
||||||
|
apiCallHistory.before_logs = query;
|
||||||
|
return Promise.resolve(mockBeforeLogsResponse);
|
||||||
|
}
|
||||||
|
if (filterExpression.includes('id >')) {
|
||||||
|
apiCallHistory.after_logs = query;
|
||||||
|
return Promise.resolve(mockAfterLogsResponse);
|
||||||
|
}
|
||||||
|
if (filterExpression.includes('trace_id =')) {
|
||||||
|
apiCallHistory.trace_only_logs = query;
|
||||||
|
return Promise.resolve(mockAfterLogsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(mockEmptyLogsResponse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllMocks(): void {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockSafeNavigate.mockClear();
|
||||||
|
mockWindowOpen.mockClear();
|
||||||
|
mockUpdateAllQueriesOperators.mockClear();
|
||||||
|
mockGetSpanPercentiles.mockClear();
|
||||||
|
mockGetUserPreference.mockClear();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,3 +9,9 @@ export const RELATED_SIGNALS_VIEW_TYPES = {
|
|||||||
// METRICS: RelatedSignalsViews.METRICS,
|
// METRICS: RelatedSignalsViews.METRICS,
|
||||||
INFRA: RelatedSignalsViews.INFRA,
|
INFRA: RelatedSignalsViews.INFRA,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay in milliseconds before fetching span percentile data on initial load.
|
||||||
|
* Product requirement to avoid overwhelming API on rapid span selections.
|
||||||
|
*/
|
||||||
|
export const SPAN_PERCENTILE_INITIAL_DELAY_MS = 2000;
|
||||||
|
|||||||
@@ -348,6 +348,165 @@ const customRender = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TIERED RENDER FUNCTIONS
|
||||||
|
// =============================================================================
|
||||||
|
// Use the lightest wrapper that meets your test's needs:
|
||||||
|
// - renderMinimal: Router + QueryClient only (fastest, for pure components)
|
||||||
|
// - renderMedium: + AppContext + ErrorModal (for components using app state)
|
||||||
|
// - render: Full provider stack (for integration tests)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal provider wrapper - Router + QueryClient only.
|
||||||
|
* Use for pure components that don't need app state, redux, or other contexts.
|
||||||
|
* ~5x faster than full render.
|
||||||
|
*/
|
||||||
|
function MinimalProviders({
|
||||||
|
children,
|
||||||
|
initialRoute = '/',
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
initialRoute?: string;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<MemoryRouter initialEntries={[initialRoute]}>
|
||||||
|
<NuqsAdapter>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderMinimal = (
|
||||||
|
ui: ReactElement,
|
||||||
|
options?: Omit<RenderOptions, 'wrapper'> & { initialRoute?: string },
|
||||||
|
): RenderResult => {
|
||||||
|
const { initialRoute, ...renderOptions } = options || {};
|
||||||
|
return render(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<MinimalProviders initialRoute={initialRoute}>{children}</MinimalProviders>
|
||||||
|
),
|
||||||
|
...renderOptions,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medium provider wrapper - Router + QueryClient + AppContext + ErrorModal.
|
||||||
|
* Use for components that need app context but not Redux, Timezone, or QueryBuilder.
|
||||||
|
* ~2x faster than full render.
|
||||||
|
*/
|
||||||
|
function MediumProviders({
|
||||||
|
children,
|
||||||
|
initialRoute = '/',
|
||||||
|
role = 'ADMIN',
|
||||||
|
appContextOverrides = {},
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
initialRoute?: string;
|
||||||
|
role?: string;
|
||||||
|
appContextOverrides?: Partial<IAppContext>;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<MemoryRouter initialEntries={[initialRoute]}>
|
||||||
|
<NuqsAdapter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
|
||||||
|
<ErrorModalProvider>{children}</ErrorModalProvider>
|
||||||
|
</AppContext.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediumProviderProps {
|
||||||
|
initialRoute?: string;
|
||||||
|
role?: string;
|
||||||
|
appContextOverrides?: Partial<IAppContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderMedium = (
|
||||||
|
ui: ReactElement,
|
||||||
|
options?: Omit<RenderOptions, 'wrapper'>,
|
||||||
|
providerProps: MediumProviderProps = {},
|
||||||
|
): RenderResult => {
|
||||||
|
const {
|
||||||
|
initialRoute = '/',
|
||||||
|
role = 'ADMIN',
|
||||||
|
appContextOverrides = {},
|
||||||
|
} = providerProps;
|
||||||
|
return render(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<MediumProviders
|
||||||
|
initialRoute={initialRoute}
|
||||||
|
role={role}
|
||||||
|
appContextOverrides={appContextOverrides}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MediumProviders>
|
||||||
|
),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SIMPLIFIED CONTEXT MOCK HELPERS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified IAppContext mock with minimal defaults.
|
||||||
|
* Use this when you need a lightweight mock with mostly null/false values.
|
||||||
|
* Pass userOverrides to customize only the user object.
|
||||||
|
*
|
||||||
|
* This consolidates the duplicate getAppContextMockState functions that existed
|
||||||
|
* in various test utility files.
|
||||||
|
*/
|
||||||
|
export function getAppContextMockMinimal(
|
||||||
|
userOverrides?: Partial<IAppContext['user']>,
|
||||||
|
): IAppContext {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
accessJwt: 'some-token',
|
||||||
|
refreshJwt: 'some-refresh-token',
|
||||||
|
id: 'some-user-id',
|
||||||
|
email: 'user@signoz.io',
|
||||||
|
displayName: 'John Doe',
|
||||||
|
createdAt: 1732544623,
|
||||||
|
organization: 'Nightswatch',
|
||||||
|
orgId: 'does-not-matter-id',
|
||||||
|
role: 'ADMIN',
|
||||||
|
...userOverrides,
|
||||||
|
},
|
||||||
|
activeLicense: null,
|
||||||
|
trialInfo: null,
|
||||||
|
featureFlags: null,
|
||||||
|
orgPreferences: null,
|
||||||
|
userPreferences: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
org: null,
|
||||||
|
isFetchingUser: false,
|
||||||
|
isFetchingActiveLicense: false,
|
||||||
|
isFetchingFeatureFlags: false,
|
||||||
|
isFetchingOrgPreferences: false,
|
||||||
|
userFetchError: undefined,
|
||||||
|
activeLicenseFetchError: null,
|
||||||
|
featureFlagsFetchError: undefined,
|
||||||
|
orgPreferencesFetchError: undefined,
|
||||||
|
changelog: null,
|
||||||
|
showChangelogModal: false,
|
||||||
|
activeLicenseRefetch: jest.fn(),
|
||||||
|
updateUser: jest.fn(),
|
||||||
|
updateOrgPreferences: jest.fn(),
|
||||||
|
updateUserPreferenceInContext: jest.fn(),
|
||||||
|
updateOrg: jest.fn(),
|
||||||
|
updateChangelog: jest.fn(),
|
||||||
|
toggleChangelogModal: jest.fn(),
|
||||||
|
versionData: null,
|
||||||
|
hasEditPermission: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export * from '@testing-library/react';
|
export * from '@testing-library/react';
|
||||||
export { default as userEvent } from '@testing-library/user-event';
|
export { default as userEvent } from '@testing-library/user-event';
|
||||||
export { customRender as render };
|
export { customRender as render };
|
||||||
|
|||||||
Reference in New Issue
Block a user