Merge branch 'main' into enhancement/cmd-click-across-routes

This commit is contained in:
manika-signoz 2025-09-22 17:55:46 +05:30 committed by GitHub
commit 5cf4e814a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
105 changed files with 5061 additions and 917 deletions

View File

@ -0,0 +1,44 @@
module base
type organisation
relations
define read: [user, role#assignee]
define update: [user, role#assignee]
type user
relations
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type anonymous
type role
relations
define assignee: [user]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resources
relations
define create: [user, role#assignee]
define list: [user, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resource
relations
define read: [user, anonymous, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
define block: [user, role#assignee]
type telemetry
relations
define read: [user, anonymous, role#assignee]

View File

@ -0,0 +1,29 @@
package openfgaschema
import (
"context"
_ "embed"
"github.com/SigNoz/signoz/pkg/authz"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
)
var (
//go:embed base.fga
baseDSL string
)
type schema struct{}
func NewSchema() authz.Schema {
return &schema{}
}
func (schema *schema) Get(ctx context.Context) []openfgapkgtransformer.ModuleFile {
return []openfgapkgtransformer.ModuleFile{
{
Name: "base.fga",
Contents: baseDSL,
},
}
}

132
ee/http/middleware/authz.go Normal file
View File

@ -0,0 +1,132 @@
package middleware
import (
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
const (
authzDeniedMessage string = "::AUTHZ-DENIED::"
)
type AuthZ struct {
logger *slog.Logger
authzService authz.AuthZ
}
func NewAuthZ(logger *slog.Logger) *AuthZ {
if logger == nil {
panic("cannot build authz middleware, logger is empty")
}
return &AuthZ{logger: logger}
}
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
next(rw, req)
})
}
// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis.
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
selector, parentSelectors, err := cb(req)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...)
if err != nil {
render.Error(rw, err)
return
}
next(rw, req)
})
}

View File

@ -8,6 +8,8 @@ import (
"net/http" "net/http"
_ "net/http/pprof" // http profiler _ "net/http/pprof" // http profiler
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
"github.com/SigNoz/signoz/ee/query-service/app/api" "github.com/SigNoz/signoz/ee/query-service/app/api"
@ -334,6 +336,8 @@ func makeRulesManager(
querier querier.Querier, querier querier.Querier,
logger *slog.Logger, logger *slog.Logger,
) (*baserules.Manager, error) { ) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts // create manager opts
managerOpts := &baserules.ManagerOptions{ managerOpts := &baserules.ManagerOptions{
TelemetryStore: telemetryStore, TelemetryStore: telemetryStore,
@ -348,8 +352,10 @@ func makeRulesManager(
PrepareTaskFunc: rules.PrepareTaskFunc, PrepareTaskFunc: rules.PrepareTaskFunc,
PrepareTestRuleFunc: rules.TestNotification, PrepareTestRuleFunc: rules.TestNotification,
Alertmanager: alertmanager, Alertmanager: alertmanager,
SQLStore: sqlstore,
OrgGetter: orgGetter, OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
} }
// create Manager // create Manager

484
frontend/.cursorrules Normal file
View File

@ -0,0 +1,484 @@
# Persona
You are an expert developer with deep knowledge of Jest, React Testing Library, MSW, and TypeScript, tasked with creating unit tests for this repository.
# Auto-detect TypeScript Usage
Check for TypeScript in the project through tsconfig.json or package.json dependencies.
Adjust syntax based on this detection.
# TypeScript Type Safety for Jest Tests
**CRITICAL**: All Jest tests MUST be fully type-safe with proper TypeScript types.
**Type Safety Requirements:**
- Use proper TypeScript interfaces for all mock data
- Type all Jest mock functions with `jest.MockedFunction<T>`
- Use generic types for React components and hooks
- Define proper return types for mock functions
- Use `as const` for literal types when needed
- Avoid `any` type use proper typing instead
# Unit Testing Focus
Focus on critical functionality (business logic, utility functions, component behavior)
Mock dependencies (API calls, external modules) before imports
Test multiple data scenarios (valid inputs, invalid inputs, edge cases)
Write maintainable tests with descriptive names grouped in describe blocks
# Global vs Local Mocks
**Use Global Mocks for:**
- High-frequency dependencies (20+ test files)
- Core infrastructure (react-router-dom, react-query, antd)
- Standard implementations across the app
- Browser APIs (ResizeObserver, matchMedia, localStorage)
- Utility libraries (date-fns, lodash)
**Use Local Mocks for:**
- Business logic dependencies (5-15 test files)
- Test-specific behavior (different data per test)
- API endpoints with specific responses
- Domain-specific components
- Error scenarios and edge cases
**Global Mock Files Available (from jest.config.ts):**
- `uplot` → `__mocks__/uplotMock.ts`
# Repo-specific Testing Conventions
## Imports
Always import from our harness:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
```
For API mocks:
```ts
import { server, rest } from 'mocks-server/server';
```
Do not import directly from `@testing-library/react`.
## Router
Use the router built into render:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on them.
## Hook Mocks
Pattern:
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */ as any);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## MSW
Global MSW server runs automatically.
Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) => res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large responses in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level/programmatic events not covered by `userEvent` (e.g., scroll, resize, setting `element.scrollTop` for virtualization). Wrap in `act(...)` if needed.
- Always await interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
```ts
// Example: virtualized list scroll (no userEvent helper)
const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement;
scroller.scrollTop = targetScrollTop;
act(() => { fireEvent.scroll(scroller); });
```
## Timers
❌ No global fake timers.
✅ Per-test only, for debounce/throttle:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries (`getByRole`, `findByRole`, `getByLabelText`).
Fallback: visible text.
Last resort: `data-testid`.
# Example Test (using only configured global mocks)
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# Best Practices
- **Critical Functionality**: Prioritize testing business logic and utilities
- **Dependency Mocking**: Global mocks for infra, local mocks for business logic
- **Data Scenarios**: Always test valid, invalid, and edge cases
- **Descriptive Names**: Make test intent clear
- **Organization**: Group related tests in describe
- **Consistency**: Match repo conventions
- **Edge Cases**: Test null, undefined, unexpected values
- **Limit Scope**: 35 focused tests per file
- **Use Helpers**: `rqSuccess`, `makeUser`, etc.
- **No Any**: Enforce type safety
# Example Test
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# TypeScript Type Safety Examples
## Proper Mock Typing
```ts
// ✅ GOOD - Properly typed mocks
interface User {
id: number;
name: string;
email: string;
}
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// Type the mock functions
const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise<ApiResponse<User>>>;
const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise<ApiResponse<User>>>;
// Mock implementation with proper typing
mockFetchUser.mockResolvedValue({
data: { id: 1, name: 'John Doe', email: 'john@example.com' },
status: 200,
message: 'Success'
});
// ❌ BAD - Using any type
const mockFetchUser = jest.fn() as any; // Don't do this
```
## React Component Testing with Types
```ts
// ✅ GOOD - Properly typed component testing
interface ComponentProps {
title: string;
data: User[];
onUserSelect: (user: User) => void;
isLoading?: boolean;
}
const TestComponent: React.FC<ComponentProps> = ({ title, data, onUserSelect, isLoading = false }) => {
// Component implementation
};
describe('TestComponent', () => {
it('should render with proper props', () => {
// Arrange - Type the props properly
const mockProps: ComponentProps = {
title: 'Test Title',
data: [{ id: 1, name: 'John', email: 'john@example.com' }],
onUserSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
isLoading: false
};
// Act
render(<TestComponent {...mockProps} />);
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
});
```
## Hook Testing with Types
```ts
// ✅ GOOD - Properly typed hook testing
interface UseUserDataReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
const useUserData = (id: number): UseUserDataReturn => {
// Hook implementation
};
describe('useUserData', () => {
it('should return user data with proper typing', () => {
// Arrange
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
mockFetchUser.mockResolvedValue({
data: mockUser,
status: 200,
message: 'Success'
});
// Act
const { result } = renderHook(() => useUserData(1));
// Assert
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
```
## Global Mock Type Safety
```ts
// ✅ GOOD - Type-safe global mocks
// In __mocks__/routerMock.ts
export const mockUseLocation = (overrides: Partial<Location> = {}): Location => ({
pathname: '/traces',
search: '',
hash: '',
state: null,
key: 'test-key',
...overrides,
});
// In test files
const location = useLocation(); // Properly typed from global mock
expect(location.pathname).toBe('/traces');
```
# TypeScript Configuration for Jest
## Required Jest Configuration
```json
// jest.config.ts
{
"preset": "ts-jest/presets/js-with-ts-esm",
"globals": {
"ts-jest": {
"useESM": true,
"isolatedModules": true,
"tsconfig": "<rootDir>/tsconfig.jest.json"
}
},
"extensionsToTreatAsEsm": [".ts", ".tsx"],
"moduleFileExtensions": ["ts", "tsx", "js", "json"]
}
```
## TypeScript Jest Configuration
```json
// tsconfig.jest.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node"
},
"include": [
"src/**/*",
"**/*.test.ts",
"**/*.test.tsx",
"__mocks__/**/*"
]
}
```
## Common Type Safety Patterns
### Mock Function Typing
```ts
// ✅ GOOD - Proper mock function typing
const mockApiCall = jest.fn() as jest.MockedFunction<typeof apiCall>;
const mockEventHandler = jest.fn() as jest.MockedFunction<(event: Event) => void>;
// ❌ BAD - Using any
const mockApiCall = jest.fn() as any;
```
### Generic Mock Typing
```ts
// ✅ GOOD - Generic mock typing
interface MockApiResponse<T> {
data: T;
status: number;
}
const mockFetchData = jest.fn() as jest.MockedFunction<
<T>(endpoint: string) => Promise<MockApiResponse<T>>
>;
// Usage
mockFetchData<User>('/users').mockResolvedValue({
data: { id: 1, name: 'John' },
status: 200
});
```
### React Testing Library with Types
```ts
// ✅ GOOD - Typed testing utilities
import { render, screen, RenderResult } from '@testing-library/react';
import { ComponentProps } from 'react';
type TestComponentProps = ComponentProps<typeof TestComponent>;
const renderTestComponent = (props: Partial<TestComponentProps> = {}): RenderResult => {
const defaultProps: TestComponentProps = {
title: 'Test',
data: [],
onSelect: jest.fn(),
...props
};
return render(<TestComponent {...defaultProps} />);
};
```
### Error Handling with Types
```ts
// ✅ GOOD - Typed error handling
interface ApiError {
message: string;
code: number;
details?: Record<string, unknown>;
}
const mockApiError: ApiError = {
message: 'API Error',
code: 500,
details: { endpoint: '/users' }
};
mockFetchUser.mockRejectedValue(new Error(JSON.stringify(mockApiError)));
```
## Type Safety Checklist
- [ ] All mock functions use `jest.MockedFunction<T>`
- [ ] All mock data has proper interfaces
- [ ] No `any` types in test files
- [ ] Generic types are used where appropriate
- [ ] Error types are properly defined
- [ ] Component props are typed
- [ ] Hook return types are defined
- [ ] API response types are defined
- [ ] Global mocks are type-safe
- [ ] Test utilities are properly typed
# Mock Decision Tree
```
Is it used in 20+ test files?
├─ YES → Use Global Mock
│ ├─ react-router-dom
│ ├─ react-query
│ ├─ antd components
│ └─ browser APIs
└─ NO → Is it business logic?
├─ YES → Use Local Mock
│ ├─ API endpoints
│ ├─ Custom hooks
│ └─ Domain components
└─ NO → Is it test-specific?
├─ YES → Use Local Mock
│ ├─ Error scenarios
│ ├─ Loading states
│ └─ Specific data
└─ NO → Consider Global Mock
└─ If it becomes frequently used
```
# Common Anti-Patterns to Avoid
❌ **Don't mock global dependencies locally:**
```js
// BAD - This is already globally mocked
jest.mock('react-router-dom', () => ({ ... }));
```
❌ **Don't create global mocks for test-specific data:**
```js
// BAD - This should be local
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => specificTestData)
}));
```
✅ **Do use global mocks for infrastructure:**
```js
// GOOD - Use global mock
import { useLocation } from 'react-router-dom';
```
✅ **Do create local mocks for business logic:**
```js
// GOOD - Local mock for specific test needs
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData)
}));
```

View File

@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// Mock for uplot library used in tests
export interface MockUPlotInstance {
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
redraw: jest.Mock;
setSeries: jest.Mock;
}
export interface MockUPlotPaths {
spline: jest.Mock;
bars: jest.Mock;
}
// Create mock instance methods
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
// Create mock paths
const mockPaths: MockUPlotPaths = {
spline: jest.fn(),
bars: jest.fn(),
};
// Mock static methods
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
_options: unknown,
_data: unknown,
_target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance();
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Export the constructor as default
export default MockUPlot;

View File

@ -0,0 +1,29 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
interface UseSafeNavigateReturn {
safeNavigate: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>;
}
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
safeNavigate: jest.fn(
(to: SafeNavigateToType, options?: SafeNavigateOptions) => {
console.log(`Mock safeNavigate called with:`, to, options);
},
) as jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>,
});

View File

@ -1,5 +1,7 @@
import type { Config } from '@jest/types'; import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = { const config: Config.InitialOptions = {
clearMocks: true, clearMocks: true,
coverageDirectory: 'coverage', coverageDirectory: 'coverage',
@ -10,6 +12,10 @@ const config: Config.InitialOptions = {
moduleNameMapper: { moduleNameMapper: {
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts', '\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
'\\.md$': '<rootDir>/__mocks__/cssMock.ts', '\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
}, },
globals: { globals: {
extensionsToTreatAsEsm: ['.ts'], extensionsToTreatAsEsm: ['.ts'],

View File

@ -139,6 +139,7 @@
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0", "rehype-raw": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2", "stream": "^0.0.2",
"style-loader": "1.3.0", "style-loader": "1.3.0",
"styled-components": "^5.3.11", "styled-components": "^5.3.11",

View File

@ -119,7 +119,9 @@ const filterAndSortTimezones = (
return createTimezoneEntry(normalizedTz, offset); return createTimezoneEntry(normalizedTz, offset);
}); });
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => { export const generateTimezoneData = (
includeEtcTimezones = false,
): Timezone[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTimezones = (Intl as any).supportedValuesOf('timeZone'); const allTimezones = (Intl as any).supportedValuesOf('timeZone');
const timezones: Timezone[] = []; const timezones: Timezone[] = [];

View File

@ -19,20 +19,6 @@ beforeAll(() => {
}); });
}); });
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-dnd', () => ({ jest.mock('react-dnd', () => ({
useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),

View File

@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error'; import APIError from 'types/api/error';
import ErrorModal from './ErrorModal'; import ErrorModal from './ErrorModal';
@ -56,9 +56,8 @@ describe('ErrorModal Component', () => {
// Click the close button // Click the close button
const closeButton = screen.getByTestId('close-button'); const closeButton = screen.getByTestId('close-button');
act(() => { const user = userEvent.setup({ pointerEventsCheck: 0 });
fireEvent.click(closeButton); await user.click(closeButton);
});
// Check if onClose was called // Check if onClose was called
expect(onCloseMock).toHaveBeenCalledTimes(1); expect(onCloseMock).toHaveBeenCalledTimes(1);
@ -149,9 +148,8 @@ it('should open the modal when the trigger component is clicked', async () => {
// Click the trigger component // Click the trigger component
const triggerButton = screen.getByText('Open Error Modal'); const triggerButton = screen.getByText('Open Error Modal');
act(() => { const user = userEvent.setup({ pointerEventsCheck: 0 });
fireEvent.click(triggerButton); await user.click(triggerButton);
});
// Check if the modal is displayed // Check if the modal is displayed
expect(screen.getByText('An error occurred')).toBeInTheDocument(); expect(screen.getByText('An error occurred')).toBeInTheDocument();
@ -170,18 +168,15 @@ it('should close the modal when the onCancel event is triggered', async () => {
// Click the trigger component // Click the trigger component
const triggerButton = screen.getByText('error'); const triggerButton = screen.getByText('error');
act(() => { const user = userEvent.setup({ pointerEventsCheck: 0 });
fireEvent.click(triggerButton); await user.click(triggerButton);
});
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('An error occurred')).toBeInTheDocument(); expect(screen.getByText('An error occurred')).toBeInTheDocument();
}); });
// Trigger the onCancel event // Trigger the onCancel event
act(() => { await user.click(screen.getByTestId('close-button'));
fireEvent.click(screen.getByTestId('close-button'));
});
// Check if the modal is closed // Check if the modal is closed
expect(onCloseMock).toHaveBeenCalledTimes(1); expect(onCloseMock).toHaveBeenCalledTimes(1);

View File

@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
filterConfig, filterConfig,
isDynamicFilters, isDynamicFilters,
customFilters, customFilters,
setIsStale, refetchCustomFilters,
isCustomFiltersLoading, isCustomFiltersLoading,
} = useFilterConfig({ signal, config }); } = useFilterConfig({ signal, config });
@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
signal={signal} signal={signal}
setIsSettingsOpen={setIsSettingsOpen} setIsSettingsOpen={setIsSettingsOpen}
customFilters={customFilters} customFilters={customFilters}
setIsStale={setIsStale} refetchCustomFilters={refetchCustomFilters}
/> />
)} )}
</div> </div>

View File

@ -14,12 +14,12 @@ function QuickFiltersSettings({
signal, signal,
setIsSettingsOpen, setIsSettingsOpen,
customFilters, customFilters,
setIsStale, refetchCustomFilters,
}: { }: {
signal: SignalType | undefined; signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void; setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[]; customFilters: FilterType[];
setIsStale: (isStale: boolean) => void; refetchCustomFilters: () => void;
}): JSX.Element { }): JSX.Element {
const { const {
handleSettingsClose, handleSettingsClose,
@ -34,7 +34,7 @@ function QuickFiltersSettings({
} = useQuickFilterSettings({ } = useQuickFilterSettings({
setIsSettingsOpen, setIsSettingsOpen,
customFilters, customFilters,
setIsStale, refetchCustomFilters,
signal, signal,
}); });

View File

@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
interface UseQuickFilterSettingsProps { interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void; setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[]; customFilters: FilterType[];
setIsStale: (isStale: boolean) => void; refetchCustomFilters: () => void;
signal?: SignalType; signal?: SignalType;
} }
@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn {
const useQuickFilterSettings = ({ const useQuickFilterSettings = ({
customFilters, customFilters,
setIsSettingsOpen, setIsSettingsOpen,
setIsStale, refetchCustomFilters,
signal, signal,
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => { }: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>(''); const [inputValue, setInputValue] = useState<string>('');
@ -46,7 +46,7 @@ const useQuickFilterSettings = ({
} = useMutation(updateCustomFiltersAPI, { } = useMutation(updateCustomFiltersAPI, {
onSuccess: () => { onSuccess: () => {
setIsSettingsOpen(false); setIsSettingsOpen(false);
setIsStale(true); refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', { logEvent('Quick Filters Settings: changes saved', {
addedFilters, addedFilters,
}); });

View File

@ -1,12 +1,8 @@
import getCustomFilters from 'api/quickFilters/getCustomFilters'; import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api'; import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import {
Filter as FilterType,
PayloadProps,
} from 'types/api/quickFilters/getCustomFilters';
import { IQuickFiltersConfig, SignalType } from '../types'; import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils'; import { getFilterConfig } from '../utils';
@ -18,37 +14,34 @@ interface UseFilterConfigProps {
interface UseFilterConfigReturn { interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[]; filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[]; customFilters: FilterType[];
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
isCustomFiltersLoading: boolean; isCustomFiltersLoading: boolean;
isDynamicFilters: boolean; isDynamicFilters: boolean;
setIsStale: React.Dispatch<React.SetStateAction<boolean>>; refetchCustomFilters: () => void;
} }
const useFilterConfig = ({ const useFilterConfig = ({
signal, signal,
config, config,
}: UseFilterConfigProps): UseFilterConfigReturn => { }: UseFilterConfigProps): UseFilterConfigReturn => {
const [customFilters, setCustomFilters] = useState<FilterType[]>([]); const {
const [isStale, setIsStale] = useState(true); isFetching: isCustomFiltersLoading,
data: customFilters = [],
refetch,
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
);
const isDynamicFilters = useMemo(() => customFilters.length > 0, [ const isDynamicFilters = useMemo(() => customFilters.length > 0, [
customFilters, customFilters,
]); ]);
const { isFetching: isCustomFiltersLoading } = useQuery<
SuccessResponse<PayloadProps> | ErrorResponse,
Error
>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
() => getCustomFilters({ signal: signal || '' }),
{
onSuccess: (data) => {
if ('payload' in data && data.payload?.filters) {
setCustomFilters(data.payload.filters || ([] as FilterType[]));
}
setIsStale(false);
},
enabled: !!signal && isStale,
},
);
const filterConfig = useMemo( const filterConfig = useMemo(
() => getFilterConfig(signal, customFilters, config), () => getFilterConfig(signal, customFilters, config),
[config, customFilters, signal], [config, customFilters, signal],
@ -57,10 +50,9 @@ const useFilterConfig = ({
return { return {
filterConfig, filterConfig,
customFilters, customFilters,
setCustomFilters,
isCustomFiltersLoading, isCustomFiltersLoading,
isDynamicFilters, isDynamicFilters,
setIsStale, refetchCustomFilters: refetch,
}; };
}; };

View File

@ -1,15 +1,6 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env'; import { ENVIRONMENT } from 'constants/env';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { import {
otherFiltersResponse, otherFiltersResponse,
@ -18,8 +9,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters'; } from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server'; import { server } from 'mocks-server/server';
import { rest } from 'msw'; import { rest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import QuickFilters from '../QuickFilters'; import QuickFilters from '../QuickFilters';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types'; import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
@ -29,21 +19,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(), useQueryBuilder: jest.fn(),
})); }));
// eslint-disable-next-line sonarjs/no-duplicate-string
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
}));
const userRole = USER_ROLES.ADMIN;
// mock useAppContext
jest.mock('providers/App/App', () => ({
useAppContext: jest.fn(() => ({ user: { role: userRole } })),
}));
const handleFilterVisibilityChange = jest.fn(); const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn(); const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn(); const putHandler = jest.fn();
@ -78,11 +53,10 @@ const setupServer = (): void => {
putHandler(await req.json()); putHandler(await req.json());
return res(ctx.status(200), ctx.json({})); return res(ctx.status(200), ctx.json({}));
}), }),
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)), res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
), ),
rest.get(fieldsValuesURL, (req, res, ctx) => rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)), res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
), ),
); );
@ -96,14 +70,12 @@ function TestQuickFilters({
config?: IQuickFiltersConfig[]; config?: IQuickFiltersConfig[];
}): JSX.Element { }): JSX.Element {
return ( return (
<MockQueryClientProvider>
<QuickFilters <QuickFilters
source={QuickFiltersSource.EXCEPTIONS} source={QuickFiltersSource.EXCEPTIONS}
config={config} config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange} handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal} signal={signal}
/> />
</MockQueryClientProvider>
); );
} }
@ -118,11 +90,11 @@ beforeAll(() => {
afterEach(() => { afterEach(() => {
server.resetHandlers(); server.resetHandlers();
jest.clearAllMocks();
}); });
afterAll(() => { afterAll(() => {
server.close(); server.close();
cleanup();
}); });
beforeEach(() => { beforeEach(() => {
@ -151,9 +123,13 @@ describe('Quick Filters', () => {
}); });
it('should add filter data to query when checkbox is clicked', async () => { it('should add filter data to query when checkbox is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters />); render(<TestQuickFilters />);
const checkbox = screen.getByText('mq-kafka');
fireEvent.click(checkbox); // Prefer role if possible; if label text isnt wired to input, clicking the label text is OK
const target = await screen.findByText('mq-kafka');
await user.click(target);
await waitFor(() => { await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith( expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
@ -182,16 +158,20 @@ describe('Quick Filters', () => {
describe('Quick Filters with custom filters', () => { describe('Quick Filters with custom filters', () => {
it('loads the custom filters correctly', async () => { it('loads the custom filters correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />); render(<TestQuickFilters signal={SIGNAL} />);
expect(screen.getByText('Filters for')).toBeInTheDocument(); expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument(); expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
await screen.findByText(FILTER_SERVICE_NAME); await screen.findByText(FILTER_SERVICE_NAME);
const allByText = await screen.findAllByText('otel-demo'); const allByText = await screen.findAllByText('otel-demo');
// since 2 filter collapse are open, there are 2 filter items visible
expect(allByText).toHaveLength(2); expect(allByText).toHaveLength(2);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon); const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument(); expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
@ -207,16 +187,19 @@ describe('Quick Filters with custom filters', () => {
}); });
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => { it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />); render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME); await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon); const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME); const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
const addButton = otherFilterItem.parentElement?.querySelector('button'); const addButton = otherFilterItem.parentElement?.querySelector('button');
expect(addButton).not.toBeNull(); expect(addButton).not.toBeNull();
fireEvent.click(addButton as HTMLButtonElement); await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => { await waitFor(() => {
@ -225,17 +208,21 @@ describe('Quick Filters with custom filters', () => {
}); });
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => { it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />); render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME); await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon); const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION); const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button'); const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull(); expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
await user.click(removeBtn as HTMLButtonElement);
await waitFor(() => { await waitFor(() => {
expect(addedSection).not.toContainElement( expect(addedSection).not.toContainElement(
@ -250,17 +237,20 @@ describe('Quick Filters with custom filters', () => {
}); });
it('restores original filter state on Discard', async () => { it('restores original filter state on Discard', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />); render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME); await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon); const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION); const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button'); const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull(); expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement); await user.click(removeBtn as HTMLButtonElement);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!; const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
await waitFor(() => { await waitFor(() => {
@ -272,7 +262,11 @@ describe('Quick Filters with custom filters', () => {
); );
}); });
fireEvent.click(screen.getByText(DISCARD_TEXT)); const discardBtn = screen
.getByText(DISCARD_TEXT)
.closest('button') as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
await user.click(discardBtn);
await waitFor(() => { await waitFor(() => {
expect(addedSection).toContainElement( expect(addedSection).toContainElement(
@ -285,18 +279,25 @@ describe('Quick Filters with custom filters', () => {
}); });
it('saves the updated filters by calling PUT with correct payload', async () => { it('saves the updated filters by calling PUT with correct payload', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />); render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME); await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon); const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const target = await screen.findByText(FILTER_OS_DESCRIPTION); const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button'); const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull(); expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement); await user.click(removeBtn as HTMLButtonElement);
fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT)); const saveBtn = screen
.getByText(SAVE_CHANGES_TEXT)
.closest('button') as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
await user.click(saveBtn);
await waitFor(() => { await waitFor(() => {
expect(putHandler).toHaveBeenCalled(); expect(putHandler).toHaveBeenCalled();
@ -311,31 +312,36 @@ describe('Quick Filters with custom filters', () => {
expect(requestBody.signal).toBe(SIGNAL); expect(requestBody.signal).toBe(SIGNAL);
}); });
// render duration filter
it('should render duration slider for duration_nono filter', async () => { it('should render duration slider for duration_nono filter', async () => {
// Set up fake timers **before rendering** // Use fake timers only in this test (for debounce), and wire them to userEvent
jest.useFakeTimers(); jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
pointerEventsCheck: 0,
});
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />); const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME); await screen.findByText(FILTER_SERVICE_NAME);
expect(screen.getByText('Duration')).toBeInTheDocument(); expect(screen.getByText('Duration')).toBeInTheDocument();
// click to open the duration filter // Open the duration section (use role if its a button/collapse)
fireEvent.click(screen.getByText('Duration')); await user.click(screen.getByText('Duration'));
const minDuration = getByTestId('min-input') as HTMLInputElement; const minDuration = getByTestId('min-input') as HTMLInputElement;
const maxDuration = getByTestId('max-input') as HTMLInputElement; const maxDuration = getByTestId('max-input') as HTMLInputElement;
expect(minDuration).toHaveValue(null); expect(minDuration).toHaveValue(null);
expect(minDuration).toHaveProperty('placeholder', '0'); expect(minDuration).toHaveProperty('placeholder', '0');
expect(maxDuration).toHaveValue(null); expect(maxDuration).toHaveValue(null);
expect(maxDuration).toHaveProperty('placeholder', '100000000'); expect(maxDuration).toHaveProperty('placeholder', '100000000');
await act(async () => { // Type values and advance debounce
// set values await user.clear(minDuration);
fireEvent.change(minDuration, { target: { value: '10000' } }); await user.type(minDuration, '10000');
fireEvent.change(maxDuration, { target: { value: '20000' } }); await user.clear(maxDuration);
await user.type(maxDuration, '20000');
jest.advanceTimersByTime(2000); jest.advanceTimersByTime(2000);
});
await waitFor(() => { await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith( expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -363,6 +369,144 @@ describe('Quick Filters with custom filters', () => {
); );
}); });
jest.useRealTimers(); // Clean up jest.useRealTimers();
});
});
describe('Quick Filters refetch behavior', () => {
it('fetches custom filters on every mount when signal is provided', async () => {
let getCalls = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
unmount();
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
expect(getCalls).toBe(2);
});
it('does not fetch custom filters when signal is undefined', async () => {
let getCalls = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
render(<TestQuickFilters signal={undefined} />);
await waitFor(() => expect(getCalls).toBe(0));
});
it('refetches custom filters after saving settings', async () => {
let getCalls = 0;
putHandler.mockClear();
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
rest.put(saveQuickFiltersURL, async (req, res, ctx) => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector(
'button',
) as HTMLButtonElement;
await user.click(removeBtn);
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => expect(putHandler).toHaveBeenCalled());
await waitFor(() => expect(getCalls).toBeGreaterThanOrEqual(2));
});
it('renders updated filters after refetch post-save', async () => {
const updatedResponse = {
...quickFiltersListResponse,
data: {
...quickFiltersListResponse.data,
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
} as const,
],
},
};
let getCount = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCount += 1;
return getCount >= 2
? res(ctx.status(200), ctx.json(updatedResponse))
: res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
rest.put(saveQuickFiltersURL, async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({})),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
// Make a minimal change so Save button appears
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector(
'button',
) as HTMLButtonElement;
await user.click(removeBtn);
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => {
expect(screen.getByText('New Custom Filter')).toBeInTheDocument();
});
});
it('shows empty state when GET fails', async () => {
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({})),
),
);
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
expect(await screen.findByText('No filters found')).toBeInTheDocument();
}); });
}); });

View File

@ -22,6 +22,8 @@ jest.mock('react-router-dom', () => ({
describe('Alert Channels Settings List page', () => { describe('Alert Channels Settings List page', () => {
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
render(<AlertChannels />); render(<AlertChannels />);
await waitFor(() => await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(), expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
@ -29,6 +31,7 @@ describe('Alert Channels Settings List page', () => {
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
jest.useRealTimers();
}); });
describe('Should display the Alert Channels page properly', () => { describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => { it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => {

View File

@ -28,6 +28,7 @@ jest.mock('react-router-dom', () => ({
describe('Alert Channels Settings List page (Normal User)', () => { describe('Alert Channels Settings List page (Normal User)', () => {
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers();
render(<AlertChannels />); render(<AlertChannels />);
await waitFor(() => await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(), expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
@ -35,6 +36,7 @@ describe('Alert Channels Settings List page (Normal User)', () => {
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
jest.useRealTimers();
}); });
describe('Should display the Alert Channels page properly', () => { describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => { it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => {

View File

@ -9,22 +9,6 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer'; import BillingContainer from './BillingContainer';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
window.ResizeObserver = window.ResizeObserver =
window.ResizeObserver || window.ResizeObserver ||
jest.fn().mockImplementation(() => ({ jest.fn().mockImplementation(() => ({
@ -67,45 +51,63 @@ describe('BillingContainer', () => {
expect(currentBill).toBeInTheDocument(); expect(currentBill).toBeInTheDocument();
}); });
describe('Trial scenarios', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
});
afterEach(() => {
jest.useRealTimers();
});
test('OnTrail', async () => { test('OnTrail', async () => {
await act(async () => { // Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining"
render(<BillingContainer />, undefined, undefined, {
trialInfo: licensesSuccessResponse.data,
});
});
const freeTrailText = await screen.findByText('Free Trial'); render(
expect(freeTrailText).toBeInTheDocument(); <BillingContainer />,
{},
const currentBill = await screen.findByText('billing'); { appContextOverrides: { trialInfo: licensesSuccessResponse.data } },
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
); );
expect(onTrail).toBeInTheDocument();
const numberOfDayRemaining = await screen.findByText(/1 days_remaining/i); // If the component schedules any setTimeout on mount, flush them:
expect(numberOfDayRemaining).toBeInTheDocument(); jest.runOnlyPendingTimers();
const upgradeButton = await screen.findAllByRole('button', {
expect(await screen.findByText('Free Trial')).toBeInTheDocument();
expect(await screen.findByText('billing')).toBeInTheDocument();
expect(await screen.findByText(/\$0/i)).toBeInTheDocument();
expect(
await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
),
).toBeInTheDocument();
expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument();
const upgradeButtons = await screen.findAllByRole('button', {
name: /upgrade_plan/i, name: /upgrade_plan/i,
}); });
expect(upgradeButton[1]).toBeInTheDocument(); expect(upgradeButtons).toHaveLength(2);
expect(upgradeButton.length).toBe(2); expect(upgradeButtons[1]).toBeInTheDocument();
const checkPaidPlan = await screen.findByText(/checkout_plans/i);
expect(checkPaidPlan).toBeInTheDocument();
const link = await screen.findByRole('link', { name: /here/i }); expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument();
expect(link).toBeInTheDocument(); expect(
await screen.findByRole('link', { name: /here/i }),
).toBeInTheDocument();
}); });
test('OnTrail but trialConvertedToSubscription', async () => { test('OnTrail but trialConvertedToSubscription', async () => {
await act(async () => { await act(async () => {
render(<BillingContainer />, undefined, undefined, { render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data, trialInfo: trialConvertedToSubscriptionResponse.data,
}); },
},
);
}); });
const currentBill = await screen.findByText('billing'); const currentBill = await screen.findByText('billing');
@ -134,11 +136,18 @@ describe('BillingContainer', () => {
); );
expect(dayRemainingInBillingPeriod).toBeInTheDocument(); expect(dayRemainingInBillingPeriod).toBeInTheDocument();
}); });
});
test('Not on ontrail', async () => { test('Not on ontrail', async () => {
const { findByText } = render(<BillingContainer />, undefined, undefined, { const { findByText } = render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: notOfTrailResponse.data, trialInfo: notOfTrailResponse.data,
}); },
},
);
const billingPeriodText = `Your current billing period is from ${getFormattedDate( const billingPeriodText = `Your current billing period is from ${getFormattedDate(
billingSuccessResponse.data.billingPeriodStart, billingSuccessResponse.data.billingPeriodStart,

View File

@ -1,7 +1,6 @@
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions'; import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert'; import CreateAlertPage from 'pages/CreateAlert';
import { MemoryRouter, Route } from 'react-router-dom';
import { act, fireEvent, render } from 'tests/test-utils'; import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
@ -14,20 +13,6 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/useSafeNavigate', () => ({ jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({ useSafeNavigate: (): any => ({
safeNavigate: jest.fn(), safeNavigate: jest.fn(),
@ -84,11 +69,11 @@ describe('Alert rule documentation redirection', () => {
beforeEach(() => { beforeEach(() => {
act(() => { act(() => {
renderResult = render( renderResult = render(
<MemoryRouter initialEntries={['/alerts/new']}> <CreateAlertPage />,
<Route path={ROUTES.ALERTS_NEW}> {},
<CreateAlertPage /> {
</Route> initialRoute: ROUTES.ALERTS_NEW,
</MemoryRouter>, },
); );
}); });
}); });

View File

@ -15,20 +15,6 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
window.ResizeObserver = window.ResizeObserver =
window.ResizeObserver || window.ResizeObserver ||
jest.fn().mockImplementation(() => ({ jest.fn().mockImplementation(() => ({

View File

@ -0,0 +1,51 @@
.time-input-container {
display: flex;
align-items: center;
gap: 0;
.time-input-field {
width: 40px;
height: 32px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
font-family: 'Space Mono', monospace;
font-size: 14px;
font-weight: 600;
text-align: center;
border-radius: 4px;
&::placeholder {
color: var(--bg-vanilla-400);
font-family: 'Space Mono', monospace;
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
outline: none;
}
&:disabled {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-400);
cursor: not-allowed;
&:hover {
border-color: var(--bg-slate-400);
}
}
}
.time-input-separator {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 600;
margin: 0 4px;
user-select: none;
}
}

View File

@ -0,0 +1,195 @@
import './TimeInput.scss';
import { Input } from 'antd';
import React, { useEffect, useState } from 'react';
export interface TimeInputProps {
value?: string; // Format: "HH:MM:SS"
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
function TimeInput({
value = '00:00:00',
onChange,
disabled = false,
className = '',
}: TimeInputProps): JSX.Element {
const [hours, setHours] = useState('00');
const [minutes, setMinutes] = useState('00');
const [seconds, setSeconds] = useState('00');
// Parse initial value
useEffect(() => {
if (value) {
const timeParts = value.split(':');
if (timeParts.length === 3) {
setHours(timeParts[0]);
setMinutes(timeParts[1]);
setSeconds(timeParts[2]);
}
}
}, [value]);
const notifyChange = (h: string, m: string, s: string): void => {
const rawValue = `${h}:${m}:${s}`;
onChange?.(rawValue);
};
const notifyFormattedChange = (h: string, m: string, s: string): void => {
const formattedValue = `${h.padStart(2, '0')}:${m.padStart(
2,
'0',
)}:${s.padStart(2, '0')}`;
onChange?.(formattedValue);
};
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newHours = e.target.value.replace(/\D/g, '');
if (newHours.length > 2) {
newHours = newHours.slice(0, 2);
}
if (newHours && parseInt(newHours, 10) > 23) {
newHours = '23';
}
setHours(newHours);
notifyChange(newHours, minutes, seconds);
};
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newMinutes = e.target.value.replace(/\D/g, '');
if (newMinutes.length > 2) {
newMinutes = newMinutes.slice(0, 2);
}
if (newMinutes && parseInt(newMinutes, 10) > 59) {
newMinutes = '59';
}
setMinutes(newMinutes);
notifyChange(hours, newMinutes, seconds);
};
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newSeconds = e.target.value.replace(/\D/g, '');
if (newSeconds.length > 2) {
newSeconds = newSeconds.slice(0, 2);
}
if (newSeconds && parseInt(newSeconds, 10) > 59) {
newSeconds = '59';
}
setSeconds(newSeconds);
notifyChange(hours, minutes, newSeconds);
};
const handleHoursBlur = (): void => {
const formattedHours = hours.padStart(2, '0');
setHours(formattedHours);
notifyFormattedChange(formattedHours, minutes, seconds);
};
const handleMinutesBlur = (): void => {
const formattedMinutes = minutes.padStart(2, '0');
setMinutes(formattedMinutes);
notifyFormattedChange(hours, formattedMinutes, seconds);
};
const handleSecondsBlur = (): void => {
const formattedSeconds = seconds.padStart(2, '0');
setSeconds(formattedSeconds);
notifyFormattedChange(hours, minutes, formattedSeconds);
};
// Helper functions for field navigation
const getNextField = (current: string): string => {
switch (current) {
case 'hours':
return 'minutes';
case 'minutes':
return 'seconds';
default:
return 'hours';
}
};
const getPrevField = (current: string): string => {
switch (current) {
case 'seconds':
return 'minutes';
case 'minutes':
return 'hours';
default:
return 'seconds';
}
};
// Handle key navigation
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
currentField: 'hours' | 'minutes' | 'seconds',
): void => {
if (e.key === 'ArrowRight' || e.key === 'Tab') {
e.preventDefault();
const nextField = document.querySelector(
`[data-field="${getNextField(currentField)}"]`,
) as HTMLInputElement;
nextField?.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prevField = document.querySelector(
`[data-field="${getPrevField(currentField)}"]`,
) as HTMLInputElement;
prevField?.focus();
}
};
return (
<div className={`time-input-container ${className}`}>
<Input
data-field="hours"
value={hours}
onChange={handleHoursChange}
onBlur={handleHoursBlur}
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
<span className="time-input-separator">:</span>
<Input
data-field="minutes"
value={minutes}
onChange={handleMinutesChange}
onBlur={handleMinutesBlur}
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
<span className="time-input-separator">:</span>
<Input
data-field="seconds"
value={seconds}
onChange={handleSecondsChange}
onBlur={handleSecondsBlur}
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
</div>
);
}
TimeInput.defaultProps = {
value: '00:00:00',
onChange: undefined,
disabled: false,
className: '',
};
export default TimeInput;

View File

@ -0,0 +1,3 @@
import TimeInput from './TimeInput';
export default TimeInput;

View File

@ -0,0 +1,241 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TimeInput from '../TimeInput/TimeInput';
describe('TimeInput', () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with default value', () => {
render(<TimeInput />);
expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds
});
it('should render with provided value', () => {
render(<TimeInput value="12:34:56" />);
expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours
expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
});
it('should handle hours changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '12' } });
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should handle minutes changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '30' } });
expect(mockOnChange).toHaveBeenCalledWith('00:30:00');
});
it('should handle seconds changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '45' } });
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
});
it('should pad single digits with zeros on blur', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '5' } });
fireEvent.blur(hoursInput);
expect(mockOnChange).toHaveBeenCalledWith('05:00:00');
});
it('should filter non-numeric characters', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '1a2b3c' } });
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should limit input to 2 characters', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '123456' } });
expect(hoursInput).toHaveValue('12');
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should handle keyboard navigation with ArrowRight', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(hoursInput);
await user.keyboard('{ArrowRight}');
expect(minutesInput).toHaveFocus();
});
it('should handle keyboard navigation with ArrowLeft', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(minutesInput);
await user.keyboard('{ArrowLeft}');
expect(hoursInput).toHaveFocus();
});
it('should handle Tab navigation', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(hoursInput);
await user.keyboard('{Tab}');
expect(minutesInput).toHaveFocus();
});
it('should disable inputs when disabled prop is true', () => {
render(<TimeInput disabled />);
const inputs = screen.getAllByRole('textbox');
inputs.forEach((input) => {
expect(input).toBeDisabled();
});
});
it('should update internal state when value prop changes', () => {
const { rerender } = render(<TimeInput value="01:02:03" />);
expect(screen.getByDisplayValue('01')).toBeInTheDocument();
expect(screen.getByDisplayValue('02')).toBeInTheDocument();
expect(screen.getByDisplayValue('03')).toBeInTheDocument();
rerender(<TimeInput value="04:05:06" />);
expect(screen.getByDisplayValue('04')).toBeInTheDocument();
expect(screen.getByDisplayValue('05')).toBeInTheDocument();
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
});
it('should handle partial time values', () => {
render(<TimeInput value="12:34" />);
// Should fall back to default values for incomplete format
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
});
it('should cap hours at 23 when user enters value > 23', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '25' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should cap hours at 23 when user enters value = 24', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '24' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should allow hours value of 23', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '23' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should cap minutes at 59 when user enters value > 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '65' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should cap minutes at 59 when user enters value = 60', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '60' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should allow minutes value of 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '59' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should cap seconds at 59 when user enters value > 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '75' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
it('should cap seconds at 59 when user enters value = 60', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '60' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
it('should allow seconds value of 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '59' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
});

View File

@ -0,0 +1,656 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable import/first */
// Mock dayjs before importing any other modules
const MOCK_DATE_STRING = '2024-01-15T00:30:00Z';
const MOCK_DATE_STRING_NON_LEAP_YEAR = '2023-01-15T00:30:00Z';
const MOCK_DATE_STRING_SPANS_MONTHS = '2024-01-31T00:30:00Z';
const FREQ_DAILY = 'FREQ=DAILY';
const TEN_THIRTY_TIME = '10:30:00';
const NINE_AM_TIME = '09:00:00';
jest.mock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = jest.fn((date?: string | Date) => {
if (date) {
return originalDayjs(date);
}
return originalDayjs(MOCK_DATE_STRING);
});
Object.keys(originalDayjs).forEach((key) => {
((mockDayjs as unknown) as Record<string, unknown>)[key] = originalDayjs[key];
});
return mockDayjs;
});
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
import dayjs, { Dayjs } from 'dayjs';
import { rrulestr } from 'rrule';
import { RollingWindowTimeframes } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
getCumulativeWindowTimeframeText,
getCustomRollingWindowTimeframeText,
getEvaluationWindowTypeText,
getRollingWindowTimeframeText,
getTimeframeText,
isValidRRule,
} from '../utils';
jest.mock('rrule', () => ({
rrulestr: jest.fn(),
}));
jest.mock('components/CustomTimePicker/timezoneUtils', () => ({
generateTimezoneData: jest.fn().mockReturnValue([
{ name: 'UTC', value: 'UTC', offset: '+00:00' },
{ name: 'America/New_York', value: 'America/New_York', offset: '-05:00' },
{ name: 'Europe/London', value: 'Europe/London', offset: '+00:00' },
]),
}));
const mockEvaluationWindowState: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
number: '0',
timezone: 'UTC',
time: '00:00:00',
unit: UniversalYAxisUnit.MINUTES,
},
};
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
describe('utils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getEvaluationWindowTypeText', () => {
it('should return correct text for rolling window', () => {
expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling');
});
it('should return correct text for cumulative window', () => {
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
});
it('should default to empty string for unknown type', () => {
expect(
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
).toBe('');
});
});
describe('getCumulativeWindowTimeframeText', () => {
it('should return correct text for current hour', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentHour',
}),
).toBe('Current hour, starting at minute 0 (UTC)');
});
it('should return correct text for current day', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentDay',
}),
).toBe('Current day, starting from 00:00:00 (UTC)');
});
it('should return correct text for current month', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentMonth',
}),
).toBe('Current month, starting from day 0 at 00:00:00 (UTC)');
});
it('should default to empty string for unknown timeframe', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'unknown',
}),
).toBe('');
});
});
describe('getRollingWindowTimeframeText', () => {
it('should return correct text for last 5 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES),
).toBe('Last 5 minutes');
});
it('should return correct text for last 10 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES),
).toBe('Last 10 minutes');
});
it('should return correct text for last 15 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES),
).toBe('Last 15 minutes');
});
it('should return correct text for last 30 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES),
).toBe('Last 30 minutes');
});
it('should return correct text for last 1 hour', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR),
).toBe('Last 1 hour');
});
it('should return correct text for last 2 hours', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS),
).toBe('Last 2 hours');
});
it('should return correct text for last 4 hours', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS),
).toBe('Last 4 hours');
});
it('should default to Last 5 minutes for unknown timeframe', () => {
expect(
getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes),
).toBe('');
});
});
describe('getCustomRollingWindowTimeframeText', () => {
it('should return correct text for custom rolling window', () => {
expect(getCustomRollingWindowTimeframeText(mockEvaluationWindowState)).toBe(
'Last 0 Minutes',
);
});
});
describe('getTimeframeText', () => {
it('should call getCustomRollingWindowTimeframeText for custom rolling window', () => {
expect(
getTimeframeText({
...mockEvaluationWindowState,
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '4',
},
}),
).toBe('Last 4 Minutes');
});
it('should call getRollingWindowTimeframeText for rolling window', () => {
expect(getTimeframeText(mockEvaluationWindowState)).toBe('Last 5 minutes');
});
it('should call getCumulativeWindowTimeframeText for cumulative window', () => {
expect(
getTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentDay',
}),
).toBe('Current day, starting from 00:00:00 (UTC)');
});
});
describe('buildAlertScheduleFromRRule', () => {
const mockRRule = {
all: jest.fn((callback) => {
const dates = [
new Date(MOCK_DATE_STRING),
new Date('2024-01-16T10:30:00Z'),
new Date('2024-01-17T10:30:00Z'),
];
dates.forEach((date, index) => callback(date, index));
}),
};
beforeEach(() => {
(rrulestr as jest.Mock).mockReturnValue(mockRRule);
});
it('should return null for empty rrule string', () => {
const result = buildAlertScheduleFromRRule('', null, '10:30:00');
expect(result).toBeNull();
});
it('should build schedule from valid rrule string', () => {
const result = buildAlertScheduleFromRRule(
FREQ_DAILY,
null,
TEN_THIRTY_TIME,
);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
expect(result).toEqual([
new Date(MOCK_DATE_STRING),
new Date('2024-01-16T10:30:00Z'),
new Date('2024-01-17T10:30:00Z'),
]);
});
it('should handle rrule with DTSTART', () => {
const date = dayjs('2024-01-20');
buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME);
// When date is provided, DTSTART is automatically added to the rrule string
expect(rrulestr).toHaveBeenCalledWith(
expect.stringMatching(/DTSTART:20240120T\d{6}Z/),
);
});
it('should handle rrule without DTSTART', () => {
// Test with no date provided - should use original rrule string
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
expect(result).toHaveLength(3);
});
it('should handle escaped newlines', () => {
buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00');
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
});
it('should limit occurrences to maxOccurrences', () => {
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2);
expect(result).toHaveLength(2);
});
it('should return null on error', () => {
(rrulestr as jest.Mock).mockImplementation(() => {
throw new Error('Invalid rrule');
});
const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00');
expect(result).toBeNull();
});
});
describe('buildAlertScheduleFromCustomSchedule', () => {
it('should generate monthly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['1', '15'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'01-02-2024 10:30:00',
'15-02-2024 10:30:00',
'01-03-2024 10:30:00',
'15-03-2024 10:30:00',
]);
});
it('should generate weekly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'12:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 12:30:00',
'19-01-2024 12:30:00',
'22-01-2024 12:30:00',
'26-01-2024 12:30:00',
'29-01-2024 12:30:00',
]);
});
it('should generate weekly occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today included (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'19-01-2024 10:30:00',
'22-01-2024 10:30:00',
'26-01-2024 10:30:00',
'29-01-2024 10:30:00',
]);
});
it('should generate weekly occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'19-01-2024 00:00:00',
'22-01-2024 00:00:00',
'26-01-2024 00:00:00',
'29-01-2024 00:00:00',
'02-02-2024 00:00:00',
]);
});
it('should generate weekly occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'19-01-2024 00:30:00',
'22-01-2024 00:30:00',
'26-01-2024 00:30:00',
'29-01-2024 00:30:00',
'02-02-2024 00:30:00',
]);
});
it('should generate monthly occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today included (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'15-02-2024 10:30:00',
'15-03-2024 10:30:00',
'15-04-2024 10:30:00',
'15-05-2024 10:30:00',
]);
});
it('should generate monthly occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-02-2024 00:00:00',
'15-03-2024 00:00:00',
'15-04-2024 00:00:00',
'15-05-2024 00:00:00',
'15-06-2024 00:00:00',
]);
});
it('should generate monthly occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-02-2024 00:30:00',
'15-03-2024 00:30:00',
'15-04-2024 00:30:00',
'15-05-2024 00:30:00',
'15-06-2024 00:30:00',
]);
});
it('should account for february 29th in a leap year', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['29'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'29-01-2024 10:30:00',
'29-02-2024 10:30:00',
'29-03-2024 10:30:00',
'29-04-2024 10:30:00',
'29-05-2024 10:30:00',
]);
});
it('should skip 31st on 30-day months', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['31'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'31-01-2024 10:30:00',
'31-03-2024 10:30:00',
'31-05-2024 10:30:00',
'31-07-2024 10:30:00',
'31-08-2024 10:30:00',
]);
});
it('should skip february 29th in a non-leap year', async () => {
jest.resetModules(); // clear previous mocks
jest.doMock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: string | Date): Dayjs => {
if (date) return originalDayjs(date);
return originalDayjs(MOCK_DATE_STRING_NON_LEAP_YEAR);
};
Object.assign(mockDayjs, originalDayjs);
return mockDayjs;
});
const { buildAlertScheduleFromCustomSchedule } = await import('../utils');
const { default: dayjs } = await import('dayjs');
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
const result = buildAlertScheduleFromCustomSchedule(
'month',
['29'],
'10:30:00',
5,
);
expect(result?.map((res) => formatDate(res))).toEqual([
'29-01-2023 10:30:00',
'29-03-2023 10:30:00',
'29-04-2023 10:30:00',
'29-05-2023 10:30:00',
'29-06-2023 10:30:00',
]);
});
it('should generate daily occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:40:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:40:00',
'16-01-2024 10:40:00',
'17-01-2024 10:40:00',
'18-01-2024 10:40:00',
'19-01-2024 10:40:00',
]);
});
it('should generate daily occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'16-01-2024 00:00:00',
'17-01-2024 00:00:00',
'18-01-2024 00:00:00',
'19-01-2024 00:00:00',
'20-01-2024 00:00:00',
]);
});
it('should generate daily occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'16-01-2024 00:30:00',
'17-01-2024 00:30:00',
'18-01-2024 00:30:00',
'19-01-2024 00:30:00',
'20-01-2024 00:30:00',
]);
});
it('should generate daily occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'16-01-2024 10:30:00',
'17-01-2024 10:30:00',
'18-01-2024 10:30:00',
'19-01-2024 10:30:00',
]);
});
it('daily occurrences should span across months correctly', async () => {
jest.resetModules(); // clear previous mocks
jest.doMock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: string | Date): Dayjs => {
if (date) return originalDayjs(date);
return originalDayjs(MOCK_DATE_STRING_SPANS_MONTHS);
};
Object.assign(mockDayjs, originalDayjs);
return mockDayjs;
});
const { buildAlertScheduleFromCustomSchedule } = await import('../utils');
const { default: dayjs } = await import('dayjs');
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'31-01-2024 10:30:00',
'01-02-2024 10:30:00',
'02-02-2024 10:30:00',
'03-02-2024 10:30:00',
'04-02-2024 10:30:00',
]);
});
});
describe('isValidRRule', () => {
beforeEach(() => {
(rrulestr as jest.Mock).mockReturnValue({});
});
it('should return true for valid rrule', () => {
expect(isValidRRule(FREQ_DAILY)).toBe(true);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
});
it('should handle escaped newlines', () => {
expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true);
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
});
it('should return false for invalid rrule', () => {
(rrulestr as jest.Mock).mockImplementation(() => {
throw new Error('Invalid rrule');
});
expect(isValidRRule('INVALID')).toBe(false);
});
});
});

View File

@ -0,0 +1,61 @@
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
export const EVALUATION_WINDOW_TYPE = [
{ label: 'Rolling', value: 'rolling' },
{ label: 'Cumulative', value: 'cumulative' },
];
export const EVALUATION_WINDOW_TIMEFRAME = {
rolling: [
{ label: 'Last 5 minutes', value: '5m0s' },
{ label: 'Last 10 minutes', value: '10m0s' },
{ label: 'Last 15 minutes', value: '15m0s' },
{ label: 'Last 30 minutes', value: '30m0s' },
{ label: 'Last 1 hour', value: '1h0m0s' },
{ label: 'Last 2 hours', value: '2h0m0s' },
{ label: 'Last 4 hours', value: '4h0m0s' },
],
cumulative: [
{ label: 'Current hour', value: 'currentHour' },
{ label: 'Current day', value: 'currentDay' },
{ label: 'Current month', value: 'currentMonth' },
],
};
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
{ label: 'WEEK', value: 'week' },
{ label: 'MONTH', value: 'month' },
];
export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
{ label: 'SUNDAY', value: 'sunday' },
{ label: 'MONDAY', value: 'monday' },
{ label: 'TUESDAY', value: 'tuesday' },
{ label: 'WEDNESDAY', value: 'wednesday' },
{ label: 'THURSDAY', value: 'thursday' },
{ label: 'FRIDAY', value: 'friday' },
{ label: 'SATURDAY', value: 'saturday' },
];
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
{ length: 31 },
(_, i) => {
const value = String(i + 1);
return { label: value, value };
},
);
export const WEEKDAY_MAP: { [key: string]: number } = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
label: `${timezone.name} (${timezone.offset})`,
value: timezone.value,
}));

View File

@ -0,0 +1,53 @@
import { Dispatch, SetStateAction } from 'react';
import {
EvaluationWindowAction,
EvaluationWindowState,
} from '../context/types';
export interface IAdvancedOptionItemProps {
title: string;
description: string;
input: JSX.Element;
}
export enum RollingWindowTimeframes {
'LAST_5_MINUTES' = '5m0s',
'LAST_10_MINUTES' = '10m0s',
'LAST_15_MINUTES' = '15m0s',
'LAST_30_MINUTES' = '30m0s',
'LAST_1_HOUR' = '1h0m0s',
'LAST_2_HOURS' = '2h0m0s',
'LAST_4_HOURS' = '4h0m0s',
}
export enum CumulativeWindowTimeframes {
'CURRENT_HOUR' = 'currentHour',
'CURRENT_DAY' = 'currentDay',
'CURRENT_MONTH' = 'currentMonth',
}
export interface IEvaluationWindowPopoverProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface IEvaluationWindowDetailsProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
}
export interface IEvaluationCadenceDetailsProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface TimeInputProps {
value?: string; // Format: "HH:MM:SS"
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}

View File

@ -0,0 +1,295 @@
import * as Sentry from '@sentry/react';
import dayjs, { Dayjs } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { rrulestr } from 'rrule';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../context/constants';
import { EvaluationWindowState } from '../context/types';
import { WEEKDAY_MAP } from './constants';
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types';
// Extend dayjs with timezone plugins
dayjs.extend(utc);
dayjs.extend(timezone);
export const getEvaluationWindowTypeText = (
windowType: 'rolling' | 'cumulative',
): string => {
switch (windowType) {
case 'rolling':
return 'Rolling';
case 'cumulative':
return 'Cumulative';
default:
return '';
}
};
export const getCumulativeWindowTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string => {
switch (evaluationWindow.timeframe) {
case CumulativeWindowTimeframes.CURRENT_HOUR:
return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`;
case CumulativeWindowTimeframes.CURRENT_DAY:
return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
case CumulativeWindowTimeframes.CURRENT_MONTH:
return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
default:
return '';
}
};
export const getRollingWindowTimeframeText = (
timeframe: RollingWindowTimeframes,
): string => {
switch (timeframe) {
case RollingWindowTimeframes.LAST_5_MINUTES:
return 'Last 5 minutes';
case RollingWindowTimeframes.LAST_10_MINUTES:
return 'Last 10 minutes';
case RollingWindowTimeframes.LAST_15_MINUTES:
return 'Last 15 minutes';
case RollingWindowTimeframes.LAST_30_MINUTES:
return 'Last 30 minutes';
case RollingWindowTimeframes.LAST_1_HOUR:
return 'Last 1 hour';
case RollingWindowTimeframes.LAST_2_HOURS:
return 'Last 2 hours';
case RollingWindowTimeframes.LAST_4_HOURS:
return 'Last 4 hours';
default:
return '';
}
};
export const getCustomRollingWindowTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string =>
`Last ${evaluationWindow.startingAt.number} ${
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
(option) => option.value === evaluationWindow.startingAt.unit,
)?.label
}`;
export const getTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string => {
if (evaluationWindow.windowType === 'rolling') {
if (evaluationWindow.timeframe === 'custom') {
return getCustomRollingWindowTimeframeText(evaluationWindow);
}
return getRollingWindowTimeframeText(
evaluationWindow.timeframe as RollingWindowTimeframes,
);
}
return getCumulativeWindowTimeframeText(evaluationWindow);
};
export function buildAlertScheduleFromRRule(
rruleString: string,
date: Dayjs | null,
startAt: string,
maxOccurrences = 10,
): Date[] | null {
try {
if (!rruleString) return null;
// Handle literal \n in string
let finalRRuleString = rruleString.replace(/\\n/g, '\n');
if (date) {
const dt = dayjs(date);
if (!dt.isValid()) throw new Error('Invalid date provided');
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
const dtWithTime = dt
.set('hour', hours)
.set('minute', minutes)
.set('second', seconds)
.set('millisecond', 0);
const dtStartStr = dtWithTime
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z');
if (!/DTSTART/i.test(finalRRuleString)) {
finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`;
}
}
const rruleObj = rrulestr(finalRRuleString);
const occurrences: Date[] = [];
rruleObj.all((date, index) => {
if (index >= maxOccurrences) return false;
occurrences.push(date);
return true;
});
return occurrences;
} catch (error) {
return null;
}
}
function generateMonthlyOccurrences(
targetDays: number[],
hours: number,
minutes: number,
seconds: number,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentMonth = dayjs().startOf('month');
const currentDate = dayjs();
const scanMonths = maxOccurrences + 12;
for (let monthOffset = 0; monthOffset < scanMonths; monthOffset++) {
const monthDate = currentMonth.add(monthOffset, 'month');
targetDays.forEach((day) => {
if (occurrences.length >= maxOccurrences) return;
const daysInMonth = monthDate.daysInMonth();
if (day <= daysInMonth) {
const targetDate = monthDate
.date(day)
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(currentDate)) {
occurrences.push(targetDate.toDate());
}
}
});
}
return occurrences;
}
function generateWeeklyOccurrences(
targetWeekdays: number[],
hours: number,
minutes: number,
seconds: number,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentWeek = dayjs().startOf('week');
const currentDate = dayjs();
for (let weekOffset = 0; weekOffset < maxOccurrences; weekOffset++) {
const weekDate = currentWeek.add(weekOffset, 'week');
targetWeekdays.forEach((weekday) => {
if (occurrences.length >= maxOccurrences) return;
const targetDate = weekDate
.day(weekday)
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(currentDate)) {
occurrences.push(targetDate.toDate());
}
});
}
return occurrences;
}
export function generateDailyOccurrences(
hours: number,
minutes: number,
seconds: number,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentDate = dayjs();
const currentTime =
currentDate.hour() * 3600 + currentDate.minute() * 60 + currentDate.second();
const targetTime = hours * 3600 + minutes * 60 + seconds;
// Start from today if target time is after current time, otherwise start from tomorrow
const startDayOffset = targetTime > currentTime ? 0 : 1;
for (
let dayOffset = startDayOffset;
dayOffset < startDayOffset + maxOccurrences;
dayOffset++
) {
const dayDate = currentDate.add(dayOffset, 'day');
const targetDate = dayDate.hour(hours).minute(minutes).second(seconds);
occurrences.push(targetDate.toDate());
}
return occurrences;
}
export function buildAlertScheduleFromCustomSchedule(
repeatEvery: string,
occurence: string[],
startAt: string,
maxOccurrences = 10,
): Date[] | null {
try {
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
let occurrences: Date[] = [];
if (repeatEvery === 'month') {
const targetDays = occurence
.map((day) => parseInt(day, 10))
.filter((day) => !Number.isNaN(day));
occurrences = generateMonthlyOccurrences(
targetDays,
hours,
minutes,
seconds,
maxOccurrences,
);
} else if (repeatEvery === 'week') {
const targetWeekdays = occurence
.map((day) => WEEKDAY_MAP[day.toLowerCase()])
.filter((day) => day !== undefined);
occurrences = generateWeeklyOccurrences(
targetWeekdays,
hours,
minutes,
seconds,
maxOccurrences,
);
} else if (repeatEvery === 'day') {
occurrences = generateDailyOccurrences(
hours,
minutes,
seconds,
maxOccurrences,
);
}
occurrences.sort((a, b) => a.getTime() - b.getTime());
return occurrences.slice(0, maxOccurrences);
} catch (error) {
Sentry.captureEvent({
message: `Error building alert schedule from custom schedule: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
level: 'error',
});
return null;
}
}
export function isValidRRule(rruleString: string): boolean {
try {
// normalize escaped \n
const finalRRuleString = rruleString.replace(/\\n/g, '\n');
rrulestr(finalRRuleString); // will throw if invalid
return true;
} catch {
return false;
}
}

