mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 16:14:42 +00:00
Merge branch 'main' into enhancement/cmd-click-across-routes
This commit is contained in:
commit
5cf4e814a4
44
ee/authz/openfgaschema/base.fga
Normal file
44
ee/authz/openfgaschema/base.fga
Normal 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]
|
||||
29
ee/authz/openfgaschema/schema.go
Normal file
29
ee/authz/openfgaschema/schema.go
Normal 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
132
ee/http/middleware/authz.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
_ "net/http/pprof" // http profiler
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
||||
@ -334,6 +336,8 @@ func makeRulesManager(
|
||||
querier querier.Querier,
|
||||
logger *slog.Logger,
|
||||
) (*baserules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
// create manager opts
|
||||
managerOpts := &baserules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
@ -348,8 +352,10 @@ func makeRulesManager(
|
||||
PrepareTaskFunc: rules.PrepareTaskFunc,
|
||||
PrepareTestRuleFunc: rules.TestNotification,
|
||||
Alertmanager: alertmanager,
|
||||
SQLStore: sqlstore,
|
||||
OrgGetter: orgGetter,
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SqlStore: sqlstore,
|
||||
}
|
||||
|
||||
// create Manager
|
||||
|
||||
484
frontend/.cursorrules
Normal file
484
frontend/.cursorrules
Normal 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**: 3–5 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)
|
||||
}));
|
||||
```
|
||||
51
frontend/__mocks__/uplotMock.ts
Normal file
51
frontend/__mocks__/uplotMock.ts
Normal 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;
|
||||
29
frontend/__mocks__/useSafeNavigate.ts
Normal file
29
frontend/__mocks__/useSafeNavigate.ts
Normal 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
|
||||
>,
|
||||
});
|
||||
@ -1,5 +1,7 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
clearMocks: true,
|
||||
coverageDirectory: 'coverage',
|
||||
@ -10,6 +12,10 @@ const config: Config.InitialOptions = {
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|scss)$': '<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: {
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
|
||||
@ -139,6 +139,7 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
"style-loader": "1.3.0",
|
||||
"styled-components": "^5.3.11",
|
||||
|
||||
@ -119,7 +119,9 @@ const filterAndSortTimezones = (
|
||||
return createTimezoneEntry(normalizedTz, offset);
|
||||
});
|
||||
|
||||
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
|
||||
export const generateTimezoneData = (
|
||||
includeEtcTimezones = false,
|
||||
): Timezone[] => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
|
||||
const timezones: Timezone[] = [];
|
||||
|
||||
@ -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', () => ({
|
||||
useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
|
||||
useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
|
||||
|
||||
@ -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 ErrorModal from './ErrorModal';
|
||||
@ -56,9 +56,8 @@ describe('ErrorModal Component', () => {
|
||||
|
||||
// Click the close button
|
||||
const closeButton = screen.getByTestId('close-button');
|
||||
act(() => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(closeButton);
|
||||
|
||||
// Check if onClose was called
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
@ -149,9 +148,8 @@ it('should open the modal when the trigger component is clicked', async () => {
|
||||
|
||||
// Click the trigger component
|
||||
const triggerButton = screen.getByText('Open Error Modal');
|
||||
act(() => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(triggerButton);
|
||||
|
||||
// Check if the modal is displayed
|
||||
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
|
||||
const triggerButton = screen.getByText('error');
|
||||
act(() => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(triggerButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('An error occurred')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Trigger the onCancel event
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId('close-button'));
|
||||
});
|
||||
await user.click(screen.getByTestId('close-button'));
|
||||
|
||||
// Check if the modal is closed
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
filterConfig,
|
||||
isDynamicFilters,
|
||||
customFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
isCustomFiltersLoading,
|
||||
} = useFilterConfig({ signal, config });
|
||||
|
||||
@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
signal={signal}
|
||||
setIsSettingsOpen={setIsSettingsOpen}
|
||||
customFilters={customFilters}
|
||||
setIsStale={setIsStale}
|
||||
refetchCustomFilters={refetchCustomFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -14,12 +14,12 @@ function QuickFiltersSettings({
|
||||
signal,
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
setIsStale: (isStale: boolean) => void;
|
||||
refetchCustomFilters: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
handleSettingsClose,
|
||||
@ -34,7 +34,7 @@ function QuickFiltersSettings({
|
||||
} = useQuickFilterSettings({
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
setIsStale: (isStale: boolean) => void;
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn {
|
||||
const useQuickFilterSettings = ({
|
||||
customFilters,
|
||||
setIsSettingsOpen,
|
||||
setIsStale,
|
||||
refetchCustomFilters,
|
||||
signal,
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
@ -46,7 +46,7 @@ const useQuickFilterSettings = ({
|
||||
} = useMutation(updateCustomFiltersAPI, {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
setIsStale(true);
|
||||
refetchCustomFilters();
|
||||
logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
Filter as FilterType,
|
||||
PayloadProps,
|
||||
} from 'types/api/quickFilters/getCustomFilters';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
@ -18,37 +14,34 @@ interface UseFilterConfigProps {
|
||||
interface UseFilterConfigReturn {
|
||||
filterConfig: IQuickFiltersConfig[];
|
||||
customFilters: FilterType[];
|
||||
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
isCustomFiltersLoading: boolean;
|
||||
isDynamicFilters: boolean;
|
||||
setIsStale: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refetchCustomFilters: () => void;
|
||||
}
|
||||
|
||||
const useFilterConfig = ({
|
||||
signal,
|
||||
config,
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const [customFilters, setCustomFilters] = useState<FilterType[]>([]);
|
||||
const [isStale, setIsStale] = useState(true);
|
||||
const {
|
||||
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, [
|
||||
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(
|
||||
() => getFilterConfig(signal, customFilters, config),
|
||||
[config, customFilters, signal],
|
||||
@ -57,10 +50,9 @@ const useFilterConfig = ({
|
||||
return {
|
||||
filterConfig,
|
||||
customFilters,
|
||||
setCustomFilters,
|
||||
isCustomFiltersLoading,
|
||||
isDynamicFilters,
|
||||
setIsStale,
|
||||
refetchCustomFilters: refetch,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
@ -18,8 +9,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
|
||||
@ -29,21 +19,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
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 redirectWithQueryBuilderData = jest.fn();
|
||||
const putHandler = jest.fn();
|
||||
@ -78,11 +53,10 @@ const setupServer = (): void => {
|
||||
putHandler(await req.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)),
|
||||
),
|
||||
rest.get(fieldsValuesURL, (req, res, ctx) =>
|
||||
rest.get(fieldsValuesURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
@ -96,14 +70,12 @@ function TestQuickFilters({
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<MockQueryClientProvider>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
signal={signal}
|
||||
/>
|
||||
</MockQueryClientProvider>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -118,11 +90,11 @@ beforeAll(() => {
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@ -151,9 +123,13 @@ describe('Quick Filters', () => {
|
||||
});
|
||||
|
||||
it('should add filter data to query when checkbox is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters />);
|
||||
const checkbox = screen.getByText('mq-kafka');
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Prefer role if possible; if label text isn’t wired to input, clicking the label text is OK
|
||||
const target = await screen.findByText('mq-kafka');
|
||||
await user.click(target);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
@ -182,16 +158,20 @@ describe('Quick Filters', () => {
|
||||
|
||||
describe('Quick Filters with custom filters', () => {
|
||||
it('loads the custom filters correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
|
||||
expect(screen.getByText('Filters for')).toBeInTheDocument();
|
||||
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
|
||||
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
const allByText = await screen.findAllByText('otel-demo');
|
||||
// since 2 filter collapse are open, there are 2 filter items visible
|
||||
expect(allByText).toHaveLength(2);
|
||||
|
||||
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();
|
||||
|
||||
@ -207,16 +187,19 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
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 addButton = otherFilterItem.parentElement?.querySelector('button');
|
||||
expect(addButton).not.toBeNull();
|
||||
fireEvent.click(addButton as HTMLButtonElement);
|
||||
await user.click(addButton as HTMLButtonElement);
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
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 () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
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 target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||
const removeBtn = target.parentElement?.querySelector('button');
|
||||
expect(removeBtn).not.toBeNull();
|
||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
||||
|
||||
await user.click(removeBtn as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addedSection).not.toContainElement(
|
||||
@ -250,17 +237,20 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
it('restores original filter state on Discard', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
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 target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||
const removeBtn = target.parentElement?.querySelector('button');
|
||||
expect(removeBtn).not.toBeNull();
|
||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
||||
await user.click(removeBtn as HTMLButtonElement);
|
||||
|
||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||
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(() => {
|
||||
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 () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
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 removeBtn = target.parentElement?.querySelector('button');
|
||||
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(() => {
|
||||
expect(putHandler).toHaveBeenCalled();
|
||||
@ -311,31 +312,36 @@ describe('Quick Filters with custom filters', () => {
|
||||
expect(requestBody.signal).toBe(SIGNAL);
|
||||
});
|
||||
|
||||
// render duration filter
|
||||
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();
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||
|
||||
// click to open the duration filter
|
||||
fireEvent.click(screen.getByText('Duration'));
|
||||
// Open the duration section (use role if it’s a button/collapse)
|
||||
await user.click(screen.getByText('Duration'));
|
||||
|
||||
const minDuration = getByTestId('min-input') as HTMLInputElement;
|
||||
const maxDuration = getByTestId('max-input') as HTMLInputElement;
|
||||
|
||||
expect(minDuration).toHaveValue(null);
|
||||
expect(minDuration).toHaveProperty('placeholder', '0');
|
||||
expect(maxDuration).toHaveValue(null);
|
||||
expect(maxDuration).toHaveProperty('placeholder', '100000000');
|
||||
|
||||
await act(async () => {
|
||||
// set values
|
||||
fireEvent.change(minDuration, { target: { value: '10000' } });
|
||||
fireEvent.change(maxDuration, { target: { value: '20000' } });
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
// Type values and advance debounce
|
||||
await user.clear(minDuration);
|
||||
await user.type(minDuration, '10000');
|
||||
await user.clear(maxDuration);
|
||||
await user.type(maxDuration, '20000');
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -22,6 +22,8 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
describe('Alert Channels Settings List page', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-10-20'));
|
||||
render(<AlertChannels />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||
@ -29,6 +31,7 @@ describe('Alert Channels Settings List page', () => {
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
describe('Should display the Alert Channels page properly', () => {
|
||||
it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => {
|
||||
|
||||
@ -28,6 +28,7 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
describe('Alert Channels Settings List page (Normal User)', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
render(<AlertChannels />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||
@ -35,6 +36,7 @@ describe('Alert Channels Settings List page (Normal User)', () => {
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@ -9,22 +9,6 @@ import { getFormattedDate } from 'utils/timeUtils';
|
||||
|
||||
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 ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
@ -67,78 +51,103 @@ describe('BillingContainer', () => {
|
||||
expect(currentBill).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('OnTrail', async () => {
|
||||
await act(async () => {
|
||||
render(<BillingContainer />, undefined, undefined, {
|
||||
trialInfo: licensesSuccessResponse.data,
|
||||
describe('Trial scenarios', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-10-20'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('OnTrail', async () => {
|
||||
// Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining"
|
||||
|
||||
render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{ appContextOverrides: { trialInfo: licensesSuccessResponse.data } },
|
||||
);
|
||||
|
||||
// If the component schedules any setTimeout on mount, flush them:
|
||||
jest.runOnlyPendingTimers();
|
||||
|
||||
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,
|
||||
});
|
||||
expect(upgradeButtons).toHaveLength(2);
|
||||
expect(upgradeButtons[1]).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole('link', { name: /here/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const freeTrailText = await screen.findByText('Free Trial');
|
||||
expect(freeTrailText).toBeInTheDocument();
|
||||
|
||||
const currentBill = await screen.findByText('billing');
|
||||
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);
|
||||
expect(numberOfDayRemaining).toBeInTheDocument();
|
||||
const upgradeButton = await screen.findAllByRole('button', {
|
||||
name: /upgrade_plan/i,
|
||||
});
|
||||
expect(upgradeButton[1]).toBeInTheDocument();
|
||||
expect(upgradeButton.length).toBe(2);
|
||||
const checkPaidPlan = await screen.findByText(/checkout_plans/i);
|
||||
expect(checkPaidPlan).toBeInTheDocument();
|
||||
|
||||
const link = await screen.findByRole('link', { name: /here/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('OnTrail but trialConvertedToSubscription', async () => {
|
||||
await act(async () => {
|
||||
render(<BillingContainer />, undefined, undefined, {
|
||||
trialInfo: trialConvertedToSubscriptionResponse.data,
|
||||
test('OnTrail but trialConvertedToSubscription', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
trialInfo: trialConvertedToSubscriptionResponse.data,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const currentBill = await screen.findByText('billing');
|
||||
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 receivedCardDetails = await screen.findByText(
|
||||
/card_details_recieved_and_billing_info/i,
|
||||
);
|
||||
expect(receivedCardDetails).toBeInTheDocument();
|
||||
|
||||
const manageBillingButton = await screen.findByRole('button', {
|
||||
name: /manage_billing/i,
|
||||
});
|
||||
expect(manageBillingButton).toBeInTheDocument();
|
||||
|
||||
const dayRemainingInBillingPeriod = await screen.findByText(
|
||||
/1 days_remaining/i,
|
||||
);
|
||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const currentBill = await screen.findByText('billing');
|
||||
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 receivedCardDetails = await screen.findByText(
|
||||
/card_details_recieved_and_billing_info/i,
|
||||
);
|
||||
expect(receivedCardDetails).toBeInTheDocument();
|
||||
|
||||
const manageBillingButton = await screen.findByRole('button', {
|
||||
name: /manage_billing/i,
|
||||
});
|
||||
expect(manageBillingButton).toBeInTheDocument();
|
||||
|
||||
const dayRemainingInBillingPeriod = await screen.findByText(
|
||||
/1 days_remaining/i,
|
||||
);
|
||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Not on ontrail', async () => {
|
||||
const { findByText } = render(<BillingContainer />, undefined, undefined, {
|
||||
trialInfo: notOfTrailResponse.data,
|
||||
});
|
||||
const { findByText } = render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
trialInfo: notOfTrailResponse.data,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
|
||||
billingSuccessResponse.data.billingPeriodStart,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import { act, fireEvent, render } from 'tests/test-utils';
|
||||
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', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
@ -84,11 +69,11 @@ describe('Alert rule documentation redirection', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
renderResult = render(
|
||||
<MemoryRouter initialEntries={['/alerts/new']}>
|
||||
<Route path={ROUTES.ALERTS_NEW}>
|
||||
<CreateAlertPage />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: ROUTES.ALERTS_NEW,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -0,0 +1,3 @@
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
export default TimeInput;
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
}));
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,18 @@
|
||||
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 { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
@ -70,6 +75,49 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
||||
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 = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
@ -115,3 +163,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
|
||||
{ value: Seasonality.DAILY, label: 'Daily' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
alertThresholdReducer,
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
} from './utils';
|
||||
|
||||
@ -80,6 +84,16 @@ export function CreateAlertProvider(
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
);
|
||||
|
||||
const [evaluationWindow, setEvaluationWindow] = useReducer(
|
||||
evaluationWindowReducer,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
);
|
||||
|
||||
const [advancedOptions, setAdvancedOptions] = useReducer(
|
||||
advancedOptionsReducer,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
@ -94,8 +108,19 @@ export function CreateAlertProvider(
|
||||
setAlertType: handleAlertTypeChange,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
}),
|
||||
[alertState, alertType, handleAlertTypeChange, thresholdState],
|
||||
[
|
||||
alertState,
|
||||
alertType,
|
||||
handleAlertTypeChange,
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
|
||||
setAlertType: Dispatch<AlertTypes>;
|
||||
thresholdState: AlertThresholdState;
|
||||
setThresholdState: Dispatch<AlertThresholdAction>;
|
||||
advancedOptions: AdvancedOptionsState;
|
||||
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
@ -101,3 +106,87 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
| { 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';
|
||||
|
||||
@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdState,
|
||||
CreateAlertAction,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
@ -110,3 +118,57 @@ export const alertThresholdReducer = (
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
const mockProps: WidgetGraphComponentProps = {
|
||||
widget: {
|
||||
|
||||
@ -33,19 +33,6 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
|
||||
|
||||
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.requireActual('react-redux'),
|
||||
useSelector: (): any => ({
|
||||
|
||||
@ -3,20 +3,6 @@ import { render, screen } from '@testing-library/react';
|
||||
|
||||
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';
|
||||
|
||||
describe('HostsListTable', () => {
|
||||
|
||||
@ -4,20 +4,6 @@ import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils';
|
||||
|
||||
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('formatDataForTable', () => {
|
||||
it('should format host data correctly', () => {
|
||||
|
||||
@ -44,20 +44,6 @@ const verifyEntityLogsPayload = ({
|
||||
return queryData;
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
|
||||
@ -4,14 +4,8 @@ import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
describe('JobDetails', () => {
|
||||
const mockJob = {
|
||||
@ -24,13 +18,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const jobNameElements = screen.getAllByText('test-job');
|
||||
@ -44,13 +32,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
@ -68,13 +50,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
@ -83,13 +59,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
@ -100,13 +70,7 @@ describe('JobDetails', () => {
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
|
||||
@ -56,19 +56,6 @@ const setupCommonMocks = (): void => {
|
||||
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', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
|
||||
@ -32,20 +32,6 @@ import {
|
||||
// Mock the useContextLogData hook
|
||||
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', () => ({
|
||||
useOptionsMenu: (): any => ({
|
||||
options: {
|
||||
|
||||
@ -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', () => ({
|
||||
__esModule: true,
|
||||
useGetExplorerQueryRange: jest.fn(),
|
||||
|
||||
@ -7,18 +7,16 @@ import { logsresponse } from 'mocks-server/__mockdata__/query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import LogsExplorer from 'pages/LogsExplorer';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import React from 'react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import i18n from 'ReactI18';
|
||||
import {
|
||||
act,
|
||||
AllTheProviders,
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@ -91,20 +89,6 @@ getStateSpy.mockImplementation(() => {
|
||||
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.requireActual('react-router-dom'),
|
||||
useLocation: (): { search: string; pathname: string } => ({
|
||||
@ -277,9 +261,7 @@ describe.skip('LogsExplorerViews Pagination', () => {
|
||||
act(() => {
|
||||
renderResult = render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight }}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<LogsExplorer />
|
||||
</I18nextProvider>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>,
|
||||
);
|
||||
});
|
||||
@ -453,13 +435,14 @@ function LogsExplorerWithMockContext({
|
||||
);
|
||||
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryBuilderContext.Provider value={contextValue as any}>
|
||||
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</QueryBuilderContext.Provider>
|
||||
</MemoryRouter>
|
||||
<AllTheProviders
|
||||
queryBuilderOverrides={contextValue as any}
|
||||
initialRoute="/logs"
|
||||
>
|
||||
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</AllTheProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@ -536,13 +519,12 @@ describe('Logs Explorer -> stage and run query', () => {
|
||||
const initialEnd = initialPayload.end;
|
||||
|
||||
// Click the Stage & Run Query button
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /stage & run query/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /stage & run query/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for additional API calls to be made after clicking Stage & Run Query
|
||||
await waitFor(
|
||||
|
||||
@ -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
|
||||
jest.mock(
|
||||
'container/TimeSeriesView/TimeSeriesView',
|
||||
|
||||
@ -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';
|
||||
|
||||
// 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', () => ({
|
||||
__esModule: true,
|
||||
|
||||
@ -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.requireActual('react-redux'),
|
||||
useSelector: (): any => ({
|
||||
|
||||
@ -15,12 +15,6 @@ import {
|
||||
TimeAggregationOptions,
|
||||
} from '../types';
|
||||
|
||||
jest.mock('uplot', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
const mockResizeObserver = jest.fn();
|
||||
mockResizeObserver.mockImplementation(() => ({
|
||||
observe: (): void => undefined,
|
||||
|
||||
@ -76,12 +76,6 @@ jest
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
jest.mock('uplot', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import * as useSafeNavigate from 'hooks/useSafeNavigate';
|
||||
|
||||
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
|
||||
|
||||
@ -24,9 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2];
|
||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
|
||||
@ -11,19 +11,6 @@ import store from 'store';
|
||||
import Summary from '../Summary';
|
||||
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', () => ({
|
||||
stratify: jest.fn().mockReturnValue({
|
||||
id: jest.fn().mockReturnValue({
|
||||
|
||||
@ -10,20 +10,6 @@ import store from 'store';
|
||||
import ChangeHistory from '../index';
|
||||
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({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@ -5,20 +5,6 @@ import { PipelineData } from 'types/api/pipeline/def';
|
||||
import { pipelineMockData } from '../mocks/pipeline';
|
||||
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 {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
|
||||
@ -11,20 +11,6 @@ jest.mock('../PipelineListsView/AddNewProcessor/config', () => ({
|
||||
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 = {
|
||||
id: '1',
|
||||
orderId: 1,
|
||||
|
||||
@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
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', () => {
|
||||
it('should render DeleteAction section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
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', () => {
|
||||
it('should render DragAction section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
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', () => {
|
||||
it('should render EditAction section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@ -8,20 +8,6 @@ import store from 'store';
|
||||
import { pipelineMockData } from '../mocks/pipeline';
|
||||
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', () => {
|
||||
it('should render PipelineActions section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@ -3,20 +3,6 @@ import { render } from 'tests/test-utils';
|
||||
import { pipelineMockData } from '../mocks/pipeline';
|
||||
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(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
|
||||
@ -6,20 +6,6 @@ import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||
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
|
||||
const mockUrlQuery = {
|
||||
get: jest.fn(),
|
||||
|
||||
@ -4,20 +4,6 @@ import { v4 } from 'uuid';
|
||||
|
||||
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(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
|
||||
@ -7,20 +7,6 @@ import store from 'store';
|
||||
|
||||
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', () => {
|
||||
it('should render TagInput section', () => {
|
||||
const { asFragment } = render(
|
||||
|
||||
@ -1,24 +1,11 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
const tags = ['server', 'app'];
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render Tags section', () => {
|
||||
const { asFragment } = render(
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Tags tags={tags} />
|
||||
</I18nextProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const { asFragment } = render(<Tags tags={tags} />);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,20 +11,6 @@ import {
|
||||
getTableColumn,
|
||||
} 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', () => {
|
||||
test('it should be check form field of add pipeline', () => {
|
||||
expect(pipelineFields.length).toBe(3);
|
||||
|
||||
@ -6,7 +6,7 @@ import { PlannedDowntime } from '../PlannedDowntime';
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
it('renders the PlannedDowntime component properly', () => {
|
||||
render(<PlannedDowntime />, {}, 'ADMIN');
|
||||
render(<PlannedDowntime />, {}, { role: 'ADMIN' });
|
||||
|
||||
// Check if title is rendered
|
||||
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
||||
@ -30,7 +30,7 @@ describe('PlannedDowntime Component', () => {
|
||||
});
|
||||
|
||||
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
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
|
||||
@ -58,12 +58,16 @@
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 120px); /* Reserve space for action buttons */
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.copy-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
|
||||
@ -97,11 +97,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<Tooltip title={item.value}>
|
||||
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||
<Typography.Text className="item-value" ellipsis>
|
||||
{item.value}
|
||||
</Typography.Text>{' '}
|
||||
</CopyClipboardHOC>
|
||||
<div className="copy-wrapper">
|
||||
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||
<Typography.Text className="item-value" ellipsis>
|
||||
{item.value}
|
||||
</Typography.Text>
|
||||
</CopyClipboardHOC>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 44px); //44px -> trace details top bar
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
overflow-y: auto;
|
||||
overflow-y: auto !important;
|
||||
&:not(&-docked) {
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
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 { SpanDuration } from '../Success';
|
||||
@ -15,7 +13,6 @@ const DIMMED_SPAN_CLASS = 'dimmed-span';
|
||||
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useSafeNavigate');
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
jest.mock('@signozhq/badge', () => ({
|
||||
Badge: jest.fn(),
|
||||
@ -52,24 +49,17 @@ const mockTraceMetadata = {
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SpanDuration', () => {
|
||||
const mockSetSelectedSpan = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryGet = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
@ -81,11 +71,6 @@ describe('SpanDuration', () => {
|
||||
get: mockUrlQueryGet,
|
||||
toString: () => 'spanId=test-span-id',
|
||||
});
|
||||
|
||||
// Mock safe navigate hook
|
||||
(useSafeNavigate as jest.Mock).mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates URL and selected span when clicked', () => {
|
||||
|
||||
@ -16,8 +16,6 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
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 {
|
||||
if (
|
||||
// in case of manually throwing errors please make sure to send error.response.status
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
// src/mocks/server.js
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { handlers } from './handlers';
|
||||
|
||||
// This configures a request mocking server with the given request handlers.
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
export { rest };
|
||||
|
||||
@ -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
|
||||
jest.mock(
|
||||
'container/TimeSeriesView/TimeSeriesView',
|
||||
|
||||
@ -35,21 +35,16 @@ jest.mock('container/TraceFlameGraph/index.tsx', () => ({
|
||||
default: (): JSX.Element => <div>TraceFlameGraph</div>,
|
||||
}));
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('TraceDetail', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-10-20'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should render tracedetail', async () => {
|
||||
const { findByText, getByText, getAllByText, getByPlaceholderText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
|
||||
@ -127,7 +127,11 @@ function TraceDetailsV2(): JSX.Element {
|
||||
];
|
||||
|
||||
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">
|
||||
<TraceMetadata
|
||||
traceID={traceId}
|
||||
|
||||
@ -16,7 +16,6 @@ import {
|
||||
} from 'mocks-server/__mockdata__/query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import {
|
||||
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(
|
||||
'components/Uplot/Uplot',
|
||||
() =>
|
||||
@ -181,32 +164,31 @@ const checkFilterValues = (
|
||||
};
|
||||
|
||||
const renderWithTracesExplorerRouter = (
|
||||
component: React.ReactNode,
|
||||
component: React.ReactElement,
|
||||
initialEntries: string[] = [
|
||||
'/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||
],
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<QueryBuilderContext.Provider value={{ ...qbProviderValue }}>
|
||||
{component}
|
||||
</QueryBuilderContext.Provider>
|
||||
</MemoryRouter>,
|
||||
component,
|
||||
{},
|
||||
{
|
||||
initialRoute: initialEntries[0],
|
||||
queryBuilderOverrides: qbProviderValue,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesExplorer - Filters', () => {
|
||||
// Initial filter panel rendering
|
||||
// Test the initial state like which filters section are opened, default state of duration slider, etc.
|
||||
it('should render the Trace filter', async () => {
|
||||
const { getByText, getAllByText, getByTestId } = render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
`${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/?panelType=list&selectedExplorerView=list`,
|
||||
]}
|
||||
>
|
||||
<Filter setOpen={jest.fn()} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const {
|
||||
getByText,
|
||||
getAllByText,
|
||||
getByTestId,
|
||||
} = renderWithTracesExplorerRouter(<Filter setOpen={jest.fn()} />, [
|
||||
`${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/?panelType=list&selectedExplorerView=list`,
|
||||
]);
|
||||
|
||||
checkFilterValues(getByText, getAllByText);
|
||||
|
||||
@ -249,8 +231,12 @@ describe('TracesExplorer - Filters', () => {
|
||||
it('filter panel actions', async () => {
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter>
|
||||
<Filter setOpen={jest.fn()} />
|
||||
<Filter setOpen={jest.fn()} />,
|
||||
</MemoryRouter>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||
},
|
||||
);
|
||||
|
||||
// Check if the section is closed
|
||||
@ -275,23 +261,21 @@ describe('TracesExplorer - Filters', () => {
|
||||
});
|
||||
|
||||
it('checking filters should update the query', async () => {
|
||||
const { getByText } = renderWithTracesExplorerRouter(
|
||||
<QueryBuilderContext.Provider
|
||||
value={
|
||||
{
|
||||
currentQuery: {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [initialQueryBuilderFormValues],
|
||||
},
|
||||
const { getByText } = render(
|
||||
<Filter setOpen={jest.fn()} />,
|
||||
{},
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
...qbProviderValue,
|
||||
currentQuery: {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [initialQueryBuilderFormValues],
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any
|
||||
}
|
||||
>
|
||||
<Filter setOpen={jest.fn()} />
|
||||
</QueryBuilderContext.Provider>,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const okCheckbox = getByText('Ok');
|
||||
@ -343,9 +327,7 @@ describe('TracesExplorer - Filters', () => {
|
||||
.spyOn(compositeQueryHook, 'useGetCompositeQueryParam')
|
||||
.mockReturnValue(compositeQuery);
|
||||
|
||||
const { findByText, getByTestId } = renderWithTracesExplorerRouter(
|
||||
<Filter setOpen={jest.fn()} />,
|
||||
);
|
||||
const { findByText, getByTestId } = render(<Filter setOpen={jest.fn()} />);
|
||||
|
||||
// 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();
|
||||
@ -369,8 +351,12 @@ describe('TracesExplorer - Filters', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText, getAllByText } = renderWithTracesExplorerRouter(
|
||||
const { getByText, getAllByText } = render(
|
||||
<Filter setOpen={jest.fn()} />,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||
},
|
||||
);
|
||||
|
||||
checkFilterValues(getByText, getAllByText);
|
||||
@ -394,31 +380,28 @@ describe('TracesExplorer - Filters', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText, getAllByText } = renderWithTracesExplorerRouter(
|
||||
<Filter setOpen={jest.fn()} />,
|
||||
);
|
||||
const { getByText, getAllByText } = render(<Filter setOpen={jest.fn()} />);
|
||||
|
||||
checkFilterValues(getByText, getAllByText);
|
||||
});
|
||||
|
||||
it('should clear filter on clear & reset button click', async () => {
|
||||
const { getByText, getByTestId } = renderWithTracesExplorerRouter(
|
||||
<QueryBuilderContext.Provider
|
||||
value={
|
||||
{
|
||||
currentQuery: {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [initialQueryBuilderFormValues],
|
||||
},
|
||||
const { getByText, getByTestId } = render(
|
||||
<Filter setOpen={jest.fn()} />,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [initialQueryBuilderFormValues],
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any
|
||||
}
|
||||
>
|
||||
<Filter setOpen={jest.fn()} />
|
||||
</QueryBuilderContext.Provider>,
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// check for the status section content
|
||||
|
||||
@ -55,7 +55,7 @@ describe('WorkspaceLocked', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(<WorkspaceLocked />, {}, 'VIEWER');
|
||||
render(<WorkspaceLocked />, {}, { role: 'VIEWER' });
|
||||
const updateCreditCardBtn = await screen.queryByRole('button', {
|
||||
name: /Continue My Journey/i,
|
||||
});
|
||||
|
||||
@ -269,7 +269,7 @@ export function DashboardProvider({
|
||||
return data;
|
||||
};
|
||||
const dashboardResponse = useQuery(
|
||||
[REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params],
|
||||
[REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params, dashboardId],
|
||||
{
|
||||
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
|
||||
queryFn: async () => {
|
||||
|
||||
292
frontend/src/providers/Dashboard/__tests__/Dashboard.test.tsx
Normal file
292
frontend/src/providers/Dashboard/__tests__/Dashboard.test.tsx
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
frontend/src/tests/README.md
Normal file
93
frontend/src/tests/README.md
Normal 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.
|
||||
@ -2,18 +2,20 @@
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import {
|
||||
QueryBuilderContext,
|
||||
QueryBuilderProvider,
|
||||
} from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
@ -23,24 +25,27 @@ import {
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
// import { MemoryRouter as V5MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-10-20'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
jest.useRealTimers();
|
||||
// jest.useRealTimers();
|
||||
});
|
||||
|
||||
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(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
@ -253,48 +240,96 @@ export function getAppContextMock(
|
||||
|
||||
export function AllTheProviders({
|
||||
children,
|
||||
role, // Accept the role as a prop
|
||||
role,
|
||||
appContextOverrides,
|
||||
queryBuilderOverrides,
|
||||
initialRoute,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
role: string; // Define the role prop
|
||||
appContextOverrides: Partial<IAppContext>;
|
||||
role?: string;
|
||||
appContextOverrides?: Partial<IAppContext>;
|
||||
queryBuilderOverrides?: Partial<QueryBuilderContextType>;
|
||||
initialRoute?: string;
|
||||
}): 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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={mockStored(role)}>
|
||||
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
|
||||
<ResourceProvider>
|
||||
<ErrorModalProvider>
|
||||
<BrowserRouter>
|
||||
<MemoryRouter initialEntries={[initialRouteValue]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={mockStored(roleValue)}>
|
||||
<AppContext.Provider
|
||||
value={getAppContextMock(roleValue, appContextOverridesValue)}
|
||||
>
|
||||
<ResourceProvider>
|
||||
<ErrorModalProvider>
|
||||
<TimezoneProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>{children}</QueryBuilderProvider>
|
||||
{queryBuilderContent}
|
||||
</PreferenceContextProvider>
|
||||
</TimezoneProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorModalProvider>
|
||||
</ResourceProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</ErrorModalProvider>
|
||||
</ResourceProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
AllTheProviders.defaultProps = {
|
||||
role: 'ADMIN',
|
||||
appContextOverrides: {},
|
||||
queryBuilderOverrides: undefined,
|
||||
initialRoute: '/',
|
||||
};
|
||||
|
||||
interface ProviderProps {
|
||||
role?: string;
|
||||
appContextOverrides?: Partial<IAppContext>;
|
||||
queryBuilderOverrides?: Partial<QueryBuilderContextType>;
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
const customRender = (
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>,
|
||||
role = 'ADMIN', // Set a default role
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
): RenderResult =>
|
||||
render(ui, {
|
||||
providerProps: ProviderProps = {},
|
||||
): RenderResult => {
|
||||
const {
|
||||
role = 'ADMIN',
|
||||
appContextOverrides = {},
|
||||
queryBuilderOverrides,
|
||||
initialRoute = '/',
|
||||
} = providerProps;
|
||||
|
||||
return render(ui, {
|
||||
wrapper: () => (
|
||||
<AllTheProviders role={role} appContextOverrides={appContextOverrides || {}}>
|
||||
<AllTheProviders
|
||||
role={role}
|
||||
appContextOverrides={appContextOverrides}
|
||||
queryBuilderOverrides={queryBuilderOverrides}
|
||||
initialRoute={initialRoute}
|
||||
>
|
||||
{ui}
|
||||
</AllTheProviders>
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { default as userEvent } from '@testing-library/user-event';
|
||||
export { customRender as render };
|
||||
|
||||
@ -16122,6 +16122,13 @@ robust-predicates@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
||||
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:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
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(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
|
||||
}
|
||||
|
||||
@ -194,3 +194,40 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ type role
|
||||
|
||||
type organisation
|
||||
relations
|
||||
define admin: [role#assignee]
|
||||
define editor: [role#assignee] or admin
|
||||
define viewer: [role#assignee] or editor
|
||||
define create: [role#assignee]
|
||||
define read: [role#assignee]
|
||||
define update: [role#assignee]
|
||||
define delete: [role#assignee]
|
||||
@ -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, relation string) http.HandlerFunc {
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ authtypes.Typeable, _ authtypes.SelectorCallbackFn) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
checkRequestTupleKey := authtypes.NewTuple("", "", "")
|
||||
err := middleware.authzService.Check(req.Context(), checkRequestTupleKey)
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
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 {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
_ "net/http/pprof" // http profiler
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
@ -308,20 +310,24 @@ func makeRulesManager(
|
||||
querier querier.Querier,
|
||||
logger *slog.Logger,
|
||||
) (*rules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
// create manager opts
|
||||
managerOpts := &rules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
Prometheus: prometheus,
|
||||
Context: context.Background(),
|
||||
Logger: zap.L(),
|
||||
Reader: ch,
|
||||
Querier: querier,
|
||||
SLogger: logger,
|
||||
Cache: cache,
|
||||
EvalDelay: constants.GetEvalDelay(),
|
||||
SQLStore: sqlstore,
|
||||
OrgGetter: orgGetter,
|
||||
Alertmanager: alertmanager,
|
||||
TelemetryStore: telemetryStore,
|
||||
Prometheus: prometheus,
|
||||
Context: context.Background(),
|
||||
Logger: zap.L(),
|
||||
Reader: ch,
|
||||
Querier: querier,
|
||||
SLogger: logger,
|
||||
Cache: cache,
|
||||
EvalDelay: constants.GetEvalDelay(),
|
||||
OrgGetter: orgGetter,
|
||||
Alertmanager: alertmanager,
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SqlStore: sqlstore,
|
||||
}
|
||||
|
||||
// create Manager
|
||||
|
||||
@ -22,7 +22,6 @@ import (
|
||||
querierV5 "github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
"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/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@ -98,8 +97,10 @@ type ManagerOptions struct {
|
||||
PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error)
|
||||
PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError)
|
||||
Alertmanager alertmanager.Alertmanager
|
||||
SQLStore sqlstore.SQLStore
|
||||
OrgGetter organization.Getter
|
||||
RuleStore ruletypes.RuleStore
|
||||
MaintenanceStore ruletypes.MaintenanceStore
|
||||
SqlStore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
// The Manager manages recording and alerting rules.
|
||||
@ -207,14 +208,12 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
// by calling the Run method.
|
||||
func NewManager(o *ManagerOptions) (*Manager, error) {
|
||||
o = defaultOptions(o)
|
||||
ruleStore := sqlrulestore.NewRuleStore(o.SQLStore)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(o.SQLStore)
|
||||
|
||||
m := &Manager{
|
||||
tasks: map[string]Task{},
|
||||
rules: map[string]Rule{},
|
||||
ruleStore: ruleStore,
|
||||
maintenanceStore: maintenanceStore,
|
||||
ruleStore: o.RuleStore,
|
||||
maintenanceStore: o.MaintenanceStore,
|
||||
opts: o,
|
||||
block: make(chan struct{}),
|
||||
logger: o.Logger,
|
||||
@ -223,8 +222,8 @@ func NewManager(o *ManagerOptions) (*Manager, error) {
|
||||
prepareTaskFunc: o.PrepareTaskFunc,
|
||||
prepareTestRuleFunc: o.PrepareTestRuleFunc,
|
||||
alertmanager: o.Alertmanager,
|
||||
sqlstore: o.SQLStore,
|
||||
orgGetter: o.OrgGetter,
|
||||
sqlstore: o.SqlStore,
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@ -896,33 +895,37 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// storedRule holds the current stored rule from DB
|
||||
patchedRule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(ruleStr), &patchedRule); err != nil {
|
||||
zap.L().Error("failed to unmarshal stored rule with given id", zap.String("id", id.StringValue()), zap.Error(err))
|
||||
storedRule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
|
||||
zap.L().Error("failed to unmarshal rule from db", 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
|
||||
}
|
||||
|
||||
// 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))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// prepare rule json to write to update db
|
||||
patchedRuleBytes, err := json.Marshal(patchedRule)
|
||||
newStoredJson, err := json.Marshal(&storedRule)
|
||||
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
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
storedJSON.Data = string(patchedRuleBytes)
|
||||
storedJSON.Data = string(newStoredJson)
|
||||
storedJSON.UpdatedBy = claims.Email
|
||||
storedJSON.UpdatedAt = now
|
||||
|
||||
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return 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))
|
||||
}
|
||||
return nil, err
|
||||
@ -931,7 +934,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
// prepare http response
|
||||
response := ruletypes.GettableRule{
|
||||
Id: id.StringValue(),
|
||||
PostableRule: patchedRule,
|
||||
PostableRule: storedRule,
|
||||
}
|
||||
|
||||
// fetch state of rule from memory
|
||||
|
||||
610
pkg/query-service/rules/manager_test.go
Normal file
610
pkg/query-service/rules/manager_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
110
pkg/ruler/rulestore/rulestoretest/rule.go
Normal file
110
pkg/ruler/rulestore/rulestoretest/rule.go
Normal 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()
|
||||
}
|
||||
@ -142,6 +142,36 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
},
|
||||
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()
|
||||
|
||||
27
pkg/types/authtypes/name.go
Normal file
27
pkg/types/authtypes/name.go
Normal 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
|
||||
}
|
||||
23
pkg/types/authtypes/organization.go
Normal file
23
pkg/types/authtypes/organization.go
Normal 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
|
||||
}
|
||||
23
pkg/types/authtypes/relation.go
Normal file
23
pkg/types/authtypes/relation.go
Normal 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 }
|
||||
37
pkg/types/authtypes/resource.go
Normal file
37
pkg/types/authtypes/resource.go
Normal 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
|
||||
}
|
||||
26
pkg/types/authtypes/resources.go
Normal file
26
pkg/types/authtypes/resources.go
Normal 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
|
||||
}
|
||||
31
pkg/types/authtypes/role.go
Normal file
31
pkg/types/authtypes/role.go
Normal 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
|
||||
}
|
||||
62
pkg/types/authtypes/selector.go
Normal file
62
pkg/types/authtypes/selector.go
Normal 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
|
||||
}
|
||||
18
pkg/types/authtypes/subject.go
Normal file
18
pkg/types/authtypes/subject.go
Normal 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
|
||||
}
|
||||
@ -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}
|
||||
}
|
||||
36
pkg/types/authtypes/typeable.go
Normal file
36
pkg/types/authtypes/typeable.go
Normal 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
Loading…
x
Reference in New Issue
Block a user