View File

@ -1,13 +1,18 @@
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/constants';
import dayjs from 'dayjs';
import getRandomColor from 'lib/getRandomColor'; import getRandomColor from 'lib/getRandomColor';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { import {
AdvancedOptionsState,
AlertState, AlertState,
AlertThresholdMatchType, AlertThresholdMatchType,
AlertThresholdOperator, AlertThresholdOperator,
AlertThresholdState, AlertThresholdState,
Algorithm, Algorithm,
EvaluationWindowState,
Seasonality, Seasonality,
Threshold, Threshold,
TimeDuration, TimeDuration,
@ -70,6 +75,49 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
thresholds: [INITIAL_CRITICAL_THRESHOLD], thresholds: [INITIAL_CRITICAL_THRESHOLD],
}; };
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
sendNotificationIfDataIsMissing: {
toleranceLimit: 15,
timeUnit: UniversalYAxisUnit.MINUTES,
},
enforceMinimumDatapoints: {
minimumDatapoints: 0,
},
delayEvaluation: {
delay: 5,
timeUnit: UniversalYAxisUnit.MINUTES,
},
evaluationCadence: {
mode: 'default',
default: {
value: 1,
timeUnit: UniversalYAxisUnit.MINUTES,
},
custom: {
repeatEvery: 'week',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
rrule: {
date: dayjs(),
startAt: '00:00:00',
rrule: '',
},
},
};
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
time: '00:00:00',
number: '1',
timezone: TIMEZONE_DATA[0].value,
unit: UniversalYAxisUnit.MINUTES,
},
};
export const THRESHOLD_OPERATOR_OPTIONS = [ export const THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' }, { value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' }, { value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
@ -115,3 +163,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
{ value: Seasonality.DAILY, label: 'Daily' }, { value: Seasonality.DAILY, label: 'Daily' },
{ value: Seasonality.WEEKLY, label: 'Weekly' }, { value: Seasonality.WEEKLY, label: 'Weekly' },
]; ];
export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
];

View File

@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
import { import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE, INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE, INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from './constants'; } from './constants';
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types'; import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
import { import {
advancedOptionsReducer,
alertCreationReducer, alertCreationReducer,
alertThresholdReducer, alertThresholdReducer,
buildInitialAlertDef, buildInitialAlertDef,
evaluationWindowReducer,
getInitialAlertTypeFromURL, getInitialAlertTypeFromURL,
} from './utils'; } from './utils';
@ -80,6 +84,16 @@ export function CreateAlertProvider(
INITIAL_ALERT_THRESHOLD_STATE, INITIAL_ALERT_THRESHOLD_STATE,
); );
const [evaluationWindow, setEvaluationWindow] = useReducer(
evaluationWindowReducer,
INITIAL_EVALUATION_WINDOW_STATE,
);
const [advancedOptions, setAdvancedOptions] = useReducer(
advancedOptionsReducer,
INITIAL_ADVANCED_OPTIONS_STATE,
);
useEffect(() => { useEffect(() => {
setThresholdState({ setThresholdState({
type: 'RESET', type: 'RESET',
@ -94,8 +108,19 @@ export function CreateAlertProvider(
setAlertType: handleAlertTypeChange, setAlertType: handleAlertTypeChange,
thresholdState, thresholdState,
setThresholdState, setThresholdState,
evaluationWindow,
setEvaluationWindow,
advancedOptions,
setAdvancedOptions,
}), }),
[alertState, alertType, handleAlertTypeChange, thresholdState], [
alertState,
alertType,
handleAlertTypeChange,
thresholdState,
evaluationWindow,
advancedOptions,
],
); );
return ( return (

View File

@ -1,3 +1,4 @@
import { Dayjs } from 'dayjs';
import { Dispatch } from 'react'; import { Dispatch } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Labels } from 'types/api/alerts/def'; import { Labels } from 'types/api/alerts/def';
@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
setAlertType: Dispatch<AlertTypes>; setAlertType: Dispatch<AlertTypes>;
thresholdState: AlertThresholdState; thresholdState: AlertThresholdState;
setThresholdState: Dispatch<AlertThresholdAction>; setThresholdState: Dispatch<AlertThresholdAction>;
advancedOptions: AdvancedOptionsState;
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
} }
export interface ICreateAlertProviderProps { export interface ICreateAlertProviderProps {
@ -101,3 +106,87 @@ export type AlertThresholdAction =
| { type: 'SET_SEASONALITY'; payload: string } | { type: 'SET_SEASONALITY'; payload: string }
| { type: 'SET_THRESHOLDS'; payload: Threshold[] } | { type: 'SET_THRESHOLDS'; payload: Threshold[] }
| { type: 'RESET' }; | { type: 'RESET' };
export interface AdvancedOptionsState {
sendNotificationIfDataIsMissing: {
toleranceLimit: number;
timeUnit: string;
};
enforceMinimumDatapoints: {
minimumDatapoints: number;
};
delayEvaluation: {
delay: number;
timeUnit: string;
};
evaluationCadence: {
mode: EvaluationCadenceMode;
default: {
value: number;
timeUnit: string;
};
custom: {
repeatEvery: string;
startAt: string;
occurence: string[];
timezone: string;
};
rrule: {
date: Dayjs | null;
startAt: string;
rrule: string;
};
};
}
export type AdvancedOptionsAction =
| {
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: { toleranceLimit: number; timeUnit: string };
}
| {
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
payload: { minimumDatapoints: number };
}
| {
type: 'SET_DELAY_EVALUATION';
payload: { delay: number; timeUnit: string };
}
| {
type: 'SET_EVALUATION_CADENCE';
payload: {
default: { value: number; timeUnit: string };
custom: {
repeatEvery: string;
startAt: string;
timezone: string;
occurence: string[];
};
rrule: { date: Dayjs | null; startAt: string; rrule: string };
};
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'RESET' };
export interface EvaluationWindowState {
windowType: 'rolling' | 'cumulative';
timeframe: string;
startingAt: {
time: string;
number: string;
timezone: string;
unit: string;
};
}
export type EvaluationWindowAction =
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
| { type: 'SET_TIMEFRAME'; payload: string }
| {
type: 'SET_STARTING_AT';
payload: { time: string; number: string; timezone: string; unit: string };
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';

View File

@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
import { import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from './constants';
import {
AdvancedOptionsAction,
AdvancedOptionsState,
AlertState, AlertState,
AlertThresholdAction, AlertThresholdAction,
AlertThresholdState, AlertThresholdState,
CreateAlertAction, CreateAlertAction,
EvaluationWindowAction,
EvaluationWindowState,
} from './types'; } from './types';
export const alertCreationReducer = ( export const alertCreationReducer = (
@ -110,3 +118,57 @@ export const alertThresholdReducer = (
return state; return state;
} }
}; };
export const advancedOptionsReducer = (
state: AdvancedOptionsState,
action: AdvancedOptionsAction,
): AdvancedOptionsState => {
switch (action.type) {
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return { ...state, sendNotificationIfDataIsMissing: action.payload };
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
return { ...state, enforceMinimumDatapoints: action.payload };
case 'SET_DELAY_EVALUATION':
return { ...state, delayEvaluation: action.payload };
case 'SET_EVALUATION_CADENCE':
return {
...state,
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
};
case 'SET_EVALUATION_CADENCE_MODE':
return {
...state,
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
};
case 'RESET':
return INITIAL_ADVANCED_OPTIONS_STATE;
default:
return state;
}
};
export const evaluationWindowReducer = (
state: EvaluationWindowState,
action: EvaluationWindowAction,
): EvaluationWindowState => {
switch (action.type) {
case 'SET_WINDOW_TYPE':
return {
...state,
windowType: action.payload,
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
timeframe:
action.payload === 'rolling'
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
: 'currentHour',
};
case 'SET_TIMEFRAME':
return { ...state, timeframe: action.payload };
case 'SET_STARTING_AT':
return { ...state, startingAt: action.payload };
case 'RESET':
return INITIAL_EVALUATION_WINDOW_STATE;
default:
return state;
}
};

View File

@ -30,20 +30,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
}), }),
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock data // Mock data
const mockProps: WidgetGraphComponentProps = { const mockProps: WidgetGraphComponentProps = {
widget: { widget: {

View File

@ -33,19 +33,6 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
const queryClient = new QueryClient(); const queryClient = new QueryClient();
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-redux', () => ({ jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'), ...jest.requireActual('react-redux'),
useSelector: (): any => ({ useSelector: (): any => ({

View File

@ -3,20 +3,6 @@ import { render, screen } from '@testing-library/react';
import HostsListTable from '../HostsListTable'; import HostsListTable from '../HostsListTable';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container'; const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
describe('HostsListTable', () => { describe('HostsListTable', () => {

View File

@ -4,20 +4,6 @@ import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils';
const PROGRESS_BAR_CLASS = '.progress-bar'; const PROGRESS_BAR_CLASS = '.progress-bar';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('InfraMonitoringHosts utils', () => { describe('InfraMonitoringHosts utils', () => {
describe('formatDataForTable', () => { describe('formatDataForTable', () => {
it('should format host data correctly', () => { it('should format host data correctly', () => {

View File

@ -44,20 +44,6 @@ const verifyEntityLogsPayload = ({
return queryData; return queryData;
}; };
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock( jest.mock(
'components/OverlayScrollbar/OverlayScrollbar', 'components/OverlayScrollbar/OverlayScrollbar',
() => () =>

View File

@ -4,14 +4,8 @@ import setupCommonMocks from '../../commonMocks';
setupCommonMocks(); setupCommonMocks();
import { fireEvent, render, screen } from '@testing-library/react';
import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails'; import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails';
import { QueryClient, QueryClientProvider } from 'react-query'; import { fireEvent, render, screen } from 'tests/test-utils';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
const queryClient = new QueryClient();
describe('JobDetails', () => { describe('JobDetails', () => {
const mockJob = { const mockJob = {
@ -24,13 +18,7 @@ describe('JobDetails', () => {
it('should render modal with relevant metadata', () => { it('should render modal with relevant metadata', () => {
render( render(
<QueryClientProvider client={queryClient}> <JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
); );
const jobNameElements = screen.getAllByText('test-job'); const jobNameElements = screen.getAllByText('test-job');
@ -44,13 +32,7 @@ describe('JobDetails', () => {
it('should render modal with 4 tabs', () => { it('should render modal with 4 tabs', () => {
render( render(
<QueryClientProvider client={queryClient}> <JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
); );
const metricsTab = screen.getByText('Metrics'); const metricsTab = screen.getByText('Metrics');
@ -68,13 +50,7 @@ describe('JobDetails', () => {
it('default tab should be metrics', () => { it('default tab should be metrics', () => {
render( render(
<QueryClientProvider client={queryClient}> <JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
); );
const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
@ -83,13 +59,7 @@ describe('JobDetails', () => {
it('should switch to events tab when events tab is clicked', () => { it('should switch to events tab when events tab is clicked', () => {
render( render(
<QueryClientProvider client={queryClient}> <JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
); );
const eventsTab = screen.getByRole('radio', { name: 'Events' }); const eventsTab = screen.getByRole('radio', { name: 'Events' });
@ -100,13 +70,7 @@ describe('JobDetails', () => {
it('should close modal when close button is clicked', () => { it('should close modal when close button is clicked', () => {
render( render(
<QueryClientProvider client={queryClient}> <JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
); );
const closeButton = screen.getByRole('button', { name: 'Close' }); const closeButton = screen.getByRole('button', { name: 'Close' });

View File

@ -56,19 +56,6 @@ const setupCommonMocks = (): void => {
useNavigationType: (): any => 'PUSH', useNavigationType: (): any => 'PUSH',
})); }));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => ({
set: jest.fn(),
delete: jest.fn(),
get: jest.fn(),
has: jest.fn(),
entries: jest.fn(() => []),
append: jest.fn(),
toString: jest.fn(() => ''),
})),
}));
jest.mock('lib/getMinMax', () => ({ jest.mock('lib/getMinMax', () => ({
__esModule: true, __esModule: true,
default: jest.fn().mockImplementation(() => ({ default: jest.fn().mockImplementation(() => ({

View File

@ -32,20 +32,6 @@ import {
// Mock the useContextLogData hook // Mock the useContextLogData hook
const mockHandleRunQuery = jest.fn(); const mockHandleRunQuery = jest.fn();
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('container/OptionsMenu', () => ({ jest.mock('container/OptionsMenu', () => ({
useOptionsMenu: (): any => ({ useOptionsMenu: (): any => ({
options: { options: {

View File

@ -73,20 +73,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
}), }),
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({ jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
__esModule: true, __esModule: true,
useGetExplorerQueryRange: jest.fn(), useGetExplorerQueryRange: jest.fn(),

View File

@ -7,18 +7,16 @@ import { logsresponse } from 'mocks-server/__mockdata__/query_range';
import { server } from 'mocks-server/server'; import { server } from 'mocks-server/server';
import { rest } from 'msw'; import { rest } from 'msw';
import LogsExplorer from 'pages/LogsExplorer'; import LogsExplorer from 'pages/LogsExplorer';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import React from 'react'; import React from 'react';
import { I18nextProvider } from 'react-i18next';
import { MemoryRouter } from 'react-router-dom-v5-compat';
import { VirtuosoMockContext } from 'react-virtuoso'; import { VirtuosoMockContext } from 'react-virtuoso';
import i18n from 'ReactI18';
import { import {
act, act,
AllTheProviders,
fireEvent, fireEvent,
render, render,
RenderResult, RenderResult,
screen, screen,
userEvent,
waitFor, waitFor,
} from 'tests/test-utils'; } from 'tests/test-utils';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
@ -91,20 +89,6 @@ getStateSpy.mockImplementation(() => {
return originalState; return originalState;
}); });
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useLocation: (): { search: string; pathname: string } => ({ useLocation: (): { search: string; pathname: string } => ({
@ -277,9 +261,7 @@ describe.skip('LogsExplorerViews Pagination', () => {
act(() => { act(() => {
renderResult = render( renderResult = render(
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight }}> <VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight }}>
<I18nextProvider i18n={i18n}>
<LogsExplorer /> <LogsExplorer />
</I18nextProvider>
</VirtuosoMockContext.Provider>, </VirtuosoMockContext.Provider>,
); );
}); });
@ -453,13 +435,14 @@ function LogsExplorerWithMockContext({
); );
return ( return (
<MemoryRouter> <AllTheProviders
<QueryBuilderContext.Provider value={contextValue as any}> queryBuilderOverrides={contextValue as any}
initialRoute="/logs"
>
<VirtuosoMockContext.Provider value={virtuosoContextValue}> <VirtuosoMockContext.Provider value={virtuosoContextValue}>
<LogsExplorer /> <LogsExplorer />
</VirtuosoMockContext.Provider> </VirtuosoMockContext.Provider>
</QueryBuilderContext.Provider> </AllTheProviders>
</MemoryRouter>
); );
} }
@ -536,13 +519,12 @@ describe('Logs Explorer -> stage and run query', () => {
const initialEnd = initialPayload.end; const initialEnd = initialPayload.end;
// Click the Stage & Run Query button // Click the Stage & Run Query button
await act(async () => { const user = userEvent.setup({ pointerEventsCheck: 0 });
fireEvent.click( await user.click(
screen.getByRole('button', { screen.getByRole('button', {
name: /stage & run query/i, name: /stage & run query/i,
}), }),
); );
});
// Wait for additional API calls to be made after clicking Stage & Run Query // Wait for additional API calls to be made after clicking Stage & Run Query
await waitFor( await waitFor(

View File

@ -33,20 +33,6 @@ const lodsQueryServerRequest = (): void =>
), ),
); );
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// mocking the graph components in this test as this should be handled separately // mocking the graph components in this test as this should be handled separately
jest.mock( jest.mock(
'container/TimeSeriesView/TimeSeriesView', 'container/TimeSeriesView/TimeSeriesView',

View File

@ -18,10 +18,6 @@ const MOCK_SEARCH_PARAMS =
'?graphType=list&widgetId=36a7b342-c642-4b92-abe4-cb833a244786&compositeQuery=%7B%22id%22%3A%22b325ac88-5e75-4117-a38c-1a2a7caf8115%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateOperator%22%3A%22noop%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22------%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22isColumn%22%3Afalse%2C%22type%22%3A%22%22%2C%22isJSON%22%3Afalse%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3A60%2C%22having%22%3A%5B%5D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%7B%22columnName%22%3A%22timestamp%22%2C%22order%22%3A%22desc%22%7D%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%2C%22offset%22%3A0%2C%22pageSize%22%3A100%7D%5D%2C%22queryFormulas%22%3A%5B%5D%7D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22queryType%22%3A%22builder%22%7D&relativeTime=30m&options=%7B%22selectColumns%22%3A%5B%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22list%22%2C%22fontSize%22%3A%22small%22%7D'; '?graphType=list&widgetId=36a7b342-c642-4b92-abe4-cb833a244786&compositeQuery=%7B%22id%22%3A%22b325ac88-5e75-4117-a38c-1a2a7caf8115%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateOperator%22%3A%22noop%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22------%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22isColumn%22%3Afalse%2C%22type%22%3A%22%22%2C%22isJSON%22%3Afalse%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3A60%2C%22having%22%3A%5B%5D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%7B%22columnName%22%3A%22timestamp%22%2C%22order%22%3A%22desc%22%7D%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%2C%22offset%22%3A0%2C%22pageSize%22%3A100%7D%5D%2C%22queryFormulas%22%3A%5B%5D%7D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22queryType%22%3A%22builder%22%7D&relativeTime=30m&options=%7B%22selectColumns%22%3A%5B%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22list%22%2C%22fontSize%22%3A%22small%22%7D';
// Mocks // Mocks
jest.mock('uplot', () => ({
paths: { spline: jest.fn(), bars: jest.fn() },
default: jest.fn(() => ({ paths: { spline: jest.fn(), bars: jest.fn() } })),
}));
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({ jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true, __esModule: true,

View File

@ -68,19 +68,6 @@ jest.mock('hooks/useNotifications', () => ({
}, },
}), }),
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-redux', () => ({ jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'), ...jest.requireActual('react-redux'),
useSelector: (): any => ({ useSelector: (): any => ({

View File

@ -15,12 +15,6 @@ import {
TimeAggregationOptions, TimeAggregationOptions,
} from '../types'; } from '../types';
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: jest.fn(),
})),
);
const mockResizeObserver = jest.fn(); const mockResizeObserver = jest.fn();
mockResizeObserver.mockImplementation(() => ({ mockResizeObserver.mockImplementation(() => ({
observe: (): void => undefined, observe: (): void => undefined,

View File

@ -76,12 +76,6 @@ jest
isLoading: false, isLoading: false,
} as any); } as any);
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: 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 } => ({

View File

@ -1,6 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import * as useSafeNavigate from 'hooks/useSafeNavigate';
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover'; import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
@ -24,9 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2]; const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn(); const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({ jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate, safeNavigate: mockSafeNavigate,
}); }),
}));
const mockSetQuery = jest.fn(); const mockSetQuery = jest.fn();
const mockUrlQuery = { const mockUrlQuery = {

View File

@ -11,19 +11,6 @@ import store from 'store';
import Summary from '../Summary'; import Summary from '../Summary';
import { TreemapViewType } from '../types'; import { TreemapViewType } from '../types';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('d3-hierarchy', () => ({ jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({ stratify: jest.fn().mockReturnValue({
id: jest.fn().mockReturnValue({ id: jest.fn().mockReturnValue({

View File

@ -10,20 +10,6 @@ import store from 'store';
import ChangeHistory from '../index'; import ChangeHistory from '../index';
import { pipelineData, pipelineDataHistory } from './testUtils'; import { pipelineData, pipelineDataHistory } from './testUtils';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {

View File

@ -5,20 +5,6 @@ import { PipelineData } from 'types/api/pipeline/def';
import { pipelineMockData } from '../mocks/pipeline'; import { pipelineMockData } from '../mocks/pipeline';
import AddNewPipeline from '../PipelineListsView/AddNewPipeline'; import AddNewPipeline from '../PipelineListsView/AddNewPipeline';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
export function matchMedia(): void { export function matchMedia(): void {
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,

View File

@ -11,20 +11,6 @@ jest.mock('../PipelineListsView/AddNewProcessor/config', () => ({
DEFAULT_PROCESSOR_TYPE: 'json_parser', DEFAULT_PROCESSOR_TYPE: 'json_parser',
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const selectedProcessorData = { const selectedProcessorData = {
id: '1', id: '1',
orderId: 1, orderId: 1,

View File

@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18'; import i18n from 'ReactI18';
import store from 'store'; import store from 'store';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => { describe('PipelinePage container test', () => {
it('should render DeleteAction section', () => { it('should render DeleteAction section', () => {
const { asFragment } = render( const { asFragment } = render(

View File

@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18'; import i18n from 'ReactI18';
import store from 'store'; import store from 'store';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => { describe('PipelinePage container test', () => {
it('should render DragAction section', () => { it('should render DragAction section', () => {
const { asFragment } = render( const { asFragment } = render(

View File

@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18'; import i18n from 'ReactI18';
import store from 'store'; import store from 'store';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => { describe('PipelinePage container test', () => {
it('should render EditAction section', () => { it('should render EditAction section', () => {
const { asFragment } = render( const { asFragment } = render(

View File

@ -8,20 +8,6 @@ import store from 'store';
import { pipelineMockData } from '../mocks/pipeline'; import { pipelineMockData } from '../mocks/pipeline';
import PipelineActions from '../PipelineListsView/TableComponents/PipelineActions'; import PipelineActions from '../PipelineListsView/TableComponents/PipelineActions';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => { describe('PipelinePage container test', () => {
it('should render PipelineActions section', () => { it('should render PipelineActions section', () => {
const { asFragment } = render( const { asFragment } = render(

View File

@ -3,20 +3,6 @@ import { render } from 'tests/test-utils';
import { pipelineMockData } from '../mocks/pipeline'; import { pipelineMockData } from '../mocks/pipeline';
import PipelineExpandView from '../PipelineListsView/PipelineExpandView'; import PipelineExpandView from '../PipelineListsView/PipelineExpandView';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
beforeAll(() => { beforeAll(() => {
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,

View File

@ -6,20 +6,6 @@ import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
import { pipelineApiResponseMockData } from '../mocks/pipeline'; import { pipelineApiResponseMockData } from '../mocks/pipeline';
import PipelineListsView from '../PipelineListsView'; import PipelineListsView from '../PipelineListsView';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock useUrlQuery hook // Mock useUrlQuery hook
const mockUrlQuery = { const mockUrlQuery = {
get: jest.fn(), get: jest.fn(),

View File

@ -4,20 +4,6 @@ import { v4 } from 'uuid';
import PipelinePageLayout from '../Layouts/Pipeline'; import PipelinePageLayout from '../Layouts/Pipeline';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
beforeAll(() => { beforeAll(() => {
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,

View File

@ -7,20 +7,6 @@ import store from 'store';
import TagInput from '../components/TagInput'; import TagInput from '../components/TagInput';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('Pipeline Page', () => { describe('Pipeline Page', () => {
it('should render TagInput section', () => { it('should render TagInput section', () => {
const { asFragment } = render( const { asFragment } = render(

View File

@ -1,24 +1,11 @@
import { render } from '@testing-library/react';
import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags'; import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags';
import { I18nextProvider } from 'react-i18next'; import { render } from 'tests/test-utils';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18';
import store from 'store';
const tags = ['server', 'app']; const tags = ['server', 'app'];
describe('PipelinePage container test', () => { describe('PipelinePage container test', () => {
it('should render Tags section', () => { it('should render Tags section', () => {
const { asFragment } = render( const { asFragment } = render(<Tags tags={tags} />);
<MemoryRouter>
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<Tags tags={tags} />
</I18nextProvider>
</Provider>
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
}); });

View File

@ -11,20 +11,6 @@ import {
getTableColumn, getTableColumn,
} from '../PipelineListsView/utils'; } from '../PipelineListsView/utils';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('Utils testing of Pipeline Page', () => { describe('Utils testing of Pipeline Page', () => {
test('it should be check form field of add pipeline', () => { test('it should be check form field of add pipeline', () => {
expect(pipelineFields.length).toBe(3); expect(pipelineFields.length).toBe(3);

View File

@ -6,7 +6,7 @@ import { PlannedDowntime } from '../PlannedDowntime';
describe('PlannedDowntime Component', () => { describe('PlannedDowntime Component', () => {
it('renders the PlannedDowntime component properly', () => { it('renders the PlannedDowntime component properly', () => {
render(<PlannedDowntime />, {}, 'ADMIN'); render(<PlannedDowntime />, {}, { role: 'ADMIN' });
// Check if title is rendered // Check if title is rendered
expect(screen.getByText('Planned Downtime')).toBeInTheDocument(); expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
@ -30,7 +30,7 @@ describe('PlannedDowntime Component', () => {
}); });
it('disables the "New downtime" button for users with VIEWER role', () => { it('disables the "New downtime" button for users with VIEWER role', () => {
render(<PlannedDowntime />, {}, USER_ROLES.VIEWER); render(<PlannedDowntime />, {}, { role: USER_ROLES.VIEWER });
// Check if "New downtime" button is disabled for VIEWER // Check if "New downtime" button is disabled for VIEWER
const newDowntimeButton = screen.getByRole('button', { const newDowntimeButton = screen.getByRole('button', {

View File

@ -58,12 +58,16 @@
padding: 2px 8px; padding: 2px 8px;
align-items: center; align-items: center;
width: fit-content; width: fit-content;
max-width: calc(100% - 120px); /* Reserve space for action buttons */ max-width: 100%;
gap: 8px; gap: 8px;
border-radius: 50px; border-radius: 50px;
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
background: var(--bg-slate-500); background: var(--bg-slate-500);
.copy-wrapper {
overflow: hidden;
}
.item-value { .item-value {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: Inter; font-family: Inter;

View File

@ -97,11 +97,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
</div> </div>
<div className="value-wrapper"> <div className="value-wrapper">
<Tooltip title={item.value}> <Tooltip title={item.value}>
<div className="copy-wrapper">
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}> <CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
<Typography.Text className="item-value" ellipsis> <Typography.Text className="item-value" ellipsis>
{item.value} {item.value}
</Typography.Text>{' '} </Typography.Text>
</CopyClipboardHOC> </CopyClipboardHOC>
</div>
</Tooltip> </Tooltip>
<AttributeActions <AttributeActions
record={item} record={item}

View File

@ -3,7 +3,7 @@
flex-direction: column; flex-direction: column;
height: calc(100vh - 44px); //44px -> trace details top bar height: calc(100vh - 44px); //44px -> trace details top bar
border-left: 1px solid var(--bg-slate-400); border-left: 1px solid var(--bg-slate-400);
overflow-y: auto; overflow-y: auto !important;
&:not(&-docked) { &:not(&-docked) {
min-width: 450px; min-width: 450px;
} }

View File

@ -1,7 +1,5 @@
import { fireEvent, screen } from '@testing-library/react';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery'; import useUrlQuery from 'hooks/useUrlQuery';
import { render } from 'tests/test-utils'; import { fireEvent, render, screen } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2'; import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success'; import { SpanDuration } from '../Success';
@ -15,7 +13,6 @@ const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span'; const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
// Mock the hooks // Mock the hooks
jest.mock('hooks/useSafeNavigate');
jest.mock('hooks/useUrlQuery'); jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/badge', () => ({ jest.mock('@signozhq/badge', () => ({
Badge: jest.fn(), Badge: jest.fn(),
@ -52,24 +49,17 @@ const mockTraceMetadata = {
hasMissingSpans: false, hasMissingSpans: false,
}; };
jest.mock('uplot', () => { const mockSafeNavigate = jest.fn();
const paths = {
spline: jest.fn(), jest.mock('hooks/useSafeNavigate', () => ({
bars: jest.fn(), useSafeNavigate: (): any => ({
}; safeNavigate: mockSafeNavigate,
const uplotMock = jest.fn(() => ({ }),
paths,
})); }));
return {
paths,
default: uplotMock,
};
});
describe('SpanDuration', () => { describe('SpanDuration', () => {
const mockSetSelectedSpan = jest.fn(); const mockSetSelectedSpan = jest.fn();
const mockUrlQuerySet = jest.fn(); const mockUrlQuerySet = jest.fn();
const mockSafeNavigate = jest.fn();
const mockUrlQueryGet = jest.fn(); const mockUrlQueryGet = jest.fn();
beforeEach(() => { beforeEach(() => {
@ -81,11 +71,6 @@ describe('SpanDuration', () => {
get: mockUrlQueryGet, get: mockUrlQueryGet,
toString: () => 'spanId=test-span-id', toString: () => 'spanId=test-span-id',
}); });
// Mock safe navigate hook
(useSafeNavigate as jest.Mock).mockReturnValue({
safeNavigate: mockSafeNavigate,
});
}); });
it('updates URL and selected span when clicked', () => { it('updates URL and selected span when clicked', () => {

View File

@ -16,8 +16,6 @@ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: 2 * 60 * 1000, // 2 minutes - data becomes stale after 2 minutes
cacheTime: 5 * 60 * 1000, // 5 minutes - cache entries are garbage collected after 5 minutes
retry(failureCount, error): boolean { retry(failureCount, error): boolean {
if ( if (
// in case of manually throwing errors please make sure to send error.response.status // in case of manually throwing errors please make sure to send error.response.status

View File

@ -1,7 +1,10 @@
// src/mocks/server.js // src/mocks/server.js
import { rest } from 'msw';
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node';
import { handlers } from './handlers'; import { handlers } from './handlers';
// This configures a request mocking server with the given request handlers. // This configures a request mocking server with the given request handlers.
export const server = setupServer(...handlers); export const server = setupServer(...handlers);
export { rest };

View File

@ -28,20 +28,6 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// mocking the graph components in this test as this should be handled separately // mocking the graph components in this test as this should be handled separately
jest.mock( jest.mock(
'container/TimeSeriesView/TimeSeriesView', 'container/TimeSeriesView/TimeSeriesView',

View File

@ -35,21 +35,16 @@ jest.mock('container/TraceFlameGraph/index.tsx', () => ({
default: (): JSX.Element => <div>TraceFlameGraph</div>, default: (): JSX.Element => <div>TraceFlameGraph</div>,
})); }));
jest.mock('uplot', () => { describe('TraceDetail', () => {
const paths = { beforeEach(() => {
spline: jest.fn(), jest.useFakeTimers();
bars: jest.fn(), jest.setSystemTime(new Date('2023-10-20'));
}; });
const uplotMock = jest.fn(() => ({
paths, afterEach(() => {
})); jest.useRealTimers();
return {
paths,
default: uplotMock,
};
}); });
describe('TraceDetail', () => {
it('should render tracedetail', async () => { it('should render tracedetail', async () => {
const { findByText, getByText, getAllByText, getByPlaceholderText } = render( const { findByText, getByText, getAllByText, getByPlaceholderText } = render(
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}> <MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>

View File

@ -127,7 +127,11 @@ function TraceDetailsV2(): JSX.Element {
]; ];
return ( return (
<ResizablePanelGroup direction="horizontal" autoSaveId="trace-drawer"> <ResizablePanelGroup
direction="horizontal"
autoSaveId="trace-drawer"
className="trace-layout"
>
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content"> <ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
<TraceMetadata <TraceMetadata
traceID={traceId} traceID={traceId}

View File

@ -16,7 +16,6 @@ import {
} from 'mocks-server/__mockdata__/query_range'; } from 'mocks-server/__mockdata__/query_range';
import { server } from 'mocks-server/server'; import { server } from 'mocks-server/server';
import { rest } from 'msw'; import { rest } from 'msw';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { MemoryRouter } from 'react-router-dom-v5-compat'; import { MemoryRouter } from 'react-router-dom-v5-compat';
import { import {
act, act,
@ -97,22 +96,6 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock( jest.mock(
'components/Uplot/Uplot', 'components/Uplot/Uplot',
() => () =>
@ -181,32 +164,31 @@ const checkFilterValues = (
}; };
const renderWithTracesExplorerRouter = ( const renderWithTracesExplorerRouter = (
component: React.ReactNode, component: React.ReactElement,
initialEntries: string[] = [ initialEntries: string[] = [
'/traces-explorer/?panelType=list&selectedExplorerView=list', '/traces-explorer/?panelType=list&selectedExplorerView=list',
], ],
): ReturnType<typeof render> => ): ReturnType<typeof render> =>
render( render(
<MemoryRouter initialEntries={initialEntries}> component,
<QueryBuilderContext.Provider value={{ ...qbProviderValue }}> {},
{component} {
</QueryBuilderContext.Provider> initialRoute: initialEntries[0],
</MemoryRouter>, queryBuilderOverrides: qbProviderValue,
},
); );
describe('TracesExplorer - Filters', () => { describe('TracesExplorer - Filters', () => {
// Initial filter panel rendering // Initial filter panel rendering
// Test the initial state like which filters section are opened, default state of duration slider, etc. // Test the initial state like which filters section are opened, default state of duration slider, etc.
it('should render the Trace filter', async () => { it('should render the Trace filter', async () => {
const { getByText, getAllByText, getByTestId } = render( const {
<MemoryRouter getByText,
initialEntries={[ getAllByText,
getByTestId,
} = renderWithTracesExplorerRouter(<Filter setOpen={jest.fn()} />, [
`${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/?panelType=list&selectedExplorerView=list`, `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/?panelType=list&selectedExplorerView=list`,
]} ]);
>
<Filter setOpen={jest.fn()} />
</MemoryRouter>,
);
checkFilterValues(getByText, getAllByText); checkFilterValues(getByText, getAllByText);
@ -249,8 +231,12 @@ describe('TracesExplorer - Filters', () => {
it('filter panel actions', async () => { it('filter panel actions', async () => {
const { getByTestId } = render( const { getByTestId } = render(
<MemoryRouter> <MemoryRouter>
<Filter setOpen={jest.fn()} /> <Filter setOpen={jest.fn()} />,
</MemoryRouter>, </MemoryRouter>,
{},
{
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
},
); );
// Check if the section is closed // Check if the section is closed
@ -275,10 +261,12 @@ describe('TracesExplorer - Filters', () => {
}); });
it('checking filters should update the query', async () => { it('checking filters should update the query', async () => {
const { getByText } = renderWithTracesExplorerRouter( const { getByText } = render(
<QueryBuilderContext.Provider <Filter setOpen={jest.fn()} />,
value={ {},
{ {
queryBuilderOverrides: {
...qbProviderValue,
currentQuery: { currentQuery: {
...initialQueriesMap.traces, ...initialQueriesMap.traces,
builder: { builder: {
@ -286,12 +274,8 @@ describe('TracesExplorer - Filters', () => {
queryData: [initialQueryBuilderFormValues], queryData: [initialQueryBuilderFormValues],
}, },
}, },
redirectWithQueryBuilderData, },
} as any },
}
>
<Filter setOpen={jest.fn()} />
</QueryBuilderContext.Provider>,
); );
const okCheckbox = getByText('Ok'); const okCheckbox = getByText('Ok');
@ -343,9 +327,7 @@ describe('TracesExplorer - Filters', () => {
.spyOn(compositeQueryHook, 'useGetCompositeQueryParam') .spyOn(compositeQueryHook, 'useGetCompositeQueryParam')
.mockReturnValue(compositeQuery); .mockReturnValue(compositeQuery);
const { findByText, getByTestId } = renderWithTracesExplorerRouter( const { findByText, getByTestId } = render(<Filter setOpen={jest.fn()} />);
<Filter setOpen={jest.fn()} />,
);
// check if the default query is applied - composite query has filters - serviceName : demo-app and name : HTTP GET /customer // check if the default query is applied - composite query has filters - serviceName : demo-app and name : HTTP GET /customer
expect(await findByText('demo-app')).toBeInTheDocument(); expect(await findByText('demo-app')).toBeInTheDocument();
@ -369,8 +351,12 @@ describe('TracesExplorer - Filters', () => {
}, },
}); });
const { getByText, getAllByText } = renderWithTracesExplorerRouter( const { getByText, getAllByText } = render(
<Filter setOpen={jest.fn()} />, <Filter setOpen={jest.fn()} />,
{},
{
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
},
); );
checkFilterValues(getByText, getAllByText); checkFilterValues(getByText, getAllByText);
@ -394,18 +380,18 @@ describe('TracesExplorer - Filters', () => {
}, },
}); });
const { getByText, getAllByText } = renderWithTracesExplorerRouter( const { getByText, getAllByText } = render(<Filter setOpen={jest.fn()} />);
<Filter setOpen={jest.fn()} />,
);
checkFilterValues(getByText, getAllByText); checkFilterValues(getByText, getAllByText);
}); });
it('should clear filter on clear & reset button click', async () => { it('should clear filter on clear & reset button click', async () => {
const { getByText, getByTestId } = renderWithTracesExplorerRouter( const { getByText, getByTestId } = render(
<QueryBuilderContext.Provider <Filter setOpen={jest.fn()} />,
value={ {},
{ {
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
queryBuilderOverrides: {
currentQuery: { currentQuery: {
...initialQueriesMap.traces, ...initialQueriesMap.traces,
builder: { builder: {
@ -414,11 +400,8 @@ describe('TracesExplorer - Filters', () => {
}, },
}, },
redirectWithQueryBuilderData, redirectWithQueryBuilderData,
} as any },
} },
>
<Filter setOpen={jest.fn()} />
</QueryBuilderContext.Provider>,
); );
// check for the status section content // check for the status section content

View File

@ -55,7 +55,7 @@ describe('WorkspaceLocked', () => {
), ),
); );
render(<WorkspaceLocked />, {}, 'VIEWER'); render(<WorkspaceLocked />, {}, { role: 'VIEWER' });
const updateCreditCardBtn = await screen.queryByRole('button', { const updateCreditCardBtn = await screen.queryByRole('button', {
name: /Continue My Journey/i, name: /Continue My Journey/i,
}); });

View File

@ -269,7 +269,7 @@ export function DashboardProvider({
return data; return data;
}; };
const dashboardResponse = useQuery( const dashboardResponse = useQuery(
[REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params], [REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params, dashboardId],
{ {
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn, enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
queryFn: async () => { queryFn: async () => {

View File

@ -0,0 +1,292 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, waitFor } from '@testing-library/react';
import getDashboard from 'api/v1/dashboards/id/get';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { DashboardProvider, useDashboard } from 'providers/Dashboard/Dashboard';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
// Mock the dashboard API
jest.mock('api/v1/dashboards/id/get');
jest.mock('api/v1/dashboards/id/lock');
const mockGetDashboard = jest.mocked(getDashboard);
// Mock useRouteMatch to simulate different route scenarios
const mockUseRouteMatch = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: (): any => mockUseRouteMatch(),
}));
// Mock other dependencies
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
// Mock only the essential dependencies for Dashboard provider
jest.mock('providers/App/App', () => ({
useAppContext: (): any => ({
isLoggedIn: true,
user: { email: 'test@example.com', role: 'ADMIN' },
}),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): any => ({ showErrorModal: jest.fn() }),
}));
jest.mock('react-redux', () => ({
useSelector: jest.fn(() => ({
selectedTime: 'GLOBAL_TIME',
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-01T01:00:00Z',
})),
useDispatch: jest.fn(() => jest.fn()),
}));
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
function TestComponent(): JSX.Element {
const { dashboardResponse, dashboardId } = useDashboard();
return (
<div>
<div data-testid="dashboard-id">{dashboardId}</div>
<div data-testid="query-status">{dashboardResponse.status}</div>
<div data-testid="is-loading">{dashboardResponse.isLoading.toString()}</div>
<div data-testid="is-fetching">
{dashboardResponse.isFetching.toString()}
</div>
</div>
);
}
// Helper to create a test query client
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
}
// Helper to render with dashboard provider
function renderWithDashboardProvider(
initialRoute = '/dashboard/test-dashboard-id',
routeMatchParams?: { dashboardId: string } | null,
): any {
const queryClient = createTestQueryClient();
// Mock the route match
mockUseRouteMatch.mockReturnValue(
routeMatchParams
? {
path: ROUTES.DASHBOARD,
url: `/dashboard/${routeMatchParams.dashboardId}`,
isExact: true,
params: routeMatchParams,
}
: null,
);
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<DashboardProvider>
<TestComponent />
</DashboardProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('Dashboard Provider - Query Key with Route Params', () => {
const DASHBOARD_ID = 'test-dashboard-id';
const mockDashboardData = {
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
description: 'Test Description',
tags: [],
data: {
title: 'Test Dashboard',
layout: [],
widgets: [],
variables: {},
panelMap: {},
},
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
createdBy: 'test-user',
updatedBy: 'test-user',
locked: false,
},
};
beforeEach(() => {
jest.clearAllMocks();
mockGetDashboard.mockResolvedValue(mockDashboardData);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Query Key Behavior', () => {
it('should include route params in query key when on dashboard page', async () => {
const dashboardId = 'test-dashboard-id';
renderWithDashboardProvider(`/dashboard/${dashboardId}`, { dashboardId });
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId });
});
// Verify the query was called with the correct parameters
expect(mockGetDashboard).toHaveBeenCalledTimes(1);
});
it('should refetch when route params change', async () => {
const initialDashboardId = 'initial-dashboard-id';
const newDashboardId = 'new-dashboard-id';
// First render with initial dashboard ID
const { rerender } = renderWithDashboardProvider(
`/dashboard/${initialDashboardId}`,
{
dashboardId: initialDashboardId,
},
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: initialDashboardId });
});
// Change route params to simulate navigation
mockUseRouteMatch.mockReturnValue({
path: ROUTES.DASHBOARD,
url: `/dashboard/${newDashboardId}`,
isExact: true,
params: { dashboardId: newDashboardId },
});
// Rerender with new route
rerender(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[`/dashboard/${newDashboardId}`]}>
<DashboardProvider>
<TestComponent />
</DashboardProvider>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: newDashboardId });
});
// Should have been called twice - once for each dashboard ID
expect(mockGetDashboard).toHaveBeenCalledTimes(2);
});
it('should not fetch when not on dashboard page', () => {
// Mock no route match (not on dashboard page)
mockUseRouteMatch.mockReturnValue(null);
renderWithDashboardProvider('/some-other-page', null);
// Should not call the API
expect(mockGetDashboard).not.toHaveBeenCalled();
});
it('should handle undefined route params gracefully', async () => {
// Mock route match with undefined params
mockUseRouteMatch.mockReturnValue({
path: ROUTES.DASHBOARD,
url: '/dashboard/undefined',
isExact: true,
params: undefined,
});
renderWithDashboardProvider('/dashboard/undefined');
// Should not call API when params are undefined
expect(mockGetDashboard).not.toHaveBeenCalled();
});
});
describe('Cache Behavior', () => {
it('should create separate cache entries for different route params', async () => {
const queryClient = createTestQueryClient();
const dashboardId1 = 'dashboard-1';
const dashboardId2 = 'dashboard-2';
// First dashboard
mockUseRouteMatch.mockReturnValue({
path: ROUTES.DASHBOARD,
url: `/dashboard/${dashboardId1}`,
isExact: true,
params: { dashboardId: dashboardId1 },
});
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/dashboard/${dashboardId1}`]}>
<DashboardProvider>
<TestComponent />
</DashboardProvider>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId1 });
});
// Second dashboard
mockUseRouteMatch.mockReturnValue({
path: ROUTES.DASHBOARD,
url: `/dashboard/${dashboardId2}`,
isExact: true,
params: { dashboardId: dashboardId2 },
});
rerender(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/dashboard/${dashboardId2}`]}>
<DashboardProvider>
<TestComponent />
</DashboardProvider>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId2 });
});
// Should have separate cache entries
const cacheKeys = queryClient
.getQueryCache()
.getAll()
.map((query) => query.queryKey);
expect(cacheKeys).toHaveLength(2);
expect(cacheKeys[0]).toEqual([
REACT_QUERY_KEY.DASHBOARD_BY_ID,
{ dashboardId: dashboardId1 },
dashboardId1,
]);
expect(cacheKeys[1]).toEqual([
REACT_QUERY_KEY.DASHBOARD_BY_ID,
{ dashboardId: dashboardId2 },
dashboardId2,
]);
});
});
});

View File

@ -0,0 +1,93 @@
### Testing Guide
#### Tech Stack
- React Testing Library (RTL)
- Jest (runner, assertions, mocking)
- MSW (Mock Service Worker) for HTTP
- TypeScript (type-safe tests)
- JSDOM (browser-like env)
#### Unit Testing: What, Why, How
- What: Small, isolated tests for components, hooks, and utilities to verify behavior and edge cases.
- Why: Confidence to refactor, faster feedback than E2E, catches regressions early, documents intended behavior.
- How: Use our test harness with providers, mock external boundaries (APIs, router), assert on visible behavior and accessible roles, not implementation details.
#### Basic Template
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
#### .cursorrules (Highlights)
- Import from `tests/test-utils` only.
- Prefer `userEvent` for real interactions; use `fireEvent` only for low-level events (scroll/resize/setting `scrollTop`).
- Use MSW to mock network calls; large JSON goes in `mocks-server/__mockdata__`.
- Keep tests type-safe (`jest.MockedFunction<T>`, avoid `any`).
- Prefer accessible queries (`getByRole`, `findByRole`) before text and `data-testid`.
- Pin time only when asserting relative dates; avoid global fake timers otherwise.
Repo-specific reasons:
- The harness wires Redux, React Query, i18n, timezone, preferences, so importing from RTL directly bypasses critical providers.
- Some infra deps are globally mocked (e.g., `uplot`) to keep tests fast and stable.
- For virtualization (react-virtuoso), there is no `userEvent` scroll helper; use `fireEvent.scroll` after setting `element.scrollTop`.
#### Example patterns (from `components/QuickFilters/tests/QuickFilters.test.tsx`)
MSW overrides per test:
```ts
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/orgs/me/filters/logs`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
),
rest.put(`${ENVIRONMENT.baseURL}/api/v1/orgs/me/filters`, async (req, res, ctx) => {
// capture payload if needed
return res(ctx.status(200), ctx.json({}));
}),
);
```
Mock hooks minimally at module level:
```ts
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
```
Interact via accessible roles:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save changes/i }));
expect(screen.getByText(/ADDED FILTERS/i)).toBeInTheDocument();
```
Virtualized scroll:
```ts
const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement;
scroller.scrollTop = 500;
fireEvent.scroll(scroller);
```
Routing-dependent behavior:
```ts
render(<Page />, undefined, { initialRoute: '/logs-explorer?panelType=list' });
```
#### Notes
- Global mocks configured in Jest: `uplot``__mocks__/uplotMock.ts`.
- If a test needs custom behavior (e.g., different API response), override with `server.use(...)` locally.

View File

@ -2,18 +2,20 @@
import { render, RenderOptions, RenderResult } from '@testing-library/react'; import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { FeatureKeys } from 'constants/features'; import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences'; import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { ResourceProvider } from 'hooks/useResourceAttribute'; import { ResourceProvider } from 'hooks/useResourceAttribute';
import { AppContext } from 'providers/App/App'; import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types'; import { IAppContext } from 'providers/App/types';
import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder'; import {
QueryBuilderContext,
QueryBuilderProvider,
} from 'providers/QueryBuilder';
import TimezoneProvider from 'providers/Timezone'; import TimezoneProvider from 'providers/Timezone';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store'; import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import store from 'store'; import store from 'store';
@ -23,24 +25,27 @@ import {
LicenseState, LicenseState,
LicenseStatus, LicenseStatus,
} from 'types/api/licensesV3/getActive'; } from 'types/api/licensesV3/getActive';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import { ROLES, USER_ROLES } from 'types/roles'; import { ROLES, USER_ROLES } from 'types/roles';
// import { MemoryRouter as V5MemoryRouter } from 'react-router-dom-v5-compat';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: false,
}, },
}, },
}); });
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); // jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20')); jest.setSystemTime(new Date('2023-10-20'));
}); });
afterEach(() => { afterEach(() => {
queryClient.clear(); queryClient.clear();
jest.useRealTimers(); // jest.useRealTimers();
}); });
const mockStore = configureStore([thunk]); const mockStore = configureStore([thunk]);
@ -85,24 +90,6 @@ jest.mock('react-i18next', () => ({
}), }),
})); }));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: (): any => 'PUSH',
}));
export function getAppContextMock( export function getAppContextMock(
role: string, role: string,
appContextOverrides?: Partial<IAppContext>, appContextOverrides?: Partial<IAppContext>,
@ -253,48 +240,96 @@ export function getAppContextMock(
export function AllTheProviders({ export function AllTheProviders({
children, children,
role, // Accept the role as a prop role,
appContextOverrides, appContextOverrides,
queryBuilderOverrides,
initialRoute,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
role: string; // Define the role prop role?: string;
appContextOverrides: Partial<IAppContext>; appContextOverrides?: Partial<IAppContext>;
queryBuilderOverrides?: Partial<QueryBuilderContextType>;
initialRoute?: string;
}): ReactElement { }): ReactElement {
// Set default values
const roleValue = role || 'ADMIN';
const appContextOverridesValue = appContextOverrides || {};
const initialRouteValue = initialRoute || '/';
const queryBuilderContent = queryBuilderOverrides ? (
<QueryBuilderContext.Provider
value={queryBuilderOverrides as QueryBuilderContextType}
>
{children}
</QueryBuilderContext.Provider>
) : (
<QueryBuilderProvider>{children}</QueryBuilderProvider>
);
return ( return (
<MemoryRouter initialEntries={[initialRouteValue]}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Provider store={mockStored(role)}> <Provider store={mockStored(roleValue)}>
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}> <AppContext.Provider
value={getAppContextMock(roleValue, appContextOverridesValue)}
>
<ResourceProvider> <ResourceProvider>
<ErrorModalProvider> <ErrorModalProvider>
<BrowserRouter>
<TimezoneProvider> <TimezoneProvider>
<PreferenceContextProvider> <PreferenceContextProvider>
<QueryBuilderProvider>{children}</QueryBuilderProvider> {queryBuilderContent}
</PreferenceContextProvider> </PreferenceContextProvider>
</TimezoneProvider> </TimezoneProvider>
</BrowserRouter>
</ErrorModalProvider> </ErrorModalProvider>
</ResourceProvider> </ResourceProvider>
</AppContext.Provider> </AppContext.Provider>
</Provider> </Provider>
</QueryClientProvider> </QueryClientProvider>
</MemoryRouter>
); );
} }
AllTheProviders.defaultProps = {
role: 'ADMIN',
appContextOverrides: {},
queryBuilderOverrides: undefined,
initialRoute: '/',
};
interface ProviderProps {
role?: string;
appContextOverrides?: Partial<IAppContext>;
queryBuilderOverrides?: Partial<QueryBuilderContextType>;
initialRoute?: string;
}
const customRender = ( const customRender = (
ui: ReactElement, ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>, options?: Omit<RenderOptions, 'wrapper'>,
role = 'ADMIN', // Set a default role providerProps: ProviderProps = {},
appContextOverrides?: Partial<IAppContext>, ): RenderResult => {
): RenderResult => const {
render(ui, { role = 'ADMIN',
appContextOverrides = {},
queryBuilderOverrides,
initialRoute = '/',
} = providerProps;
return render(ui, {
wrapper: () => ( wrapper: () => (
<AllTheProviders role={role} appContextOverrides={appContextOverrides || {}}> <AllTheProviders
role={role}
appContextOverrides={appContextOverrides}
queryBuilderOverrides={queryBuilderOverrides}
initialRoute={initialRoute}
>
{ui} {ui}
</AllTheProviders> </AllTheProviders>
), ),
...options, ...options,
}); });
};
export * from '@testing-library/react'; export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';
export { customRender as render }; export { customRender as render };

View File

@ -16122,6 +16122,13 @@ robust-predicates@^3.0.2:
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
rrule@2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e"
integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==
dependencies:
tslib "^2.4.0"
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1: rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
version "1.16.1" version "1.16.1"
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz" resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/authtypes"
openfgav1 "github.com/openfga/api/proto/openfga/v1" openfgav1 "github.com/openfga/api/proto/openfga/v1"
) )
@ -12,4 +13,7 @@ type AuthZ interface {
// Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o). // Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o).
Check(context.Context, *openfgav1.CheckRequestTupleKey) error Check(context.Context, *openfgav1.CheckRequestTupleKey) error
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, authtypes.Typeable, ...authtypes.Selector) error
} }

View File

@ -194,3 +194,40 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe
return nil return nil
} }
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selector authtypes.Selector, parentTypeable authtypes.Typeable, parentSelectors ...authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...)
if err != nil {
return err
}
check, err := provider.sequentialCheck(ctx, tuples)
if err != nil {
return err
}
if !check {
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", subject, relation.StringValue(), typeable.Type().StringValue())
}
return nil
}
func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) {
for _, tupleReq := range tuplesReq {
err := provider.Check(ctx, tupleReq)
if err == nil {
return true, nil
}
if errors.Ast(err, errors.TypeInternal) {
// return at the first internal error as the evaluation will be incorrect
return false, err
}
}
return false, nil
}

View File

@ -8,6 +8,7 @@ type role
type organisation type organisation
relations relations
define admin: [role#assignee] define create: [role#assignee]
define editor: [role#assignee] or admin define read: [role#assignee]
define viewer: [role#assignee] or editor define update: [role#assignee]
define delete: [role#assignee]

View File

@ -106,11 +106,15 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
}) })
} }
// each individual APIs should be responsible for defining the relation and the object being accessed, subject will be derived from the request func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ authtypes.Typeable, _ authtypes.SelectorCallbackFn) http.HandlerFunc {
func (middleware *AuthZ) Check(next http.HandlerFunc, relation string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
checkRequestTupleKey := authtypes.NewTuple("", "", "") claims, err := authtypes.ClaimsFromContext(req.Context())
err := middleware.authzService.Check(req.Context(), checkRequestTupleKey) if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID), nil)
if err != nil { if err != nil {
render.Error(rw, err) render.Error(rw, err)
return return

View File

@ -8,6 +8,8 @@ import (
"net/http" "net/http"
_ "net/http/pprof" // http profiler _ "net/http/pprof" // http profiler
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
"github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/alertmanager"
@ -308,6 +310,8 @@ func makeRulesManager(
querier querier.Querier, querier querier.Querier,
logger *slog.Logger, logger *slog.Logger,
) (*rules.Manager, error) { ) (*rules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts // create manager opts
managerOpts := &rules.ManagerOptions{ managerOpts := &rules.ManagerOptions{
TelemetryStore: telemetryStore, TelemetryStore: telemetryStore,
@ -319,9 +323,11 @@ func makeRulesManager(
SLogger: logger, SLogger: logger,
Cache: cache, Cache: cache,
EvalDelay: constants.GetEvalDelay(), EvalDelay: constants.GetEvalDelay(),
SQLStore: sqlstore,
OrgGetter: orgGetter, OrgGetter: orgGetter,
Alertmanager: alertmanager, Alertmanager: alertmanager,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
} }
// create Manager // create Manager

View File

@ -22,7 +22,6 @@ import (
querierV5 "github.com/SigNoz/signoz/pkg/querier" querierV5 "github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/interfaces" "github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore" "github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
@ -98,8 +97,10 @@ type ManagerOptions struct {
PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error) PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error)
PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError) PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError)
Alertmanager alertmanager.Alertmanager Alertmanager alertmanager.Alertmanager
SQLStore sqlstore.SQLStore
OrgGetter organization.Getter OrgGetter organization.Getter
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
SqlStore sqlstore.SQLStore
} }
// The Manager manages recording and alerting rules. // The Manager manages recording and alerting rules.
@ -207,14 +208,12 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
// by calling the Run method. // by calling the Run method.
func NewManager(o *ManagerOptions) (*Manager, error) { func NewManager(o *ManagerOptions) (*Manager, error) {
o = defaultOptions(o) o = defaultOptions(o)
ruleStore := sqlrulestore.NewRuleStore(o.SQLStore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(o.SQLStore)
m := &Manager{ m := &Manager{
tasks: map[string]Task{}, tasks: map[string]Task{},
rules: map[string]Rule{}, rules: map[string]Rule{},
ruleStore: ruleStore, ruleStore: o.RuleStore,
maintenanceStore: maintenanceStore, maintenanceStore: o.MaintenanceStore,
opts: o, opts: o,
block: make(chan struct{}), block: make(chan struct{}),
logger: o.Logger, logger: o.Logger,
@ -223,8 +222,8 @@ func NewManager(o *ManagerOptions) (*Manager, error) {
prepareTaskFunc: o.PrepareTaskFunc, prepareTaskFunc: o.PrepareTaskFunc,
prepareTestRuleFunc: o.PrepareTestRuleFunc, prepareTestRuleFunc: o.PrepareTestRuleFunc,
alertmanager: o.Alertmanager, alertmanager: o.Alertmanager,
sqlstore: o.SQLStore,
orgGetter: o.OrgGetter, orgGetter: o.OrgGetter,
sqlstore: o.SqlStore,
} }
return m, nil return m, nil
@ -896,33 +895,37 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
return nil, err return nil, err
} }
// storedRule holds the current stored rule from DB storedRule := ruletypes.PostableRule{}
patchedRule := ruletypes.PostableRule{} if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
if err := json.Unmarshal([]byte(ruleStr), &patchedRule); err != nil { zap.L().Error("failed to unmarshal rule from db", zap.String("id", id.StringValue()), zap.Error(err))
zap.L().Error("failed to unmarshal stored rule with given id", zap.String("id", id.StringValue()), zap.Error(err)) return nil, err
}
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
zap.L().Error("failed to unmarshal patched rule with given id", zap.String("id", id.StringValue()), zap.Error(err))
return nil, err return nil, err
} }
// deploy or un-deploy task according to patched (new) rule state // deploy or un-deploy task according to patched (new) rule state
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &patchedRule); err != nil { if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
zap.L().Error("failed to sync stored rule state with the task", zap.String("taskName", taskName), zap.Error(err)) zap.L().Error("failed to sync stored rule state with the task", zap.String("taskName", taskName), zap.Error(err))
return nil, err return nil, err
} }
// prepare rule json to write to update db newStoredJson, err := json.Marshal(&storedRule)
patchedRuleBytes, err := json.Marshal(patchedRule)
if err != nil { if err != nil {
zap.L().Error("failed to marshal new stored rule with given id", zap.String("id", id.StringValue()), zap.Error(err))
return nil, err return nil, err
} }
now := time.Now() now := time.Now()
storedJSON.Data = string(patchedRuleBytes) storedJSON.Data = string(newStoredJson)
storedJSON.UpdatedBy = claims.Email storedJSON.UpdatedBy = claims.Email
storedJSON.UpdatedAt = now storedJSON.UpdatedAt = now
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil }) err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
if err != nil { if err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &patchedRule); err != nil { if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
zap.L().Error("failed to restore rule after patch failure", zap.String("taskName", taskName), zap.Error(err)) zap.L().Error("failed to restore rule after patch failure", zap.String("taskName", taskName), zap.Error(err))
} }
return nil, err return nil, err
@ -931,7 +934,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// prepare http response // prepare http response
response := ruletypes.GettableRule{ response := ruletypes.GettableRule{
Id: id.StringValue(), Id: id.StringValue(),
PostableRule: patchedRule, PostableRule: storedRule,
} }
// fetch state of rule from memory // fetch state of rule from memory

View File

@ -0,0 +1,610 @@
package rules
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/rulestoretest"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func TestManager_PatchRule_PayloadVariations(t *testing.T) {
// Set up test claims and manager once for all test cases
claims := &authtypes.Claims{
UserID: "550e8400-e29b-41d4-a716-446655440000",
Email: "test@example.com",
Role: "admin",
}
manager, mockSQLRuleStore, orgId := setupTestManager(t)
claims.OrgID = orgId
testCases := []struct {
name string
originalData string
patchData string
expectedResult func(*ruletypes.GettableRule) bool
expectError bool
description string
}{
{
name: "patch complete rule with task sync validation",
originalData: `{
"schemaVersion":"v1",
"alert": "test-original-alert",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"evalWindow": "5m0s",
"condition": {
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"disabled": false,
"aggregations": [
{
"metricName": "container.cpu.time",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}
]
}
}
]
}
},
"labels": {
"severity": "warning"
},
"disabled": false,
"preferredChannels": ["test-alerts"]
}`,
patchData: `{
"alert": "test-patched-alert",
"labels": {
"severity": "critical"
}
}`,
expectedResult: func(result *ruletypes.GettableRule) bool {
return result.AlertName == "test-patched-alert" &&
result.Labels["severity"] == "critical" &&
result.Disabled == false
},
expectError: false,
},
{
name: "patch rule to disabled state",
originalData: `{
"schemaVersion":"v2",
"alert": "test-disable-alert",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"evalWindow": "5m0s",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "WARNING",
"target": 30,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"channels": ["test-alerts"]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"disabled": false,
"aggregations": [
{
"metricName": "container.memory.usage",
"timeAggregation": "avg",
"spaceAggregation": "sum"
}
]
}
}
]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m",
"frequency": "1m"
}
},
"labels": {
"severity": "warning"
},
"disabled": false,
"preferredChannels": ["test-alerts"]
}`,
patchData: `{
"disabled": true
}`,
expectedResult: func(result *ruletypes.GettableRule) bool {
return result.Disabled == true
},
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ruleID := valuer.GenerateUUID()
existingRule := &ruletypes.Rule{
Identifiable: types.Identifiable{
ID: ruleID,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: "creator@example.com",
UpdatedBy: "creator@example.com",
},
Data: tc.originalData,
OrgID: claims.OrgID,
}
mockSQLRuleStore.ExpectGetStoredRule(ruleID, existingRule)
mockSQLRuleStore.ExpectEditRule(existingRule)
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
result, err := manager.PatchRule(ctx, tc.patchData, ruleID)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, ruleID.StringValue(), result.Id)
if tc.expectedResult != nil {
assert.True(t, tc.expectedResult(result), "Expected result validation failed")
}
taskName := prepareTaskName(result.Id)
if result.Disabled {
syncCompleted := waitForTaskSync(manager, taskName, false, 2*time.Second)
assert.True(t, syncCompleted, "Task synchronization should complete within timeout")
assert.Nil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be removed for disabled rule")
} else {
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
assert.True(t, syncCompleted, "Task synchronization should complete within timeout")
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be created/updated for enabled rule")
assert.Greater(t, len(manager.Rules()), 0, "Rules should be updated in manager")
}
assert.NoError(t, mockSQLRuleStore.AssertExpectations())
})
}
}
func waitForTaskSync(manager *Manager, taskName string, expectedExists bool, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
task := findTaskByName(manager.RuleTasks(), taskName)
exists := task != nil
if exists == expectedExists {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
// findTaskByName finds a task by name in the slice of tasks
func findTaskByName(tasks []Task, taskName string) Task {
for i := 0; i < len(tasks); i++ {
if tasks[i].Name() == taskName {
return tasks[i]
}
}
return nil
}
func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore, string) {
settings := instrumentationtest.New().ToProviderSettings()
testDB := utils.NewQueryServiceDBForTests(t)
err := utils.CreateTestOrg(t, testDB)
if err != nil {
t.Fatalf("Failed to create test org: %v", err)
}
testOrgID, err := utils.GetTestOrgId(testDB)
if err != nil {
t.Fatalf("Failed to get test org ID: %v", err)
}
//will replace this with alertmanager mock
newConfig := alertmanagerserver.NewConfig()
defaultConfig, err := alertmanagertypes.NewDefaultConfig(newConfig.Global, newConfig.Route, testOrgID.StringValue())
if err != nil {
t.Fatalf("Failed to create default alertmanager config: %v", err)
}
_, err = testDB.BunDB().NewInsert().
Model(defaultConfig.StoreableConfig()).
Exec(context.Background())
if err != nil {
t.Fatalf("Failed to insert alertmanager config: %v", err)
}
noopSharder, err := noopsharder.New(context.TODO(), settings, sharder.Config{})
if err != nil {
t.Fatalf("Failed to create noop sharder: %v", err)
}
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), noopSharder)
alertManager, err := signozalertmanager.New(context.TODO(), settings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
if err != nil {
t.Fatalf("Failed to create alert manager: %v", err)
}
mockSQLRuleStore := rulestoretest.NewMockSQLRuleStore()
options := ManagerOptions{
Context: context.Background(),
Logger: zap.L(),
SLogger: instrumentationtest.New().Logger(),
EvalDelay: time.Minute,
PrepareTaskFunc: defaultPrepareTaskFunc,
Alertmanager: alertManager,
OrgGetter: orgGetter,
RuleStore: mockSQLRuleStore,
}
manager, err := NewManager(&options)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
close(manager.block)
return manager, mockSQLRuleStore, testOrgID.StringValue()
}
func TestCreateRule(t *testing.T) {
claims := &authtypes.Claims{
Email: "test@example.com",
}
manager, mockSQLRuleStore, orgId := setupTestManager(t)
claims.OrgID = orgId
testCases := []struct {
name string
ruleStr string
}{
{
name: "validate stored rule data structure",
ruleStr: `{
"alert": "cpu usage",
"ruleType": "threshold_rule",
"evalWindow": "5m",
"frequency": "1m",
"condition": {
"compositeQuery": {
"queryType": "builder",
"builderQueries": {
"A": {
"expression": "A",
"disabled": false,
"dataSource": "metrics",
"aggregateOperator": "avg",
"aggregateAttribute": {
"key": "cpu_usage",
"type": "Gauge"
}
}
}
},
"op": "1",
"target": 80,
"matchType": "1"
},
"labels": {
"severity": "warning"
},
"annotations": {
"summary": "High CPU usage detected"
},
"preferredChannels": ["test-alerts"]
}`,
},
{
name: "create complete v2 rule with thresholds",
ruleStr: `{
"schemaVersion":"v2",
"state": "firing",
"alert": "test-multi-threshold-create",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"evalWindow": "5m0s",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "CRITICAL",
"target": 0,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"channels": ["test-alerts"]
},
{
"name": "WARNING",
"target": 0,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"channels": ["test-alerts"]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"disabled": false,
"aggregations": [
{
"metricName": "container.cpu.time",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}
]
}
}
]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "6m",
"frequency": "1m"
}
},
"labels": {
"severity": "warning"
},
"annotations": {
"description": "This alert is fired when the defined metric crosses the threshold",
"summary": "The rule threshold is set and the observed metric value is evaluated"
},
"disabled": false,
"preferredChannels": ["#test-alerts-v2"],
"version": "v5"
}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rule := &ruletypes.Rule{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
OrgID: claims.OrgID,
}
mockSQLRuleStore.ExpectCreateRule(rule)
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
result, err := manager.CreateRule(ctx, tc.ruleStr)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotEmpty(t, result.Id, "Result should have a valid ID")
// Wait for task creation with proper synchronization
taskName := prepareTaskName(result.Id)
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
assert.True(t, syncCompleted, "Task creation should complete within timeout")
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be created with correct name")
assert.Greater(t, len(manager.Rules()), 0, "Rules should be added to manager")
assert.NoError(t, mockSQLRuleStore.AssertExpectations())
})
}
}
func TestEditRule(t *testing.T) {
// Set up test claims and manager once for all test cases
claims := &authtypes.Claims{
Email: "test@example.com",
}
manager, mockSQLRuleStore, orgId := setupTestManager(t)
claims.OrgID = orgId
testCases := []struct {
name string
ruleStr string
}{
{
name: "validate edit rule functionality",
ruleStr: `{
"alert": "updated cpu usage",
"ruleType": "threshold_rule",
"evalWindow": "10m",
"frequency": "2m",
"condition": {
"compositeQuery": {
"queryType": "builder",
"builderQueries": {
"A": {
"expression": "A",
"disabled": false,
"dataSource": "metrics",
"aggregateOperator": "avg",
"aggregateAttribute": {
"key": "cpu_usage",
"type": "Gauge"
}
}
}
},
"op": "1",
"target": 90,
"matchType": "1"
},
"labels": {
"severity": "critical"
},
"annotations": {
"summary": "Very high CPU usage detected"
},
"preferredChannels": ["critical-alerts"]
}`,
},
{
name: "edit complete v2 rule with thresholds",
ruleStr: `{
"schemaVersion":"v2",
"state": "firing",
"alert": "test-multi-threshold-edit",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"evalWindow": "5m0s",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "CRITICAL",
"target": 10,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"channels": ["test-alerts"]
},
{
"name": "WARNING",
"target": 5,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"channels": ["test-alerts"]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"disabled": false,
"aggregations": [
{
"metricName": "container.memory.usage",
"timeAggregation": "avg",
"spaceAggregation": "sum"
}
]
}
}
]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "8m",
"frequency": "2m"
}
},
"labels": {
"severity": "critical"
},
"annotations": {
"description": "This alert is fired when memory usage crosses the threshold",
"summary": "Memory usage threshold exceeded"
},
"disabled": false,
"preferredChannels": ["#critical-alerts-v2"],
"version": "v5"
}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ruleID := valuer.GenerateUUID()
existingRule := &ruletypes.Rule{
Identifiable: types.Identifiable{
ID: ruleID,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: "creator@example.com",
UpdatedBy: "creator@example.com",
},
Data: `{"alert": "original cpu usage", "disabled": false}`,
OrgID: claims.OrgID,
}
mockSQLRuleStore.ExpectGetStoredRule(ruleID, existingRule)
mockSQLRuleStore.ExpectEditRule(existingRule)
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
err := manager.EditRule(ctx, tc.ruleStr, ruleID)
assert.NoError(t, err)
// Wait for task update with proper synchronization
taskName := prepareTaskName(ruleID.StringValue())
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
assert.True(t, syncCompleted, "Task update should complete within timeout")
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be updated with correct name")
assert.Greater(t, len(manager.Rules()), 0, "Rules should be updated in manager")
assert.NoError(t, mockSQLRuleStore.AssertExpectations())
})
}
}

View File

@ -0,0 +1,110 @@
package rulestoretest
import (
"context"
"regexp"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MockSQLRuleStore is a mock RuleStore backed by sqlmock
type MockSQLRuleStore struct {
ruleStore ruletypes.RuleStore
mock sqlmock.Sqlmock
}
// NewMockSQLRuleStore creates a new MockSQLRuleStore with sqlmock
func NewMockSQLRuleStore() *MockSQLRuleStore {
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
ruleStore := sqlrulestore.NewRuleStore(sqlStore)
return &MockSQLRuleStore{
ruleStore: ruleStore,
mock: sqlStore.Mock(),
}
}
// Mock returns the sqlmock.Sqlmock instance for setting expectations
func (m *MockSQLRuleStore) Mock() sqlmock.Sqlmock {
return m.mock
}
// CreateRule implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL
func (m *MockSQLRuleStore) CreateRule(ctx context.Context, rule *ruletypes.Rule, fn func(context.Context, valuer.UUID) error) (valuer.UUID, error) {
return m.ruleStore.CreateRule(ctx, rule, fn)
}
// EditRule implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL
func (m *MockSQLRuleStore) EditRule(ctx context.Context, rule *ruletypes.Rule, fn func(context.Context) error) error {
return m.ruleStore.EditRule(ctx, rule, fn)
}
// DeleteRule implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL
func (m *MockSQLRuleStore) DeleteRule(ctx context.Context, id valuer.UUID, fn func(context.Context) error) error {
return m.ruleStore.DeleteRule(ctx, id, fn)
}
// GetStoredRule implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL
func (m *MockSQLRuleStore) GetStoredRule(ctx context.Context, id valuer.UUID) (*ruletypes.Rule, error) {
return m.ruleStore.GetStoredRule(ctx, id)
}
// GetStoredRules implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL
func (m *MockSQLRuleStore) GetStoredRules(ctx context.Context, orgID string) ([]*ruletypes.Rule, error) {
return m.ruleStore.GetStoredRules(ctx, orgID)
}
// ExpectCreateRule sets up SQL expectations for CreateRule operation
func (m *MockSQLRuleStore) ExpectCreateRule(rule *ruletypes.Rule) {
rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "created_by", "updated_by", "deleted", "data", "org_id"}).
AddRow(rule.ID, rule.CreatedAt, rule.UpdatedAt, rule.CreatedBy, rule.UpdatedBy, rule.Deleted, rule.Data, rule.OrgID)
expectedPattern := `INSERT INTO "rule" \(.+\) VALUES \(.+` +
regexp.QuoteMeta(rule.CreatedBy) + `.+` +
regexp.QuoteMeta(rule.OrgID) + `.+\) RETURNING`
m.mock.ExpectQuery(expectedPattern).
WillReturnRows(rows)
}
// ExpectEditRule sets up SQL expectations for EditRule operation
func (m *MockSQLRuleStore) ExpectEditRule(rule *ruletypes.Rule) {
expectedPattern := `UPDATE "rule".+` + rule.UpdatedBy + `.+` + rule.OrgID + `.+WHERE \(id = '` + rule.ID.StringValue() + `'\)`
m.mock.ExpectExec(expectedPattern).
WillReturnResult(sqlmock.NewResult(1, 1))
}
// ExpectDeleteRule sets up SQL expectations for DeleteRule operation
func (m *MockSQLRuleStore) ExpectDeleteRule(ruleID valuer.UUID) {
expectedPattern := `DELETE FROM "rule".+WHERE \(id = '` + ruleID.StringValue() + `'\)`
m.mock.ExpectExec(expectedPattern).
WillReturnResult(sqlmock.NewResult(1, 1))
}
// ExpectGetStoredRule sets up SQL expectations for GetStoredRule operation
func (m *MockSQLRuleStore) ExpectGetStoredRule(ruleID valuer.UUID, rule *ruletypes.Rule) {
rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "created_by", "updated_by", "deleted", "data", "org_id"}).
AddRow(rule.ID, rule.CreatedAt, rule.UpdatedAt, rule.CreatedBy, rule.UpdatedBy, rule.Deleted, rule.Data, rule.OrgID)
expectedPattern := `SELECT (.+) FROM "rule".+WHERE \(id = '` + ruleID.StringValue() + `'\)`
m.mock.ExpectQuery(expectedPattern).
WillReturnRows(rows)
}
// ExpectGetStoredRules sets up SQL expectations for GetStoredRules operation
func (m *MockSQLRuleStore) ExpectGetStoredRules(orgID string, rules []*ruletypes.Rule) {
rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "created_by", "updated_by", "deleted", "data", "org_id"})
for _, rule := range rules {
rows.AddRow(rule.ID, rule.CreatedAt, rule.UpdatedAt, rule.CreatedBy, rule.UpdatedBy, rule.Deleted, rule.Data, rule.OrgID)
}
expectedPattern := `SELECT (.+) FROM "rule".+WHERE \(.+org_id.+'` + orgID + `'\)`
m.mock.ExpectQuery(expectedPattern).
WillReturnRows(rows)
}
// AssertExpectations asserts that all SQL expectations were met
func (m *MockSQLRuleStore) AssertExpectations() error {
return m.mock.ExpectationsWereMet()
}

View File

@ -142,6 +142,36 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
}, },
expectedErr: nil, expectedErr: nil,
}, },
{
name: "Time series with group by on materialized column",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "service.name = 'cartservice'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "materialized.key.name",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`materialized.key.name`) GLOBAL IN (SELECT `materialized.key.name` FROM __limit_cte) GROUP BY ts, `materialized.key.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
} }
fm := NewFieldMapper() fm := NewFieldMapper()

View File

@ -0,0 +1,27 @@
package authtypes
import (
"regexp"
"github.com/SigNoz/signoz/pkg/errors"
)
var (
nameRegex = regexp.MustCompile("^[a-z]{1,35}$")
)
type Name struct {
val string
}
func MustNewName(name string) Name {
if !nameRegex.MatchString(name) {
panic(errors.NewInternalf(errors.CodeInternal, "name must conform to regex %s", nameRegex.String()))
}
return Name{val: name}
}
func (name Name) String() string {
return name.val
}

View File

@ -0,0 +1,23 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(organization)
type organization struct{}
func (organization *organization) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
tuples := make([]*openfgav1.CheckRequestTupleKey, 0)
object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object})
return tuples, nil
}
func (organization *organization) Type() Type {
return TypeOrganization
}

View File

@ -0,0 +1,23 @@
package authtypes
import "github.com/SigNoz/signoz/pkg/valuer"
var (
RelationCreate = Relation{valuer.NewString("create")}
RelationRead = Relation{valuer.NewString("read")}
RelationUpdate = Relation{valuer.NewString("update")}
RelationDelete = Relation{valuer.NewString("delete")}
RelationList = Relation{valuer.NewString("list")}
RelationBlock = Relation{valuer.NewString("block")}
RelationAssignee = Relation{valuer.NewString("assignee")}
)
var (
TypeUserSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete}
TypeRoleSupportedRelations = []Relation{RelationAssignee, RelationRead, RelationUpdate, RelationDelete}
TypeOrganizationSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList}
TypeResourceSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete, RelationBlock}
TypeResourcesSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList}
)
type Relation struct{ valuer.String }

View File

@ -0,0 +1,37 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(resource)
type resource struct {
name Name
}
func MustNewResource(name string) Typeable {
return &resource{name: MustNewName(name)}
}
func (resource *resource) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
tuples := make([]*openfgav1.CheckRequestTupleKey, 0)
for _, selector := range parentSelectors {
resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil)
if err != nil {
return nil, err
}
tuples = append(tuples, resourcesTuples...)
}
object := strings.Join([]string{TypeResource.StringValue(), resource.name.String(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object})
return tuples, nil
}
func (resource *resource) Type() Type {
return TypeResource
}

View File

@ -0,0 +1,26 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(resources)
type resources struct {
name Name
}
func MustNewResources(name string) Typeable {
return &resources{name: MustNewName(name)}
}
func (resources *resources) Tuples(subject string, relation Relation, selector Selector, _ Typeable, _ ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
object := strings.Join([]string{TypeResources.StringValue(), resources.name.String(), selector.String()}, ":")
return []*openfgav1.CheckRequestTupleKey{{User: subject, Relation: relation.StringValue(), Object: object}}, nil
}
func (resources *resources) Type() Type {
return TypeResources
}

View File

@ -0,0 +1,31 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(role)
type role struct{}
func (role *role) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
tuples := make([]*openfgav1.CheckRequestTupleKey, 0)
for _, selector := range parentSelectors {
resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil)
if err != nil {
return nil, err
}
tuples = append(tuples, resourcesTuples...)
}
object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object})
return tuples, nil
}
func (role *role) Type() Type {
return TypeRole
}

View File

@ -0,0 +1,62 @@
package authtypes
import (
"net/http"
"regexp"
"github.com/SigNoz/signoz/pkg/errors"
)
var (
typeUserSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeRoleSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeOrganizationSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeResourceSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeResourcesSelectorRegex = regexp.MustCompile(`^org:[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
)
type SelectorCallbackFn func(*http.Request) (Selector, []Selector, error)
type Selector struct {
val string
}
func NewSelector(typed Type, selector string) (Selector, error) {
switch typed {
case TypeUser:
if !typeUserSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeUserSelectorRegex.String())
}
case TypeRole:
if !typeRoleSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeRoleSelectorRegex.String())
}
case TypeOrganization:
if !typeOrganizationSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeOrganizationSelectorRegex.String())
}
case TypeResource:
if !typeResourceSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourceSelectorRegex.String())
}
case TypeResources:
if !typeResourcesSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourcesSelectorRegex.String())
}
}
return Selector{val: selector}, nil
}
func MustNewSelector(typed Type, input string) Selector {
selector, err := NewSelector(typed, input)
if err != nil {
panic(err)
}
return selector
}
func (selector Selector) String() string {
return selector.val
}

View File

@ -0,0 +1,18 @@
package authtypes
func NewSubject(subjectType Type, selector string, relation Relation) (string, error) {
if relation.IsZero() {
return subjectType.StringValue() + ":" + selector, nil
}
return subjectType.StringValue() + ":" + selector + "#" + relation.StringValue(), nil
}
func MustNewSubject(subjectType Type, selector string, relation Relation) string {
subject, err := NewSubject(subjectType, selector, relation)
if err != nil {
panic(err)
}
return subject
}

View File

@ -1,15 +0,0 @@
package authtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var (
ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable")
ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden")
)
func NewTuple(subject string, relation string, object string) *openfgav1.CheckRequestTupleKey {
return &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation, Object: object}
}

View File

@ -0,0 +1,36 @@
package authtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var (
ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable")
ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden")
ErrCodeAuthZInvalidSelectorRegex = errors.MustNewCode("authz_invalid_selector_regex")
ErrCodeAuthZUnsupportedRelation = errors.MustNewCode("authz_unsupported_relation")
ErrCodeAuthZInvalidSubject = errors.MustNewCode("authz_invalid_subject")
)
var (
TypeUser = Type{valuer.NewString("user")}
TypeRole = Type{valuer.NewString("role")}
TypeOrganization = Type{valuer.NewString("organization")}
TypeResource = Type{valuer.NewString("resource")}
TypeResources = Type{valuer.NewString("resources")}
)
var (
TypeableUser = &user{}
TypeableRole = &role{}
TypeableOrganization = &organization{}
)
type Typeable interface {
Type() Type
Tuples(subject string, relation Relation, selector Selector, parentType Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error)
}
type Type struct{ valuer.String }

Some files were not shown because too many files have changed in this diff Show More