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"
|
||||||
_ "net/http/pprof" // http profiler
|
_ "net/http/pprof" // http profiler
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||||
|
|
||||||
"github.com/gorilla/handlers"
|
"github.com/gorilla/handlers"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
||||||
@ -334,6 +336,8 @@ func makeRulesManager(
|
|||||||
querier querier.Querier,
|
querier querier.Querier,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) (*baserules.Manager, error) {
|
) (*baserules.Manager, error) {
|
||||||
|
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
|
||||||
|
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||||
// create manager opts
|
// create manager opts
|
||||||
managerOpts := &baserules.ManagerOptions{
|
managerOpts := &baserules.ManagerOptions{
|
||||||
TelemetryStore: telemetryStore,
|
TelemetryStore: telemetryStore,
|
||||||
@ -348,8 +352,10 @@ func makeRulesManager(
|
|||||||
PrepareTaskFunc: rules.PrepareTaskFunc,
|
PrepareTaskFunc: rules.PrepareTaskFunc,
|
||||||
PrepareTestRuleFunc: rules.TestNotification,
|
PrepareTestRuleFunc: rules.TestNotification,
|
||||||
Alertmanager: alertmanager,
|
Alertmanager: alertmanager,
|
||||||
SQLStore: sqlstore,
|
|
||||||
OrgGetter: orgGetter,
|
OrgGetter: orgGetter,
|
||||||
|
RuleStore: ruleStore,
|
||||||
|
MaintenanceStore: maintenanceStore,
|
||||||
|
SqlStore: sqlstore,
|
||||||
}
|
}
|
||||||
|
|
||||||
// create Manager
|
// create Manager
|
||||||
|
|||||||
484
frontend/.cursorrules
Normal file
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';
|
import type { Config } from '@jest/types';
|
||||||
|
|
||||||
|
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||||
|
|
||||||
const config: Config.InitialOptions = {
|
const config: Config.InitialOptions = {
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
@ -10,6 +12,10 @@ const config: Config.InitialOptions = {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
||||||
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
|
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
|
||||||
|
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
|
||||||
|
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||||
|
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||||
|
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||||
},
|
},
|
||||||
globals: {
|
globals: {
|
||||||
extensionsToTreatAsEsm: ['.ts'],
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
|||||||
@ -139,6 +139,7 @@
|
|||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"rehype-raw": "7.0.0",
|
"rehype-raw": "7.0.0",
|
||||||
|
"rrule": "2.8.1",
|
||||||
"stream": "^0.0.2",
|
"stream": "^0.0.2",
|
||||||
"style-loader": "1.3.0",
|
"style-loader": "1.3.0",
|
||||||
"styled-components": "^5.3.11",
|
"styled-components": "^5.3.11",
|
||||||
|
|||||||
@ -119,7 +119,9 @@ const filterAndSortTimezones = (
|
|||||||
return createTimezoneEntry(normalizedTz, offset);
|
return createTimezoneEntry(normalizedTz, offset);
|
||||||
});
|
});
|
||||||
|
|
||||||
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
|
export const generateTimezoneData = (
|
||||||
|
includeEtcTimezones = false,
|
||||||
|
): Timezone[] => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
|
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
|
||||||
const timezones: Timezone[] = [];
|
const timezones: Timezone[] = [];
|
||||||
|
|||||||
@ -19,20 +19,6 @@ beforeAll(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('react-dnd', () => ({
|
jest.mock('react-dnd', () => ({
|
||||||
useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
|
useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
|
||||||
useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
|
useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||||
import APIError from 'types/api/error';
|
import APIError from 'types/api/error';
|
||||||
|
|
||||||
import ErrorModal from './ErrorModal';
|
import ErrorModal from './ErrorModal';
|
||||||
@ -56,9 +56,8 @@ describe('ErrorModal Component', () => {
|
|||||||
|
|
||||||
// Click the close button
|
// Click the close button
|
||||||
const closeButton = screen.getByTestId('close-button');
|
const closeButton = screen.getByTestId('close-button');
|
||||||
act(() => {
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
fireEvent.click(closeButton);
|
await user.click(closeButton);
|
||||||
});
|
|
||||||
|
|
||||||
// Check if onClose was called
|
// Check if onClose was called
|
||||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||||
@ -149,9 +148,8 @@ it('should open the modal when the trigger component is clicked', async () => {
|
|||||||
|
|
||||||
// Click the trigger component
|
// Click the trigger component
|
||||||
const triggerButton = screen.getByText('Open Error Modal');
|
const triggerButton = screen.getByText('Open Error Modal');
|
||||||
act(() => {
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
fireEvent.click(triggerButton);
|
await user.click(triggerButton);
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the modal is displayed
|
// Check if the modal is displayed
|
||||||
expect(screen.getByText('An error occurred')).toBeInTheDocument();
|
expect(screen.getByText('An error occurred')).toBeInTheDocument();
|
||||||
@ -170,18 +168,15 @@ it('should close the modal when the onCancel event is triggered', async () => {
|
|||||||
|
|
||||||
// Click the trigger component
|
// Click the trigger component
|
||||||
const triggerButton = screen.getByText('error');
|
const triggerButton = screen.getByText('error');
|
||||||
act(() => {
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
fireEvent.click(triggerButton);
|
await user.click(triggerButton);
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('An error occurred')).toBeInTheDocument();
|
expect(screen.getByText('An error occurred')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger the onCancel event
|
// Trigger the onCancel event
|
||||||
act(() => {
|
await user.click(screen.getByTestId('close-button'));
|
||||||
fireEvent.click(screen.getByTestId('close-button'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the modal is closed
|
// Check if the modal is closed
|
||||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
|||||||
filterConfig,
|
filterConfig,
|
||||||
isDynamicFilters,
|
isDynamicFilters,
|
||||||
customFilters,
|
customFilters,
|
||||||
setIsStale,
|
refetchCustomFilters,
|
||||||
isCustomFiltersLoading,
|
isCustomFiltersLoading,
|
||||||
} = useFilterConfig({ signal, config });
|
} = useFilterConfig({ signal, config });
|
||||||
|
|
||||||
@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
|||||||
signal={signal}
|
signal={signal}
|
||||||
setIsSettingsOpen={setIsSettingsOpen}
|
setIsSettingsOpen={setIsSettingsOpen}
|
||||||
customFilters={customFilters}
|
customFilters={customFilters}
|
||||||
setIsStale={setIsStale}
|
refetchCustomFilters={refetchCustomFilters}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,12 +14,12 @@ function QuickFiltersSettings({
|
|||||||
signal,
|
signal,
|
||||||
setIsSettingsOpen,
|
setIsSettingsOpen,
|
||||||
customFilters,
|
customFilters,
|
||||||
setIsStale,
|
refetchCustomFilters,
|
||||||
}: {
|
}: {
|
||||||
signal: SignalType | undefined;
|
signal: SignalType | undefined;
|
||||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||||
customFilters: FilterType[];
|
customFilters: FilterType[];
|
||||||
setIsStale: (isStale: boolean) => void;
|
refetchCustomFilters: () => void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const {
|
const {
|
||||||
handleSettingsClose,
|
handleSettingsClose,
|
||||||
@ -34,7 +34,7 @@ function QuickFiltersSettings({
|
|||||||
} = useQuickFilterSettings({
|
} = useQuickFilterSettings({
|
||||||
setIsSettingsOpen,
|
setIsSettingsOpen,
|
||||||
customFilters,
|
customFilters,
|
||||||
setIsStale,
|
refetchCustomFilters,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
|||||||
interface UseQuickFilterSettingsProps {
|
interface UseQuickFilterSettingsProps {
|
||||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||||
customFilters: FilterType[];
|
customFilters: FilterType[];
|
||||||
setIsStale: (isStale: boolean) => void;
|
refetchCustomFilters: () => void;
|
||||||
signal?: SignalType;
|
signal?: SignalType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn {
|
|||||||
const useQuickFilterSettings = ({
|
const useQuickFilterSettings = ({
|
||||||
customFilters,
|
customFilters,
|
||||||
setIsSettingsOpen,
|
setIsSettingsOpen,
|
||||||
setIsStale,
|
refetchCustomFilters,
|
||||||
signal,
|
signal,
|
||||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||||
const [inputValue, setInputValue] = useState<string>('');
|
const [inputValue, setInputValue] = useState<string>('');
|
||||||
@ -46,7 +46,7 @@ const useQuickFilterSettings = ({
|
|||||||
} = useMutation(updateCustomFiltersAPI, {
|
} = useMutation(updateCustomFiltersAPI, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIsSettingsOpen(false);
|
setIsSettingsOpen(false);
|
||||||
setIsStale(true);
|
refetchCustomFilters();
|
||||||
logEvent('Quick Filters Settings: changes saved', {
|
logEvent('Quick Filters Settings: changes saved', {
|
||||||
addedFilters,
|
addedFilters,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||||
import {
|
|
||||||
Filter as FilterType,
|
|
||||||
PayloadProps,
|
|
||||||
} from 'types/api/quickFilters/getCustomFilters';
|
|
||||||
|
|
||||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||||
import { getFilterConfig } from '../utils';
|
import { getFilterConfig } from '../utils';
|
||||||
@ -18,37 +14,34 @@ interface UseFilterConfigProps {
|
|||||||
interface UseFilterConfigReturn {
|
interface UseFilterConfigReturn {
|
||||||
filterConfig: IQuickFiltersConfig[];
|
filterConfig: IQuickFiltersConfig[];
|
||||||
customFilters: FilterType[];
|
customFilters: FilterType[];
|
||||||
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
|
||||||
isCustomFiltersLoading: boolean;
|
isCustomFiltersLoading: boolean;
|
||||||
isDynamicFilters: boolean;
|
isDynamicFilters: boolean;
|
||||||
setIsStale: React.Dispatch<React.SetStateAction<boolean>>;
|
refetchCustomFilters: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useFilterConfig = ({
|
const useFilterConfig = ({
|
||||||
signal,
|
signal,
|
||||||
config,
|
config,
|
||||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||||
const [customFilters, setCustomFilters] = useState<FilterType[]>([]);
|
const {
|
||||||
const [isStale, setIsStale] = useState(true);
|
isFetching: isCustomFiltersLoading,
|
||||||
|
data: customFilters = [],
|
||||||
|
refetch,
|
||||||
|
} = useQuery<FilterType[], Error>(
|
||||||
|
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
|
||||||
|
async () => {
|
||||||
|
const res = await getCustomFilters({ signal: signal || '' });
|
||||||
|
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const isDynamicFilters = useMemo(() => customFilters.length > 0, [
|
const isDynamicFilters = useMemo(() => customFilters.length > 0, [
|
||||||
customFilters,
|
customFilters,
|
||||||
]);
|
]);
|
||||||
const { isFetching: isCustomFiltersLoading } = useQuery<
|
|
||||||
SuccessResponse<PayloadProps> | ErrorResponse,
|
|
||||||
Error
|
|
||||||
>(
|
|
||||||
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
|
|
||||||
() => getCustomFilters({ signal: signal || '' }),
|
|
||||||
{
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if ('payload' in data && data.payload?.filters) {
|
|
||||||
setCustomFilters(data.payload.filters || ([] as FilterType[]));
|
|
||||||
}
|
|
||||||
setIsStale(false);
|
|
||||||
},
|
|
||||||
enabled: !!signal && isStale,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const filterConfig = useMemo(
|
const filterConfig = useMemo(
|
||||||
() => getFilterConfig(signal, customFilters, config),
|
() => getFilterConfig(signal, customFilters, config),
|
||||||
[config, customFilters, signal],
|
[config, customFilters, signal],
|
||||||
@ -57,10 +50,9 @@ const useFilterConfig = ({
|
|||||||
return {
|
return {
|
||||||
filterConfig,
|
filterConfig,
|
||||||
customFilters,
|
customFilters,
|
||||||
setCustomFilters,
|
|
||||||
isCustomFiltersLoading,
|
isCustomFiltersLoading,
|
||||||
isDynamicFilters,
|
isDynamicFilters,
|
||||||
setIsStale,
|
refetchCustomFilters: refetch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,6 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import { ENVIRONMENT } from 'constants/env';
|
import { ENVIRONMENT } from 'constants/env';
|
||||||
import ROUTES from 'constants/routes';
|
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import {
|
import {
|
||||||
otherFiltersResponse,
|
otherFiltersResponse,
|
||||||
@ -18,8 +9,7 @@ import {
|
|||||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||||
import { server } from 'mocks-server/server';
|
import { server } from 'mocks-server/server';
|
||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||||
import { USER_ROLES } from 'types/roles';
|
|
||||||
|
|
||||||
import QuickFilters from '../QuickFilters';
|
import QuickFilters from '../QuickFilters';
|
||||||
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
|
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
|
||||||
@ -29,21 +19,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
|||||||
useQueryBuilder: jest.fn(),
|
useQueryBuilder: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useLocation: (): { pathname: string } => ({
|
|
||||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const userRole = USER_ROLES.ADMIN;
|
|
||||||
|
|
||||||
// mock useAppContext
|
|
||||||
jest.mock('providers/App/App', () => ({
|
|
||||||
useAppContext: jest.fn(() => ({ user: { role: userRole } })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleFilterVisibilityChange = jest.fn();
|
const handleFilterVisibilityChange = jest.fn();
|
||||||
const redirectWithQueryBuilderData = jest.fn();
|
const redirectWithQueryBuilderData = jest.fn();
|
||||||
const putHandler = jest.fn();
|
const putHandler = jest.fn();
|
||||||
@ -78,11 +53,10 @@ const setupServer = (): void => {
|
|||||||
putHandler(await req.json());
|
putHandler(await req.json());
|
||||||
return res(ctx.status(200), ctx.json({}));
|
return res(ctx.status(200), ctx.json({}));
|
||||||
}),
|
}),
|
||||||
|
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
|
||||||
rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) =>
|
|
||||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||||
),
|
),
|
||||||
rest.get(fieldsValuesURL, (req, res, ctx) =>
|
rest.get(fieldsValuesURL, (_req, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -96,14 +70,12 @@ function TestQuickFilters({
|
|||||||
config?: IQuickFiltersConfig[];
|
config?: IQuickFiltersConfig[];
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<MockQueryClientProvider>
|
|
||||||
<QuickFilters
|
<QuickFilters
|
||||||
source={QuickFiltersSource.EXCEPTIONS}
|
source={QuickFiltersSource.EXCEPTIONS}
|
||||||
config={config}
|
config={config}
|
||||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||||
signal={signal}
|
signal={signal}
|
||||||
/>
|
/>
|
||||||
</MockQueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,11 +90,11 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
server.close();
|
server.close();
|
||||||
cleanup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -151,9 +123,13 @@ describe('Quick Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add filter data to query when checkbox is clicked', async () => {
|
it('should add filter data to query when checkbox is clicked', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
render(<TestQuickFilters />);
|
render(<TestQuickFilters />);
|
||||||
const checkbox = screen.getByText('mq-kafka');
|
|
||||||
fireEvent.click(checkbox);
|
// Prefer role if possible; if label text isn’t wired to input, clicking the label text is OK
|
||||||
|
const target = await screen.findByText('mq-kafka');
|
||||||
|
await user.click(target);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
@ -182,16 +158,20 @@ describe('Quick Filters', () => {
|
|||||||
|
|
||||||
describe('Quick Filters with custom filters', () => {
|
describe('Quick Filters with custom filters', () => {
|
||||||
it('loads the custom filters correctly', async () => {
|
it('loads the custom filters correctly', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
render(<TestQuickFilters signal={SIGNAL} />);
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
|
|
||||||
expect(screen.getByText('Filters for')).toBeInTheDocument();
|
expect(screen.getByText('Filters for')).toBeInTheDocument();
|
||||||
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
|
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
|
||||||
|
|
||||||
await screen.findByText(FILTER_SERVICE_NAME);
|
await screen.findByText(FILTER_SERVICE_NAME);
|
||||||
const allByText = await screen.findAllByText('otel-demo');
|
const allByText = await screen.findAllByText('otel-demo');
|
||||||
// since 2 filter collapse are open, there are 2 filter items visible
|
|
||||||
expect(allByText).toHaveLength(2);
|
expect(allByText).toHaveLength(2);
|
||||||
|
|
||||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||||
fireEvent.click(icon);
|
const settingsButton = icon.closest('button') ?? icon;
|
||||||
|
await user.click(settingsButton);
|
||||||
|
|
||||||
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
|
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
|
||||||
|
|
||||||
@ -207,16 +187,19 @@ describe('Quick Filters with custom filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
render(<TestQuickFilters signal={SIGNAL} />);
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
await screen.findByText(FILTER_SERVICE_NAME);
|
await screen.findByText(FILTER_SERVICE_NAME);
|
||||||
|
|
||||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||||
fireEvent.click(icon);
|
const settingsButton = icon.closest('button') ?? icon;
|
||||||
|
await user.click(settingsButton);
|
||||||
|
|
||||||
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
|
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
|
||||||
const addButton = otherFilterItem.parentElement?.querySelector('button');
|
const addButton = otherFilterItem.parentElement?.querySelector('button');
|
||||||
expect(addButton).not.toBeNull();
|
expect(addButton).not.toBeNull();
|
||||||
fireEvent.click(addButton as HTMLButtonElement);
|
await user.click(addButton as HTMLButtonElement);
|
||||||
|
|
||||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -225,17 +208,21 @@ describe('Quick Filters with custom filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
|
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
render(<TestQuickFilters signal={SIGNAL} />);
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
await screen.findByText(FILTER_SERVICE_NAME);
|
await screen.findByText(FILTER_SERVICE_NAME);
|
||||||
|
|
||||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||||
fireEvent.click(icon);
|
const settingsButton = icon.closest('button') ?? icon;
|
||||||
|
await user.click(settingsButton);
|
||||||
|
|
||||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||||
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||||
const removeBtn = target.parentElement?.querySelector('button');
|
const removeBtn = target.parentElement?.querySelector('button');
|
||||||
expect(removeBtn).not.toBeNull();
|
expect(removeBtn).not.toBeNull();
|
||||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
|
||||||
|
await user.click(removeBtn as HTMLButtonElement);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(addedSection).not.toContainElement(
|
expect(addedSection).not.toContainElement(
|
||||||
@ -250,17 +237,20 @@ describe('Quick Filters with custom filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('restores original filter state on Discard', async () => {
|
it('restores original filter state on Discard', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
render(<TestQuickFilters signal={SIGNAL} />);
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
await screen.findByText(FILTER_SERVICE_NAME);
|
await screen.findByText(FILTER_SERVICE_NAME);
|
||||||
|
|
||||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||||
fireEvent.click(icon);
|
const settingsButton = icon.closest('button') ?? icon;
|
||||||
|
await user.click(settingsButton);
|
||||||
|
|
||||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||||
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||||
const removeBtn = target.parentElement?.querySelector('button');
|
const removeBtn = target.parentElement?.querySelector('button');
|
||||||
expect(removeBtn).not.toBeNull();
|
expect(removeBtn).not.toBeNull();
|
||||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
await user.click(removeBtn as HTMLButtonElement);
|
||||||
|
|
||||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -272,7 +262,11 @@ describe('Quick Filters with custom filters', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(DISCARD_TEXT));
|
const discardBtn = screen
|
||||||
|
.getByText(DISCARD_TEXT)
|
||||||
|
.closest('button') as HTMLButtonElement;
|
||||||
|
expect(discardBtn).not.toBeNull();
|
||||||
|
await user.click(discardBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(addedSection).toContainElement(
|
expect(addedSection).toContainElement(
|
||||||
@ -285,18 +279,25 @@ describe('Quick Filters with custom filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('saves the updated filters by calling PUT with correct payload', async () => {
|
it('saves the updated filters by calling PUT with correct payload', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
render(<TestQuickFilters signal={SIGNAL} />);
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
await screen.findByText(FILTER_SERVICE_NAME);
|
await screen.findByText(FILTER_SERVICE_NAME);
|
||||||
|
|
||||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||||
fireEvent.click(icon);
|
const settingsButton = icon.closest('button') ?? icon;
|
||||||
|
await user.click(settingsButton);
|
||||||
|
|
||||||
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||||
const removeBtn = target.parentElement?.querySelector('button');
|
const removeBtn = target.parentElement?.querySelector('button');
|
||||||
expect(removeBtn).not.toBeNull();
|
expect(removeBtn).not.toBeNull();
|
||||||
fireEvent.click(removeBtn as HTMLButtonElement);
|
await user.click(removeBtn as HTMLButtonElement);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT));
|
const saveBtn = screen
|
||||||
|
.getByText(SAVE_CHANGES_TEXT)
|
||||||
|
.closest('button') as HTMLButtonElement;
|
||||||
|
expect(saveBtn).not.toBeNull();
|
||||||
|
await user.click(saveBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(putHandler).toHaveBeenCalled();
|
expect(putHandler).toHaveBeenCalled();
|
||||||
@ -311,31 +312,36 @@ describe('Quick Filters with custom filters', () => {
|
|||||||
expect(requestBody.signal).toBe(SIGNAL);
|
expect(requestBody.signal).toBe(SIGNAL);
|
||||||
});
|
});
|
||||||
|
|
||||||
// render duration filter
|
|
||||||
it('should render duration slider for duration_nono filter', async () => {
|
it('should render duration slider for duration_nono filter', async () => {
|
||||||
// Set up fake timers **before rendering**
|
// Use fake timers only in this test (for debounce), and wire them to userEvent
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
const user = userEvent.setup({
|
||||||
|
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||||
|
pointerEventsCheck: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
|
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
await screen.findByText(FILTER_SERVICE_NAME);
|
await screen.findByText(FILTER_SERVICE_NAME);
|
||||||
expect(screen.getByText('Duration')).toBeInTheDocument();
|
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||||
|
|
||||||
// click to open the duration filter
|
// Open the duration section (use role if it’s a button/collapse)
|
||||||
fireEvent.click(screen.getByText('Duration'));
|
await user.click(screen.getByText('Duration'));
|
||||||
|
|
||||||
const minDuration = getByTestId('min-input') as HTMLInputElement;
|
const minDuration = getByTestId('min-input') as HTMLInputElement;
|
||||||
const maxDuration = getByTestId('max-input') as HTMLInputElement;
|
const maxDuration = getByTestId('max-input') as HTMLInputElement;
|
||||||
|
|
||||||
expect(minDuration).toHaveValue(null);
|
expect(minDuration).toHaveValue(null);
|
||||||
expect(minDuration).toHaveProperty('placeholder', '0');
|
expect(minDuration).toHaveProperty('placeholder', '0');
|
||||||
expect(maxDuration).toHaveValue(null);
|
expect(maxDuration).toHaveValue(null);
|
||||||
expect(maxDuration).toHaveProperty('placeholder', '100000000');
|
expect(maxDuration).toHaveProperty('placeholder', '100000000');
|
||||||
|
|
||||||
await act(async () => {
|
// Type values and advance debounce
|
||||||
// set values
|
await user.clear(minDuration);
|
||||||
fireEvent.change(minDuration, { target: { value: '10000' } });
|
await user.type(minDuration, '10000');
|
||||||
fireEvent.change(maxDuration, { target: { value: '20000' } });
|
await user.clear(maxDuration);
|
||||||
|
await user.type(maxDuration, '20000');
|
||||||
jest.advanceTimersByTime(2000);
|
jest.advanceTimersByTime(2000);
|
||||||
});
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -363,6 +369,144 @@ describe('Quick Filters with custom filters', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.useRealTimers(); // Clean up
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Quick Filters refetch behavior', () => {
|
||||||
|
it('fetches custom filters on every mount when signal is provided', async () => {
|
||||||
|
let getCalls = 0;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(quickFiltersListURL, (_req, res, ctx) => {
|
||||||
|
getCalls += 1;
|
||||||
|
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
|
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
|
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(getCalls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch custom filters when signal is undefined', async () => {
|
||||||
|
let getCalls = 0;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(quickFiltersListURL, (_req, res, ctx) => {
|
||||||
|
getCalls += 1;
|
||||||
|
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TestQuickFilters signal={undefined} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(getCalls).toBe(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches custom filters after saving settings', async () => {
|
||||||
|
let getCalls = 0;
|
||||||
|
putHandler.mockClear();
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
rest.get(quickFiltersListURL, (_req, res, ctx) => {
|
||||||
|
getCalls += 1;
|
||||||
|
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
|
||||||
|
}),
|
||||||
|
rest.put(saveQuickFiltersURL, async (req, res, ctx) => {
|
||||||
|
putHandler(await req.json());
|
||||||
|
return res(ctx.status(200), ctx.json({}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||||
|
const settingsButton = icon.closest('button') ?? icon;
|
||||||
|
await user.click(settingsButton);
|
||||||
|
|
||||||
|
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||||
|
const removeBtn = target.parentElement?.querySelector(
|
||||||
|
'button',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await user.click(removeBtn);
|
||||||
|
|
||||||
|
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
|
||||||
|
|
||||||
|
await waitFor(() => expect(putHandler).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(getCalls).toBeGreaterThanOrEqual(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders updated filters after refetch post-save', async () => {
|
||||||
|
const updatedResponse = {
|
||||||
|
...quickFiltersListResponse,
|
||||||
|
data: {
|
||||||
|
...quickFiltersListResponse.data,
|
||||||
|
filters: [
|
||||||
|
...(quickFiltersListResponse.data.filters ?? []),
|
||||||
|
{
|
||||||
|
key: 'new.custom.filter',
|
||||||
|
dataType: 'string',
|
||||||
|
type: 'resource',
|
||||||
|
} as const,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let getCount = 0;
|
||||||
|
server.use(
|
||||||
|
rest.get(quickFiltersListURL, (_req, res, ctx) => {
|
||||||
|
getCount += 1;
|
||||||
|
return getCount >= 2
|
||||||
|
? res(ctx.status(200), ctx.json(updatedResponse))
|
||||||
|
: res(ctx.status(200), ctx.json(quickFiltersListResponse));
|
||||||
|
}),
|
||||||
|
rest.put(saveQuickFiltersURL, async (_req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json({})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
render(<TestQuickFilters signal={SIGNAL} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||||
|
const settingsButton = icon.closest('button') ?? icon;
|
||||||
|
await user.click(settingsButton);
|
||||||
|
|
||||||
|
// Make a minimal change so Save button appears
|
||||||
|
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
|
||||||
|
const removeBtn = target.parentElement?.querySelector(
|
||||||
|
'button',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await user.click(removeBtn);
|
||||||
|
|
||||||
|
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('New Custom Filter')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when GET fails', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(quickFiltersListURL, (_req, res, ctx) =>
|
||||||
|
res(ctx.status(500), ctx.json({})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('No filters found')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,6 +22,8 @@ jest.mock('react-router-dom', () => ({
|
|||||||
|
|
||||||
describe('Alert Channels Settings List page', () => {
|
describe('Alert Channels Settings List page', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-10-20'));
|
||||||
render(<AlertChannels />);
|
render(<AlertChannels />);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||||
@ -29,6 +31,7 @@ describe('Alert Channels Settings List page', () => {
|
|||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
describe('Should display the Alert Channels page properly', () => {
|
describe('Should display the Alert Channels page properly', () => {
|
||||||
it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => {
|
it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ jest.mock('react-router-dom', () => ({
|
|||||||
|
|
||||||
describe('Alert Channels Settings List page (Normal User)', () => {
|
describe('Alert Channels Settings List page (Normal User)', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
render(<AlertChannels />);
|
render(<AlertChannels />);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||||
@ -35,6 +36,7 @@ describe('Alert Channels Settings List page (Normal User)', () => {
|
|||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
describe('Should display the Alert Channels page properly', () => {
|
describe('Should display the Alert Channels page properly', () => {
|
||||||
it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => {
|
it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => {
|
||||||
|
|||||||
@ -9,22 +9,6 @@ import { getFormattedDate } from 'utils/timeUtils';
|
|||||||
|
|
||||||
import BillingContainer from './BillingContainer';
|
import BillingContainer from './BillingContainer';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
window.ResizeObserver =
|
window.ResizeObserver =
|
||||||
window.ResizeObserver ||
|
window.ResizeObserver ||
|
||||||
jest.fn().mockImplementation(() => ({
|
jest.fn().mockImplementation(() => ({
|
||||||
@ -67,45 +51,63 @@ describe('BillingContainer', () => {
|
|||||||
expect(currentBill).toBeInTheDocument();
|
expect(currentBill).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Trial scenarios', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-10-20'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
test('OnTrail', async () => {
|
test('OnTrail', async () => {
|
||||||
await act(async () => {
|
// Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining"
|
||||||
render(<BillingContainer />, undefined, undefined, {
|
|
||||||
trialInfo: licensesSuccessResponse.data,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const freeTrailText = await screen.findByText('Free Trial');
|
render(
|
||||||
expect(freeTrailText).toBeInTheDocument();
|
<BillingContainer />,
|
||||||
|
{},
|
||||||
const currentBill = await screen.findByText('billing');
|
{ appContextOverrides: { trialInfo: licensesSuccessResponse.data } },
|
||||||
expect(currentBill).toBeInTheDocument();
|
|
||||||
|
|
||||||
const dollar0 = await screen.findByText(/\$0/i);
|
|
||||||
expect(dollar0).toBeInTheDocument();
|
|
||||||
const onTrail = await screen.findByText(
|
|
||||||
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
|
|
||||||
);
|
);
|
||||||
expect(onTrail).toBeInTheDocument();
|
|
||||||
|
|
||||||
const numberOfDayRemaining = await screen.findByText(/1 days_remaining/i);
|
// If the component schedules any setTimeout on mount, flush them:
|
||||||
expect(numberOfDayRemaining).toBeInTheDocument();
|
jest.runOnlyPendingTimers();
|
||||||
const upgradeButton = await screen.findAllByRole('button', {
|
|
||||||
|
expect(await screen.findByText('Free Trial')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('billing')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/\$0/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const upgradeButtons = await screen.findAllByRole('button', {
|
||||||
name: /upgrade_plan/i,
|
name: /upgrade_plan/i,
|
||||||
});
|
});
|
||||||
expect(upgradeButton[1]).toBeInTheDocument();
|
expect(upgradeButtons).toHaveLength(2);
|
||||||
expect(upgradeButton.length).toBe(2);
|
expect(upgradeButtons[1]).toBeInTheDocument();
|
||||||
const checkPaidPlan = await screen.findByText(/checkout_plans/i);
|
|
||||||
expect(checkPaidPlan).toBeInTheDocument();
|
|
||||||
|
|
||||||
const link = await screen.findByRole('link', { name: /here/i });
|
expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument();
|
||||||
expect(link).toBeInTheDocument();
|
expect(
|
||||||
|
await screen.findByRole('link', { name: /here/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('OnTrail but trialConvertedToSubscription', async () => {
|
test('OnTrail but trialConvertedToSubscription', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(<BillingContainer />, undefined, undefined, {
|
render(
|
||||||
|
<BillingContainer />,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
appContextOverrides: {
|
||||||
trialInfo: trialConvertedToSubscriptionResponse.data,
|
trialInfo: trialConvertedToSubscriptionResponse.data,
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentBill = await screen.findByText('billing');
|
const currentBill = await screen.findByText('billing');
|
||||||
@ -134,11 +136,18 @@ describe('BillingContainer', () => {
|
|||||||
);
|
);
|
||||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('Not on ontrail', async () => {
|
test('Not on ontrail', async () => {
|
||||||
const { findByText } = render(<BillingContainer />, undefined, undefined, {
|
const { findByText } = render(
|
||||||
|
<BillingContainer />,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
appContextOverrides: {
|
||||||
trialInfo: notOfTrailResponse.data,
|
trialInfo: notOfTrailResponse.data,
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
|
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
|
||||||
billingSuccessResponse.data.billingPeriodStart,
|
billingSuccessResponse.data.billingPeriodStart,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||||
import CreateAlertPage from 'pages/CreateAlert';
|
import CreateAlertPage from 'pages/CreateAlert';
|
||||||
import { MemoryRouter, Route } from 'react-router-dom';
|
|
||||||
import { act, fireEvent, render } from 'tests/test-utils';
|
import { act, fireEvent, render } from 'tests/test-utils';
|
||||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||||
|
|
||||||
@ -14,20 +13,6 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('hooks/useSafeNavigate', () => ({
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
useSafeNavigate: (): any => ({
|
useSafeNavigate: (): any => ({
|
||||||
safeNavigate: jest.fn(),
|
safeNavigate: jest.fn(),
|
||||||
@ -84,11 +69,11 @@ describe('Alert rule documentation redirection', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
act(() => {
|
act(() => {
|
||||||
renderResult = render(
|
renderResult = render(
|
||||||
<MemoryRouter initialEntries={['/alerts/new']}>
|
<CreateAlertPage />,
|
||||||
<Route path={ROUTES.ALERTS_NEW}>
|
{},
|
||||||
<CreateAlertPage />
|
{
|
||||||
</Route>
|
initialRoute: ROUTES.ALERTS_NEW,
|
||||||
</MemoryRouter>,
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,20 +15,6 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
window.ResizeObserver =
|
window.ResizeObserver =
|
||||||
window.ResizeObserver ||
|
window.ResizeObserver ||
|
||||||
jest.fn().mockImplementation(() => ({
|
jest.fn().mockImplementation(() => ({
|
||||||
|
|||||||
@ -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 { Color } from '@signozhq/design-tokens';
|
||||||
|
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||||
|
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/constants';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import getRandomColor from 'lib/getRandomColor';
|
import getRandomColor from 'lib/getRandomColor';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AdvancedOptionsState,
|
||||||
AlertState,
|
AlertState,
|
||||||
AlertThresholdMatchType,
|
AlertThresholdMatchType,
|
||||||
AlertThresholdOperator,
|
AlertThresholdOperator,
|
||||||
AlertThresholdState,
|
AlertThresholdState,
|
||||||
Algorithm,
|
Algorithm,
|
||||||
|
EvaluationWindowState,
|
||||||
Seasonality,
|
Seasonality,
|
||||||
Threshold,
|
Threshold,
|
||||||
TimeDuration,
|
TimeDuration,
|
||||||
@ -70,6 +75,49 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
|||||||
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||||
|
sendNotificationIfDataIsMissing: {
|
||||||
|
toleranceLimit: 15,
|
||||||
|
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||||
|
},
|
||||||
|
enforceMinimumDatapoints: {
|
||||||
|
minimumDatapoints: 0,
|
||||||
|
},
|
||||||
|
delayEvaluation: {
|
||||||
|
delay: 5,
|
||||||
|
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||||
|
},
|
||||||
|
evaluationCadence: {
|
||||||
|
mode: 'default',
|
||||||
|
default: {
|
||||||
|
value: 1,
|
||||||
|
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
repeatEvery: 'week',
|
||||||
|
startAt: '00:00:00',
|
||||||
|
occurence: [],
|
||||||
|
timezone: TIMEZONE_DATA[0].value,
|
||||||
|
},
|
||||||
|
rrule: {
|
||||||
|
date: dayjs(),
|
||||||
|
startAt: '00:00:00',
|
||||||
|
rrule: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
|
||||||
|
windowType: 'rolling',
|
||||||
|
timeframe: '5m0s',
|
||||||
|
startingAt: {
|
||||||
|
time: '00:00:00',
|
||||||
|
number: '1',
|
||||||
|
timezone: TIMEZONE_DATA[0].value,
|
||||||
|
unit: UniversalYAxisUnit.MINUTES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const THRESHOLD_OPERATOR_OPTIONS = [
|
export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||||
@ -115,3 +163,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
|
|||||||
{ value: Seasonality.DAILY, label: 'Daily' },
|
{ value: Seasonality.DAILY, label: 'Daily' },
|
||||||
{ value: Seasonality.WEEKLY, label: 'Weekly' },
|
{ value: Seasonality.WEEKLY, label: 'Weekly' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
||||||
|
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
|
||||||
|
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
|
||||||
|
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
|
||||||
|
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
|
||||||
|
];
|
||||||
|
|||||||
@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||||
INITIAL_ALERT_STATE,
|
INITIAL_ALERT_STATE,
|
||||||
INITIAL_ALERT_THRESHOLD_STATE,
|
INITIAL_ALERT_THRESHOLD_STATE,
|
||||||
|
INITIAL_EVALUATION_WINDOW_STATE,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||||
import {
|
import {
|
||||||
|
advancedOptionsReducer,
|
||||||
alertCreationReducer,
|
alertCreationReducer,
|
||||||
alertThresholdReducer,
|
alertThresholdReducer,
|
||||||
buildInitialAlertDef,
|
buildInitialAlertDef,
|
||||||
|
evaluationWindowReducer,
|
||||||
getInitialAlertTypeFromURL,
|
getInitialAlertTypeFromURL,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
@ -80,6 +84,16 @@ export function CreateAlertProvider(
|
|||||||
INITIAL_ALERT_THRESHOLD_STATE,
|
INITIAL_ALERT_THRESHOLD_STATE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [evaluationWindow, setEvaluationWindow] = useReducer(
|
||||||
|
evaluationWindowReducer,
|
||||||
|
INITIAL_EVALUATION_WINDOW_STATE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [advancedOptions, setAdvancedOptions] = useReducer(
|
||||||
|
advancedOptionsReducer,
|
||||||
|
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setThresholdState({
|
setThresholdState({
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
@ -94,8 +108,19 @@ export function CreateAlertProvider(
|
|||||||
setAlertType: handleAlertTypeChange,
|
setAlertType: handleAlertTypeChange,
|
||||||
thresholdState,
|
thresholdState,
|
||||||
setThresholdState,
|
setThresholdState,
|
||||||
|
evaluationWindow,
|
||||||
|
setEvaluationWindow,
|
||||||
|
advancedOptions,
|
||||||
|
setAdvancedOptions,
|
||||||
}),
|
}),
|
||||||
[alertState, alertType, handleAlertTypeChange, thresholdState],
|
[
|
||||||
|
alertState,
|
||||||
|
alertType,
|
||||||
|
handleAlertTypeChange,
|
||||||
|
thresholdState,
|
||||||
|
evaluationWindow,
|
||||||
|
advancedOptions,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Dayjs } from 'dayjs';
|
||||||
import { Dispatch } from 'react';
|
import { Dispatch } from 'react';
|
||||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||||
import { Labels } from 'types/api/alerts/def';
|
import { Labels } from 'types/api/alerts/def';
|
||||||
@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
|
|||||||
setAlertType: Dispatch<AlertTypes>;
|
setAlertType: Dispatch<AlertTypes>;
|
||||||
thresholdState: AlertThresholdState;
|
thresholdState: AlertThresholdState;
|
||||||
setThresholdState: Dispatch<AlertThresholdAction>;
|
setThresholdState: Dispatch<AlertThresholdAction>;
|
||||||
|
advancedOptions: AdvancedOptionsState;
|
||||||
|
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||||
|
evaluationWindow: EvaluationWindowState;
|
||||||
|
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICreateAlertProviderProps {
|
export interface ICreateAlertProviderProps {
|
||||||
@ -101,3 +106,87 @@ export type AlertThresholdAction =
|
|||||||
| { type: 'SET_SEASONALITY'; payload: string }
|
| { type: 'SET_SEASONALITY'; payload: string }
|
||||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||||
| { type: 'RESET' };
|
| { type: 'RESET' };
|
||||||
|
|
||||||
|
export interface AdvancedOptionsState {
|
||||||
|
sendNotificationIfDataIsMissing: {
|
||||||
|
toleranceLimit: number;
|
||||||
|
timeUnit: string;
|
||||||
|
};
|
||||||
|
enforceMinimumDatapoints: {
|
||||||
|
minimumDatapoints: number;
|
||||||
|
};
|
||||||
|
delayEvaluation: {
|
||||||
|
delay: number;
|
||||||
|
timeUnit: string;
|
||||||
|
};
|
||||||
|
evaluationCadence: {
|
||||||
|
mode: EvaluationCadenceMode;
|
||||||
|
default: {
|
||||||
|
value: number;
|
||||||
|
timeUnit: string;
|
||||||
|
};
|
||||||
|
custom: {
|
||||||
|
repeatEvery: string;
|
||||||
|
startAt: string;
|
||||||
|
occurence: string[];
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
rrule: {
|
||||||
|
date: Dayjs | null;
|
||||||
|
startAt: string;
|
||||||
|
rrule: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdvancedOptionsAction =
|
||||||
|
| {
|
||||||
|
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||||
|
payload: { toleranceLimit: number; timeUnit: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
|
||||||
|
payload: { minimumDatapoints: number };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'SET_DELAY_EVALUATION';
|
||||||
|
payload: { delay: number; timeUnit: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'SET_EVALUATION_CADENCE';
|
||||||
|
payload: {
|
||||||
|
default: { value: number; timeUnit: string };
|
||||||
|
custom: {
|
||||||
|
repeatEvery: string;
|
||||||
|
startAt: string;
|
||||||
|
timezone: string;
|
||||||
|
occurence: string[];
|
||||||
|
};
|
||||||
|
rrule: { date: Dayjs | null; startAt: string; rrule: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||||
|
| { type: 'RESET' };
|
||||||
|
|
||||||
|
export interface EvaluationWindowState {
|
||||||
|
windowType: 'rolling' | 'cumulative';
|
||||||
|
timeframe: string;
|
||||||
|
startingAt: {
|
||||||
|
time: string;
|
||||||
|
number: string;
|
||||||
|
timezone: string;
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvaluationWindowAction =
|
||||||
|
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
|
||||||
|
| { type: 'SET_TIMEFRAME'; payload: string }
|
||||||
|
| {
|
||||||
|
type: 'SET_STARTING_AT';
|
||||||
|
payload: { time: string; number: string; timezone: string; unit: string };
|
||||||
|
}
|
||||||
|
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||||
|
| { type: 'RESET' };
|
||||||
|
|
||||||
|
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||||
|
|||||||
@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
|
|||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
|
|
||||||
import {
|
import {
|
||||||
|
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||||
|
INITIAL_ALERT_THRESHOLD_STATE,
|
||||||
|
INITIAL_EVALUATION_WINDOW_STATE,
|
||||||
|
} from './constants';
|
||||||
|
import {
|
||||||
|
AdvancedOptionsAction,
|
||||||
|
AdvancedOptionsState,
|
||||||
AlertState,
|
AlertState,
|
||||||
AlertThresholdAction,
|
AlertThresholdAction,
|
||||||
AlertThresholdState,
|
AlertThresholdState,
|
||||||
CreateAlertAction,
|
CreateAlertAction,
|
||||||
|
EvaluationWindowAction,
|
||||||
|
EvaluationWindowState,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const alertCreationReducer = (
|
export const alertCreationReducer = (
|
||||||
@ -110,3 +118,57 @@ export const alertThresholdReducer = (
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const advancedOptionsReducer = (
|
||||||
|
state: AdvancedOptionsState,
|
||||||
|
action: AdvancedOptionsAction,
|
||||||
|
): AdvancedOptionsState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
|
||||||
|
return { ...state, sendNotificationIfDataIsMissing: action.payload };
|
||||||
|
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
|
||||||
|
return { ...state, enforceMinimumDatapoints: action.payload };
|
||||||
|
case 'SET_DELAY_EVALUATION':
|
||||||
|
return { ...state, delayEvaluation: action.payload };
|
||||||
|
case 'SET_EVALUATION_CADENCE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
|
||||||
|
};
|
||||||
|
case 'SET_EVALUATION_CADENCE_MODE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
||||||
|
};
|
||||||
|
case 'RESET':
|
||||||
|
return INITIAL_ADVANCED_OPTIONS_STATE;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const evaluationWindowReducer = (
|
||||||
|
state: EvaluationWindowState,
|
||||||
|
action: EvaluationWindowAction,
|
||||||
|
): EvaluationWindowState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_WINDOW_TYPE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
windowType: action.payload,
|
||||||
|
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||||
|
timeframe:
|
||||||
|
action.payload === 'rolling'
|
||||||
|
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
|
||||||
|
: 'currentHour',
|
||||||
|
};
|
||||||
|
case 'SET_TIMEFRAME':
|
||||||
|
return { ...state, timeframe: action.payload };
|
||||||
|
case 'SET_STARTING_AT':
|
||||||
|
return { ...state, startingAt: action.payload };
|
||||||
|
case 'RESET':
|
||||||
|
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -30,20 +30,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const mockProps: WidgetGraphComponentProps = {
|
const mockProps: WidgetGraphComponentProps = {
|
||||||
widget: {
|
widget: {
|
||||||
|
|||||||
@ -33,19 +33,6 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
|
|||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
jest.mock('react-redux', () => ({
|
jest.mock('react-redux', () => ({
|
||||||
...jest.requireActual('react-redux'),
|
...jest.requireActual('react-redux'),
|
||||||
useSelector: (): any => ({
|
useSelector: (): any => ({
|
||||||
|
|||||||
@ -3,20 +3,6 @@ import { render, screen } from '@testing-library/react';
|
|||||||
|
|
||||||
import HostsListTable from '../HostsListTable';
|
import HostsListTable from '../HostsListTable';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
|
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
|
||||||
|
|
||||||
describe('HostsListTable', () => {
|
describe('HostsListTable', () => {
|
||||||
|
|||||||
@ -4,20 +4,6 @@ import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils';
|
|||||||
|
|
||||||
const PROGRESS_BAR_CLASS = '.progress-bar';
|
const PROGRESS_BAR_CLASS = '.progress-bar';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('InfraMonitoringHosts utils', () => {
|
describe('InfraMonitoringHosts utils', () => {
|
||||||
describe('formatDataForTable', () => {
|
describe('formatDataForTable', () => {
|
||||||
it('should format host data correctly', () => {
|
it('should format host data correctly', () => {
|
||||||
|
|||||||
@ -44,20 +44,6 @@ const verifyEntityLogsPayload = ({
|
|||||||
return queryData;
|
return queryData;
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'components/OverlayScrollbar/OverlayScrollbar',
|
'components/OverlayScrollbar/OverlayScrollbar',
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@ -4,14 +4,8 @@ import setupCommonMocks from '../../commonMocks';
|
|||||||
|
|
||||||
setupCommonMocks();
|
setupCommonMocks();
|
||||||
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails';
|
import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails';
|
||||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import store from 'store';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
describe('JobDetails', () => {
|
describe('JobDetails', () => {
|
||||||
const mockJob = {
|
const mockJob = {
|
||||||
@ -24,13 +18,7 @@ describe('JobDetails', () => {
|
|||||||
|
|
||||||
it('should render modal with relevant metadata', () => {
|
it('should render modal with relevant metadata', () => {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||||
<Provider store={store}>
|
|
||||||
<MemoryRouter>
|
|
||||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
|
||||||
</MemoryRouter>
|
|
||||||
</Provider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const jobNameElements = screen.getAllByText('test-job');
|
const jobNameElements = screen.getAllByText('test-job');
|
||||||
@ -44,13 +32,7 @@ describe('JobDetails', () => {
|
|||||||
|
|
||||||
it('should render modal with 4 tabs', () => {
|
it('should render modal with 4 tabs', () => {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||||
<Provider store={store}>
|
|
||||||
<MemoryRouter>
|
|
||||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
|
||||||
</MemoryRouter>
|
|
||||||
</Provider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const metricsTab = screen.getByText('Metrics');
|
const metricsTab = screen.getByText('Metrics');
|
||||||
@ -68,13 +50,7 @@ describe('JobDetails', () => {
|
|||||||
|
|
||||||
it('default tab should be metrics', () => {
|
it('default tab should be metrics', () => {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||||
<Provider store={store}>
|
|
||||||
<MemoryRouter>
|
|
||||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
|
||||||
</MemoryRouter>
|
|
||||||
</Provider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||||
@ -83,13 +59,7 @@ describe('JobDetails', () => {
|
|||||||
|
|
||||||
it('should switch to events tab when events tab is clicked', () => {
|
it('should switch to events tab when events tab is clicked', () => {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||||
<Provider store={store}>
|
|
||||||
<MemoryRouter>
|
|
||||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
|
||||||
</MemoryRouter>
|
|
||||||
</Provider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||||
@ -100,13 +70,7 @@ describe('JobDetails', () => {
|
|||||||
|
|
||||||
it('should close modal when close button is clicked', () => {
|
it('should close modal when close button is clicked', () => {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
|
||||||
<Provider store={store}>
|
|
||||||
<MemoryRouter>
|
|
||||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
|
||||||
</MemoryRouter>
|
|
||||||
</Provider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||||
|
|||||||
@ -56,19 +56,6 @@ const setupCommonMocks = (): void => {
|
|||||||
useNavigationType: (): any => 'PUSH',
|
useNavigationType: (): any => 'PUSH',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('hooks/useUrlQuery', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: jest.fn(() => ({
|
|
||||||
set: jest.fn(),
|
|
||||||
delete: jest.fn(),
|
|
||||||
get: jest.fn(),
|
|
||||||
has: jest.fn(),
|
|
||||||
entries: jest.fn(() => []),
|
|
||||||
append: jest.fn(),
|
|
||||||
toString: jest.fn(() => ''),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('lib/getMinMax', () => ({
|
jest.mock('lib/getMinMax', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: jest.fn().mockImplementation(() => ({
|
default: jest.fn().mockImplementation(() => ({
|
||||||
|
|||||||
@ -32,20 +32,6 @@ import {
|
|||||||
// Mock the useContextLogData hook
|
// Mock the useContextLogData hook
|
||||||
const mockHandleRunQuery = jest.fn();
|
const mockHandleRunQuery = jest.fn();
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('container/OptionsMenu', () => ({
|
jest.mock('container/OptionsMenu', () => ({
|
||||||
useOptionsMenu: (): any => ({
|
useOptionsMenu: (): any => ({
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@ -73,20 +73,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
|
jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
useGetExplorerQueryRange: jest.fn(),
|
useGetExplorerQueryRange: jest.fn(),
|
||||||
|
|||||||
@ -7,18 +7,16 @@ import { logsresponse } from 'mocks-server/__mockdata__/query_range';
|
|||||||
import { server } from 'mocks-server/server';
|
import { server } from 'mocks-server/server';
|
||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
import LogsExplorer from 'pages/LogsExplorer';
|
import LogsExplorer from 'pages/LogsExplorer';
|
||||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
|
||||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
|
||||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
import i18n from 'ReactI18';
|
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
|
AllTheProviders,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
RenderResult,
|
RenderResult,
|
||||||
screen,
|
screen,
|
||||||
|
userEvent,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from 'tests/test-utils';
|
} from 'tests/test-utils';
|
||||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
@ -91,20 +89,6 @@ getStateSpy.mockImplementation(() => {
|
|||||||
return originalState;
|
return originalState;
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('react-router-dom'),
|
...jest.requireActual('react-router-dom'),
|
||||||
useLocation: (): { search: string; pathname: string } => ({
|
useLocation: (): { search: string; pathname: string } => ({
|
||||||
@ -277,9 +261,7 @@ describe.skip('LogsExplorerViews Pagination', () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
renderResult = render(
|
renderResult = render(
|
||||||
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight }}>
|
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight }}>
|
||||||
<I18nextProvider i18n={i18n}>
|
|
||||||
<LogsExplorer />
|
<LogsExplorer />
|
||||||
</I18nextProvider>
|
|
||||||
</VirtuosoMockContext.Provider>,
|
</VirtuosoMockContext.Provider>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -453,13 +435,14 @@ function LogsExplorerWithMockContext({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemoryRouter>
|
<AllTheProviders
|
||||||
<QueryBuilderContext.Provider value={contextValue as any}>
|
queryBuilderOverrides={contextValue as any}
|
||||||
|
initialRoute="/logs"
|
||||||
|
>
|
||||||
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
|
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
|
||||||
<LogsExplorer />
|
<LogsExplorer />
|
||||||
</VirtuosoMockContext.Provider>
|
</VirtuosoMockContext.Provider>
|
||||||
</QueryBuilderContext.Provider>
|
</AllTheProviders>
|
||||||
</MemoryRouter>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -536,13 +519,12 @@ describe('Logs Explorer -> stage and run query', () => {
|
|||||||
const initialEnd = initialPayload.end;
|
const initialEnd = initialPayload.end;
|
||||||
|
|
||||||
// Click the Stage & Run Query button
|
// Click the Stage & Run Query button
|
||||||
await act(async () => {
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
fireEvent.click(
|
await user.click(
|
||||||
screen.getByRole('button', {
|
screen.getByRole('button', {
|
||||||
name: /stage & run query/i,
|
name: /stage & run query/i,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for additional API calls to be made after clicking Stage & Run Query
|
// Wait for additional API calls to be made after clicking Stage & Run Query
|
||||||
await waitFor(
|
await waitFor(
|
||||||
|
|||||||
@ -33,20 +33,6 @@ const lodsQueryServerRequest = (): void =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// mocking the graph components in this test as this should be handled separately
|
// mocking the graph components in this test as this should be handled separately
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'container/TimeSeriesView/TimeSeriesView',
|
'container/TimeSeriesView/TimeSeriesView',
|
||||||
|
|||||||
@ -18,10 +18,6 @@ const MOCK_SEARCH_PARAMS =
|
|||||||
'?graphType=list&widgetId=36a7b342-c642-4b92-abe4-cb833a244786&compositeQuery=%7B%22id%22%3A%22b325ac88-5e75-4117-a38c-1a2a7caf8115%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateOperator%22%3A%22noop%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22------%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22isColumn%22%3Afalse%2C%22type%22%3A%22%22%2C%22isJSON%22%3Afalse%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3A60%2C%22having%22%3A%5B%5D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%7B%22columnName%22%3A%22timestamp%22%2C%22order%22%3A%22desc%22%7D%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%2C%22offset%22%3A0%2C%22pageSize%22%3A100%7D%5D%2C%22queryFormulas%22%3A%5B%5D%7D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22queryType%22%3A%22builder%22%7D&relativeTime=30m&options=%7B%22selectColumns%22%3A%5B%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22list%22%2C%22fontSize%22%3A%22small%22%7D';
|
'?graphType=list&widgetId=36a7b342-c642-4b92-abe4-cb833a244786&compositeQuery=%7B%22id%22%3A%22b325ac88-5e75-4117-a38c-1a2a7caf8115%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateOperator%22%3A%22noop%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22------%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22isColumn%22%3Afalse%2C%22type%22%3A%22%22%2C%22isJSON%22%3Afalse%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3A60%2C%22having%22%3A%5B%5D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%7B%22columnName%22%3A%22timestamp%22%2C%22order%22%3A%22desc%22%7D%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%2C%22offset%22%3A0%2C%22pageSize%22%3A100%7D%5D%2C%22queryFormulas%22%3A%5B%5D%7D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22queryType%22%3A%22builder%22%7D&relativeTime=30m&options=%7B%22selectColumns%22%3A%5B%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22list%22%2C%22fontSize%22%3A%22small%22%7D';
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
jest.mock('uplot', () => ({
|
|
||||||
paths: { spline: jest.fn(), bars: jest.fn() },
|
|
||||||
default: jest.fn(() => ({ paths: { spline: jest.fn(), bars: jest.fn() } })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
|
|||||||
@ -68,19 +68,6 @@ jest.mock('hooks/useNotifications', () => ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
jest.mock('react-redux', () => ({
|
jest.mock('react-redux', () => ({
|
||||||
...jest.requireActual('react-redux'),
|
...jest.requireActual('react-redux'),
|
||||||
useSelector: (): any => ({
|
useSelector: (): any => ({
|
||||||
|
|||||||
@ -15,12 +15,6 @@ import {
|
|||||||
TimeAggregationOptions,
|
TimeAggregationOptions,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
jest.mock('uplot', () =>
|
|
||||||
jest.fn().mockImplementation(() => ({
|
|
||||||
destroy: jest.fn(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockResizeObserver = jest.fn();
|
const mockResizeObserver = jest.fn();
|
||||||
mockResizeObserver.mockImplementation(() => ({
|
mockResizeObserver.mockImplementation(() => ({
|
||||||
observe: (): void => undefined,
|
observe: (): void => undefined,
|
||||||
|
|||||||
@ -76,12 +76,6 @@ jest
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
jest.mock('uplot', () =>
|
|
||||||
jest.fn().mockImplementation(() => ({
|
|
||||||
destroy: jest.fn(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('react-router-dom'),
|
...jest.requireActual('react-router-dom'),
|
||||||
useLocation: (): { pathname: string } => ({
|
useLocation: (): { pathname: string } => ({
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import * as useSafeNavigate from 'hooks/useSafeNavigate';
|
|
||||||
|
|
||||||
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
|
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
|
||||||
|
|
||||||
@ -24,9 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2];
|
|||||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||||
|
|
||||||
const mockSafeNavigate = jest.fn();
|
const mockSafeNavigate = jest.fn();
|
||||||
jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): any => ({
|
||||||
safeNavigate: mockSafeNavigate,
|
safeNavigate: mockSafeNavigate,
|
||||||
});
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockSetQuery = jest.fn();
|
const mockSetQuery = jest.fn();
|
||||||
const mockUrlQuery = {
|
const mockUrlQuery = {
|
||||||
|
|||||||
@ -11,19 +11,6 @@ import store from 'store';
|
|||||||
import Summary from '../Summary';
|
import Summary from '../Summary';
|
||||||
import { TreemapViewType } from '../types';
|
import { TreemapViewType } from '../types';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
jest.mock('d3-hierarchy', () => ({
|
jest.mock('d3-hierarchy', () => ({
|
||||||
stratify: jest.fn().mockReturnValue({
|
stratify: jest.fn().mockReturnValue({
|
||||||
id: jest.fn().mockReturnValue({
|
id: jest.fn().mockReturnValue({
|
||||||
|
|||||||
@ -10,20 +10,6 @@ import store from 'store';
|
|||||||
import ChangeHistory from '../index';
|
import ChangeHistory from '../index';
|
||||||
import { pipelineData, pipelineDataHistory } from './testUtils';
|
import { pipelineData, pipelineDataHistory } from './testUtils';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
|||||||
@ -5,20 +5,6 @@ import { PipelineData } from 'types/api/pipeline/def';
|
|||||||
import { pipelineMockData } from '../mocks/pipeline';
|
import { pipelineMockData } from '../mocks/pipeline';
|
||||||
import AddNewPipeline from '../PipelineListsView/AddNewPipeline';
|
import AddNewPipeline from '../PipelineListsView/AddNewPipeline';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export function matchMedia(): void {
|
export function matchMedia(): void {
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
@ -11,20 +11,6 @@ jest.mock('../PipelineListsView/AddNewProcessor/config', () => ({
|
|||||||
DEFAULT_PROCESSOR_TYPE: 'json_parser',
|
DEFAULT_PROCESSOR_TYPE: 'json_parser',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedProcessorData = {
|
const selectedProcessorData = {
|
||||||
id: '1',
|
id: '1',
|
||||||
orderId: 1,
|
orderId: 1,
|
||||||
|
|||||||
@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import i18n from 'ReactI18';
|
import i18n from 'ReactI18';
|
||||||
import store from 'store';
|
import store from 'store';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PipelinePage container test', () => {
|
describe('PipelinePage container test', () => {
|
||||||
it('should render DeleteAction section', () => {
|
it('should render DeleteAction section', () => {
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
|
|||||||
@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import i18n from 'ReactI18';
|
import i18n from 'ReactI18';
|
||||||
import store from 'store';
|
import store from 'store';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PipelinePage container test', () => {
|
describe('PipelinePage container test', () => {
|
||||||
it('should render DragAction section', () => {
|
it('should render DragAction section', () => {
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
|
|||||||
@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import i18n from 'ReactI18';
|
import i18n from 'ReactI18';
|
||||||
import store from 'store';
|
import store from 'store';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PipelinePage container test', () => {
|
describe('PipelinePage container test', () => {
|
||||||
it('should render EditAction section', () => {
|
it('should render EditAction section', () => {
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
|
|||||||
@ -8,20 +8,6 @@ import store from 'store';
|
|||||||
import { pipelineMockData } from '../mocks/pipeline';
|
import { pipelineMockData } from '../mocks/pipeline';
|
||||||
import PipelineActions from '../PipelineListsView/TableComponents/PipelineActions';
|
import PipelineActions from '../PipelineListsView/TableComponents/PipelineActions';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PipelinePage container test', () => {
|
describe('PipelinePage container test', () => {
|
||||||
it('should render PipelineActions section', () => {
|
it('should render PipelineActions section', () => {
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
|
|||||||
@ -3,20 +3,6 @@ import { render } from 'tests/test-utils';
|
|||||||
import { pipelineMockData } from '../mocks/pipeline';
|
import { pipelineMockData } from '../mocks/pipeline';
|
||||||
import PipelineExpandView from '../PipelineListsView/PipelineExpandView';
|
import PipelineExpandView from '../PipelineListsView/PipelineExpandView';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
@ -6,20 +6,6 @@ import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
|
|||||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||||
import PipelineListsView from '../PipelineListsView';
|
import PipelineListsView from '../PipelineListsView';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock useUrlQuery hook
|
// Mock useUrlQuery hook
|
||||||
const mockUrlQuery = {
|
const mockUrlQuery = {
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
|
|||||||
@ -4,20 +4,6 @@ import { v4 } from 'uuid';
|
|||||||
|
|
||||||
import PipelinePageLayout from '../Layouts/Pipeline';
|
import PipelinePageLayout from '../Layouts/Pipeline';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
@ -7,20 +7,6 @@ import store from 'store';
|
|||||||
|
|
||||||
import TagInput from '../components/TagInput';
|
import TagInput from '../components/TagInput';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Pipeline Page', () => {
|
describe('Pipeline Page', () => {
|
||||||
it('should render TagInput section', () => {
|
it('should render TagInput section', () => {
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
|
|||||||
@ -1,24 +1,11 @@
|
|||||||
import { render } from '@testing-library/react';
|
|
||||||
import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags';
|
import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { render } from 'tests/test-utils';
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import i18n from 'ReactI18';
|
|
||||||
import store from 'store';
|
|
||||||
|
|
||||||
const tags = ['server', 'app'];
|
const tags = ['server', 'app'];
|
||||||
|
|
||||||
describe('PipelinePage container test', () => {
|
describe('PipelinePage container test', () => {
|
||||||
it('should render Tags section', () => {
|
it('should render Tags section', () => {
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(<Tags tags={tags} />);
|
||||||
<MemoryRouter>
|
|
||||||
<Provider store={store}>
|
|
||||||
<I18nextProvider i18n={i18n}>
|
|
||||||
<Tags tags={tags} />
|
|
||||||
</I18nextProvider>
|
|
||||||
</Provider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,20 +11,6 @@ import {
|
|||||||
getTableColumn,
|
getTableColumn,
|
||||||
} from '../PipelineListsView/utils';
|
} from '../PipelineListsView/utils';
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Utils testing of Pipeline Page', () => {
|
describe('Utils testing of Pipeline Page', () => {
|
||||||
test('it should be check form field of add pipeline', () => {
|
test('it should be check form field of add pipeline', () => {
|
||||||
expect(pipelineFields.length).toBe(3);
|
expect(pipelineFields.length).toBe(3);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { PlannedDowntime } from '../PlannedDowntime';
|
|||||||
|
|
||||||
describe('PlannedDowntime Component', () => {
|
describe('PlannedDowntime Component', () => {
|
||||||
it('renders the PlannedDowntime component properly', () => {
|
it('renders the PlannedDowntime component properly', () => {
|
||||||
render(<PlannedDowntime />, {}, 'ADMIN');
|
render(<PlannedDowntime />, {}, { role: 'ADMIN' });
|
||||||
|
|
||||||
// Check if title is rendered
|
// Check if title is rendered
|
||||||
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
||||||
@ -30,7 +30,7 @@ describe('PlannedDowntime Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('disables the "New downtime" button for users with VIEWER role', () => {
|
it('disables the "New downtime" button for users with VIEWER role', () => {
|
||||||
render(<PlannedDowntime />, {}, USER_ROLES.VIEWER);
|
render(<PlannedDowntime />, {}, { role: USER_ROLES.VIEWER });
|
||||||
|
|
||||||
// Check if "New downtime" button is disabled for VIEWER
|
// Check if "New downtime" button is disabled for VIEWER
|
||||||
const newDowntimeButton = screen.getByRole('button', {
|
const newDowntimeButton = screen.getByRole('button', {
|
||||||
|
|||||||
@ -58,12 +58,16 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: calc(100% - 120px); /* Reserve space for action buttons */
|
max-width: 100%;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
border: 1px solid var(--bg-slate-400);
|
border: 1px solid var(--bg-slate-400);
|
||||||
background: var(--bg-slate-500);
|
background: var(--bg-slate-500);
|
||||||
|
|
||||||
|
.copy-wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.item-value {
|
.item-value {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
font-family: Inter;
|
font-family: Inter;
|
||||||
|
|||||||
@ -97,11 +97,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<div className="value-wrapper">
|
<div className="value-wrapper">
|
||||||
<Tooltip title={item.value}>
|
<Tooltip title={item.value}>
|
||||||
|
<div className="copy-wrapper">
|
||||||
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||||
<Typography.Text className="item-value" ellipsis>
|
<Typography.Text className="item-value" ellipsis>
|
||||||
{item.value}
|
{item.value}
|
||||||
</Typography.Text>{' '}
|
</Typography.Text>
|
||||||
</CopyClipboardHOC>
|
</CopyClipboardHOC>
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<AttributeActions
|
<AttributeActions
|
||||||
record={item}
|
record={item}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 44px); //44px -> trace details top bar
|
height: calc(100vh - 44px); //44px -> trace details top bar
|
||||||
border-left: 1px solid var(--bg-slate-400);
|
border-left: 1px solid var(--bg-slate-400);
|
||||||
overflow-y: auto;
|
overflow-y: auto !important;
|
||||||
&:not(&-docked) {
|
&:not(&-docked) {
|
||||||
min-width: 450px;
|
min-width: 450px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react';
|
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import { render } from 'tests/test-utils';
|
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||||
import { Span } from 'types/api/trace/getTraceV2';
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
|
|
||||||
import { SpanDuration } from '../Success';
|
import { SpanDuration } from '../Success';
|
||||||
@ -15,7 +13,6 @@ const DIMMED_SPAN_CLASS = 'dimmed-span';
|
|||||||
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
||||||
|
|
||||||
// Mock the hooks
|
// Mock the hooks
|
||||||
jest.mock('hooks/useSafeNavigate');
|
|
||||||
jest.mock('hooks/useUrlQuery');
|
jest.mock('hooks/useUrlQuery');
|
||||||
jest.mock('@signozhq/badge', () => ({
|
jest.mock('@signozhq/badge', () => ({
|
||||||
Badge: jest.fn(),
|
Badge: jest.fn(),
|
||||||
@ -52,24 +49,17 @@ const mockTraceMetadata = {
|
|||||||
hasMissingSpans: false,
|
hasMissingSpans: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
const mockSafeNavigate = jest.fn();
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
bars: jest.fn(),
|
useSafeNavigate: (): any => ({
|
||||||
};
|
safeNavigate: mockSafeNavigate,
|
||||||
const uplotMock = jest.fn(() => ({
|
}),
|
||||||
paths,
|
}));
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SpanDuration', () => {
|
describe('SpanDuration', () => {
|
||||||
const mockSetSelectedSpan = jest.fn();
|
const mockSetSelectedSpan = jest.fn();
|
||||||
const mockUrlQuerySet = jest.fn();
|
const mockUrlQuerySet = jest.fn();
|
||||||
const mockSafeNavigate = jest.fn();
|
|
||||||
const mockUrlQueryGet = jest.fn();
|
const mockUrlQueryGet = jest.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -81,11 +71,6 @@ describe('SpanDuration', () => {
|
|||||||
get: mockUrlQueryGet,
|
get: mockUrlQueryGet,
|
||||||
toString: () => 'spanId=test-span-id',
|
toString: () => 'spanId=test-span-id',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock safe navigate hook
|
|
||||||
(useSafeNavigate as jest.Mock).mockReturnValue({
|
|
||||||
safeNavigate: mockSafeNavigate,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates URL and selected span when clicked', () => {
|
it('updates URL and selected span when clicked', () => {
|
||||||
|
|||||||
@ -16,8 +16,6 @@ const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes - data becomes stale after 2 minutes
|
|
||||||
cacheTime: 5 * 60 * 1000, // 5 minutes - cache entries are garbage collected after 5 minutes
|
|
||||||
retry(failureCount, error): boolean {
|
retry(failureCount, error): boolean {
|
||||||
if (
|
if (
|
||||||
// in case of manually throwing errors please make sure to send error.response.status
|
// in case of manually throwing errors please make sure to send error.response.status
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
// src/mocks/server.js
|
// src/mocks/server.js
|
||||||
|
import { rest } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
|
|
||||||
import { handlers } from './handlers';
|
import { handlers } from './handlers';
|
||||||
|
|
||||||
// This configures a request mocking server with the given request handlers.
|
// This configures a request mocking server with the given request handlers.
|
||||||
export const server = setupServer(...handlers);
|
export const server = setupServer(...handlers);
|
||||||
|
|
||||||
|
export { rest };
|
||||||
|
|||||||
@ -28,20 +28,6 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// mocking the graph components in this test as this should be handled separately
|
// mocking the graph components in this test as this should be handled separately
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'container/TimeSeriesView/TimeSeriesView',
|
'container/TimeSeriesView/TimeSeriesView',
|
||||||
|
|||||||
@ -35,21 +35,16 @@ jest.mock('container/TraceFlameGraph/index.tsx', () => ({
|
|||||||
default: (): JSX.Element => <div>TraceFlameGraph</div>,
|
default: (): JSX.Element => <div>TraceFlameGraph</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TraceDetail', () => {
|
describe('TraceDetail', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2023-10-20'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render tracedetail', async () => {
|
it('should render tracedetail', async () => {
|
||||||
const { findByText, getByText, getAllByText, getByPlaceholderText } = render(
|
const { findByText, getByText, getAllByText, getByPlaceholderText } = render(
|
||||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||||
|
|||||||
@ -127,7 +127,11 @@ function TraceDetailsV2(): JSX.Element {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup direction="horizontal" autoSaveId="trace-drawer">
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
autoSaveId="trace-drawer"
|
||||||
|
className="trace-layout"
|
||||||
|
>
|
||||||
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
|
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
|
||||||
<TraceMetadata
|
<TraceMetadata
|
||||||
traceID={traceId}
|
traceID={traceId}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import {
|
|||||||
} from 'mocks-server/__mockdata__/query_range';
|
} from 'mocks-server/__mockdata__/query_range';
|
||||||
import { server } from 'mocks-server/server';
|
import { server } from 'mocks-server/server';
|
||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
|
||||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
@ -97,22 +96,6 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
|
||||||
const paths = {
|
|
||||||
spline: jest.fn(),
|
|
||||||
bars: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const uplotMock = jest.fn(() => ({
|
|
||||||
paths,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
default: uplotMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'components/Uplot/Uplot',
|
'components/Uplot/Uplot',
|
||||||
() =>
|
() =>
|
||||||
@ -181,32 +164,31 @@ const checkFilterValues = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderWithTracesExplorerRouter = (
|
const renderWithTracesExplorerRouter = (
|
||||||
component: React.ReactNode,
|
component: React.ReactElement,
|
||||||
initialEntries: string[] = [
|
initialEntries: string[] = [
|
||||||
'/traces-explorer/?panelType=list&selectedExplorerView=list',
|
'/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||||
],
|
],
|
||||||
): ReturnType<typeof render> =>
|
): ReturnType<typeof render> =>
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={initialEntries}>
|
component,
|
||||||
<QueryBuilderContext.Provider value={{ ...qbProviderValue }}>
|
{},
|
||||||
{component}
|
{
|
||||||
</QueryBuilderContext.Provider>
|
initialRoute: initialEntries[0],
|
||||||
</MemoryRouter>,
|
queryBuilderOverrides: qbProviderValue,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('TracesExplorer - Filters', () => {
|
describe('TracesExplorer - Filters', () => {
|
||||||
// Initial filter panel rendering
|
// Initial filter panel rendering
|
||||||
// Test the initial state like which filters section are opened, default state of duration slider, etc.
|
// Test the initial state like which filters section are opened, default state of duration slider, etc.
|
||||||
it('should render the Trace filter', async () => {
|
it('should render the Trace filter', async () => {
|
||||||
const { getByText, getAllByText, getByTestId } = render(
|
const {
|
||||||
<MemoryRouter
|
getByText,
|
||||||
initialEntries={[
|
getAllByText,
|
||||||
|
getByTestId,
|
||||||
|
} = renderWithTracesExplorerRouter(<Filter setOpen={jest.fn()} />, [
|
||||||
`${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/?panelType=list&selectedExplorerView=list`,
|
`${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/?panelType=list&selectedExplorerView=list`,
|
||||||
]}
|
]);
|
||||||
>
|
|
||||||
<Filter setOpen={jest.fn()} />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
checkFilterValues(getByText, getAllByText);
|
checkFilterValues(getByText, getAllByText);
|
||||||
|
|
||||||
@ -249,8 +231,12 @@ describe('TracesExplorer - Filters', () => {
|
|||||||
it('filter panel actions', async () => {
|
it('filter panel actions', async () => {
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Filter setOpen={jest.fn()} />
|
<Filter setOpen={jest.fn()} />,
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if the section is closed
|
// Check if the section is closed
|
||||||
@ -275,10 +261,12 @@ describe('TracesExplorer - Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('checking filters should update the query', async () => {
|
it('checking filters should update the query', async () => {
|
||||||
const { getByText } = renderWithTracesExplorerRouter(
|
const { getByText } = render(
|
||||||
<QueryBuilderContext.Provider
|
<Filter setOpen={jest.fn()} />,
|
||||||
value={
|
{},
|
||||||
{
|
{
|
||||||
|
queryBuilderOverrides: {
|
||||||
|
...qbProviderValue,
|
||||||
currentQuery: {
|
currentQuery: {
|
||||||
...initialQueriesMap.traces,
|
...initialQueriesMap.traces,
|
||||||
builder: {
|
builder: {
|
||||||
@ -286,12 +274,8 @@ describe('TracesExplorer - Filters', () => {
|
|||||||
queryData: [initialQueryBuilderFormValues],
|
queryData: [initialQueryBuilderFormValues],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
redirectWithQueryBuilderData,
|
},
|
||||||
} as any
|
},
|
||||||
}
|
|
||||||
>
|
|
||||||
<Filter setOpen={jest.fn()} />
|
|
||||||
</QueryBuilderContext.Provider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const okCheckbox = getByText('Ok');
|
const okCheckbox = getByText('Ok');
|
||||||
@ -343,9 +327,7 @@ describe('TracesExplorer - Filters', () => {
|
|||||||
.spyOn(compositeQueryHook, 'useGetCompositeQueryParam')
|
.spyOn(compositeQueryHook, 'useGetCompositeQueryParam')
|
||||||
.mockReturnValue(compositeQuery);
|
.mockReturnValue(compositeQuery);
|
||||||
|
|
||||||
const { findByText, getByTestId } = renderWithTracesExplorerRouter(
|
const { findByText, getByTestId } = render(<Filter setOpen={jest.fn()} />);
|
||||||
<Filter setOpen={jest.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// check if the default query is applied - composite query has filters - serviceName : demo-app and name : HTTP GET /customer
|
// check if the default query is applied - composite query has filters - serviceName : demo-app and name : HTTP GET /customer
|
||||||
expect(await findByText('demo-app')).toBeInTheDocument();
|
expect(await findByText('demo-app')).toBeInTheDocument();
|
||||||
@ -369,8 +351,12 @@ describe('TracesExplorer - Filters', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText, getAllByText } = renderWithTracesExplorerRouter(
|
const { getByText, getAllByText } = render(
|
||||||
<Filter setOpen={jest.fn()} />,
|
<Filter setOpen={jest.fn()} />,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
checkFilterValues(getByText, getAllByText);
|
checkFilterValues(getByText, getAllByText);
|
||||||
@ -394,18 +380,18 @@ describe('TracesExplorer - Filters', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText, getAllByText } = renderWithTracesExplorerRouter(
|
const { getByText, getAllByText } = render(<Filter setOpen={jest.fn()} />);
|
||||||
<Filter setOpen={jest.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
checkFilterValues(getByText, getAllByText);
|
checkFilterValues(getByText, getAllByText);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear filter on clear & reset button click', async () => {
|
it('should clear filter on clear & reset button click', async () => {
|
||||||
const { getByText, getByTestId } = renderWithTracesExplorerRouter(
|
const { getByText, getByTestId } = render(
|
||||||
<QueryBuilderContext.Provider
|
<Filter setOpen={jest.fn()} />,
|
||||||
value={
|
{},
|
||||||
{
|
{
|
||||||
|
initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list',
|
||||||
|
queryBuilderOverrides: {
|
||||||
currentQuery: {
|
currentQuery: {
|
||||||
...initialQueriesMap.traces,
|
...initialQueriesMap.traces,
|
||||||
builder: {
|
builder: {
|
||||||
@ -414,11 +400,8 @@ describe('TracesExplorer - Filters', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
redirectWithQueryBuilderData,
|
redirectWithQueryBuilderData,
|
||||||
} as any
|
},
|
||||||
}
|
},
|
||||||
>
|
|
||||||
<Filter setOpen={jest.fn()} />
|
|
||||||
</QueryBuilderContext.Provider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// check for the status section content
|
// check for the status section content
|
||||||
|
|||||||
@ -55,7 +55,7 @@ describe('WorkspaceLocked', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<WorkspaceLocked />, {}, 'VIEWER');
|
render(<WorkspaceLocked />, {}, { role: 'VIEWER' });
|
||||||
const updateCreditCardBtn = await screen.queryByRole('button', {
|
const updateCreditCardBtn = await screen.queryByRole('button', {
|
||||||
name: /Continue My Journey/i,
|
name: /Continue My Journey/i,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -269,7 +269,7 @@ export function DashboardProvider({
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
const dashboardResponse = useQuery(
|
const dashboardResponse = useQuery(
|
||||||
[REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params],
|
[REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params, dashboardId],
|
||||||
{
|
{
|
||||||
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
|
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
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 { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||||
import { FeatureKeys } from 'constants/features';
|
import { FeatureKeys } from 'constants/features';
|
||||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||||
import ROUTES from 'constants/routes';
|
|
||||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||||
import { AppContext } from 'providers/App/App';
|
import { AppContext } from 'providers/App/App';
|
||||||
import { IAppContext } from 'providers/App/types';
|
import { IAppContext } from 'providers/App/types';
|
||||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
import {
|
||||||
|
QueryBuilderContext,
|
||||||
|
QueryBuilderProvider,
|
||||||
|
} from 'providers/QueryBuilder';
|
||||||
import TimezoneProvider from 'providers/Timezone';
|
import TimezoneProvider from 'providers/Timezone';
|
||||||
import React, { ReactElement } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
import thunk from 'redux-thunk';
|
import thunk from 'redux-thunk';
|
||||||
import store from 'store';
|
import store from 'store';
|
||||||
@ -23,24 +25,27 @@ import {
|
|||||||
LicenseState,
|
LicenseState,
|
||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
} from 'types/api/licensesV3/getActive';
|
} from 'types/api/licensesV3/getActive';
|
||||||
|
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||||
import { ROLES, USER_ROLES } from 'types/roles';
|
import { ROLES, USER_ROLES } from 'types/roles';
|
||||||
|
// import { MemoryRouter as V5MemoryRouter } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
// jest.useFakeTimers();
|
||||||
jest.setSystemTime(new Date('2023-10-20'));
|
jest.setSystemTime(new Date('2023-10-20'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
jest.useRealTimers();
|
// jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockStore = configureStore([thunk]);
|
const mockStore = configureStore([thunk]);
|
||||||
@ -85,24 +90,6 @@ jest.mock('react-i18next', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useLocation: (): { pathname: string } => ({
|
|
||||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('hooks/useSafeNavigate', () => ({
|
|
||||||
useSafeNavigate: (): any => ({
|
|
||||||
safeNavigate: jest.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-router-dom-v5-compat', () => ({
|
|
||||||
...jest.requireActual('react-router-dom-v5-compat'),
|
|
||||||
useNavigationType: (): any => 'PUSH',
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function getAppContextMock(
|
export function getAppContextMock(
|
||||||
role: string,
|
role: string,
|
||||||
appContextOverrides?: Partial<IAppContext>,
|
appContextOverrides?: Partial<IAppContext>,
|
||||||
@ -253,48 +240,96 @@ export function getAppContextMock(
|
|||||||
|
|
||||||
export function AllTheProviders({
|
export function AllTheProviders({
|
||||||
children,
|
children,
|
||||||
role, // Accept the role as a prop
|
role,
|
||||||
appContextOverrides,
|
appContextOverrides,
|
||||||
|
queryBuilderOverrides,
|
||||||
|
initialRoute,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
role: string; // Define the role prop
|
role?: string;
|
||||||
appContextOverrides: Partial<IAppContext>;
|
appContextOverrides?: Partial<IAppContext>;
|
||||||
|
queryBuilderOverrides?: Partial<QueryBuilderContextType>;
|
||||||
|
initialRoute?: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
|
// Set default values
|
||||||
|
const roleValue = role || 'ADMIN';
|
||||||
|
const appContextOverridesValue = appContextOverrides || {};
|
||||||
|
const initialRouteValue = initialRoute || '/';
|
||||||
|
|
||||||
|
const queryBuilderContent = queryBuilderOverrides ? (
|
||||||
|
<QueryBuilderContext.Provider
|
||||||
|
value={queryBuilderOverrides as QueryBuilderContextType}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</QueryBuilderContext.Provider>
|
||||||
|
) : (
|
||||||
|
<QueryBuilderProvider>{children}</QueryBuilderProvider>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<MemoryRouter initialEntries={[initialRouteValue]}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Provider store={mockStored(role)}>
|
<Provider store={mockStored(roleValue)}>
|
||||||
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
|
<AppContext.Provider
|
||||||
|
value={getAppContextMock(roleValue, appContextOverridesValue)}
|
||||||
|
>
|
||||||
<ResourceProvider>
|
<ResourceProvider>
|
||||||
<ErrorModalProvider>
|
<ErrorModalProvider>
|
||||||
<BrowserRouter>
|
|
||||||
<TimezoneProvider>
|
<TimezoneProvider>
|
||||||
<PreferenceContextProvider>
|
<PreferenceContextProvider>
|
||||||
<QueryBuilderProvider>{children}</QueryBuilderProvider>
|
{queryBuilderContent}
|
||||||
</PreferenceContextProvider>
|
</PreferenceContextProvider>
|
||||||
</TimezoneProvider>
|
</TimezoneProvider>
|
||||||
</BrowserRouter>
|
|
||||||
</ErrorModalProvider>
|
</ErrorModalProvider>
|
||||||
</ResourceProvider>
|
</ResourceProvider>
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
</Provider>
|
</Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AllTheProviders.defaultProps = {
|
||||||
|
role: 'ADMIN',
|
||||||
|
appContextOverrides: {},
|
||||||
|
queryBuilderOverrides: undefined,
|
||||||
|
initialRoute: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProviderProps {
|
||||||
|
role?: string;
|
||||||
|
appContextOverrides?: Partial<IAppContext>;
|
||||||
|
queryBuilderOverrides?: Partial<QueryBuilderContextType>;
|
||||||
|
initialRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const customRender = (
|
const customRender = (
|
||||||
ui: ReactElement,
|
ui: ReactElement,
|
||||||
options?: Omit<RenderOptions, 'wrapper'>,
|
options?: Omit<RenderOptions, 'wrapper'>,
|
||||||
role = 'ADMIN', // Set a default role
|
providerProps: ProviderProps = {},
|
||||||
appContextOverrides?: Partial<IAppContext>,
|
): RenderResult => {
|
||||||
): RenderResult =>
|
const {
|
||||||
render(ui, {
|
role = 'ADMIN',
|
||||||
|
appContextOverrides = {},
|
||||||
|
queryBuilderOverrides,
|
||||||
|
initialRoute = '/',
|
||||||
|
} = providerProps;
|
||||||
|
|
||||||
|
return render(ui, {
|
||||||
wrapper: () => (
|
wrapper: () => (
|
||||||
<AllTheProviders role={role} appContextOverrides={appContextOverrides || {}}>
|
<AllTheProviders
|
||||||
|
role={role}
|
||||||
|
appContextOverrides={appContextOverrides}
|
||||||
|
queryBuilderOverrides={queryBuilderOverrides}
|
||||||
|
initialRoute={initialRoute}
|
||||||
|
>
|
||||||
{ui}
|
{ui}
|
||||||
</AllTheProviders>
|
</AllTheProviders>
|
||||||
),
|
),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export * from '@testing-library/react';
|
export * from '@testing-library/react';
|
||||||
|
export { default as userEvent } from '@testing-library/user-event';
|
||||||
export { customRender as render };
|
export { customRender as render };
|
||||||
|
|||||||
@ -16122,6 +16122,13 @@ robust-predicates@^3.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
||||||
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
||||||
|
|
||||||
|
rrule@2.8.1:
|
||||||
|
version "2.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e"
|
||||||
|
integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
|
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
|
||||||
version "1.16.1"
|
version "1.16.1"
|
||||||
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"
|
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/factory"
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -12,4 +13,7 @@ type AuthZ interface {
|
|||||||
|
|
||||||
// Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o).
|
// Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o).
|
||||||
Check(context.Context, *openfgav1.CheckRequestTupleKey) error
|
Check(context.Context, *openfgav1.CheckRequestTupleKey) error
|
||||||
|
|
||||||
|
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
|
||||||
|
CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, authtypes.Typeable, ...authtypes.Selector) error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -194,3 +194,40 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selector authtypes.Selector, parentTypeable authtypes.Typeable, parentSelectors ...authtypes.Selector) error {
|
||||||
|
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
check, err := provider.sequentialCheck(ctx, tuples)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !check {
|
||||||
|
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", subject, relation.StringValue(), typeable.Type().StringValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) {
|
||||||
|
for _, tupleReq := range tuplesReq {
|
||||||
|
err := provider.Check(ctx, tupleReq)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if errors.Ast(err, errors.TypeInternal) {
|
||||||
|
// return at the first internal error as the evaluation will be incorrect
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ type role
|
|||||||
|
|
||||||
type organisation
|
type organisation
|
||||||
relations
|
relations
|
||||||
define admin: [role#assignee]
|
define create: [role#assignee]
|
||||||
define editor: [role#assignee] or admin
|
define read: [role#assignee]
|
||||||
define viewer: [role#assignee] or editor
|
define update: [role#assignee]
|
||||||
|
define delete: [role#assignee]
|
||||||
@ -106,11 +106,15 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// each individual APIs should be responsible for defining the relation and the object being accessed, subject will be derived from the request
|
func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ authtypes.Typeable, _ authtypes.SelectorCallbackFn) http.HandlerFunc {
|
||||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation string) http.HandlerFunc {
|
|
||||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
checkRequestTupleKey := authtypes.NewTuple("", "", "")
|
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||||
err := middleware.authzService.Check(req.Context(), checkRequestTupleKey)
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
render.Error(rw, err)
|
render.Error(rw, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
_ "net/http/pprof" // http profiler
|
_ "net/http/pprof" // http profiler
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||||
|
|
||||||
"github.com/gorilla/handlers"
|
"github.com/gorilla/handlers"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||||
@ -308,6 +310,8 @@ func makeRulesManager(
|
|||||||
querier querier.Querier,
|
querier querier.Querier,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) (*rules.Manager, error) {
|
) (*rules.Manager, error) {
|
||||||
|
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
|
||||||
|
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||||
// create manager opts
|
// create manager opts
|
||||||
managerOpts := &rules.ManagerOptions{
|
managerOpts := &rules.ManagerOptions{
|
||||||
TelemetryStore: telemetryStore,
|
TelemetryStore: telemetryStore,
|
||||||
@ -319,9 +323,11 @@ func makeRulesManager(
|
|||||||
SLogger: logger,
|
SLogger: logger,
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
EvalDelay: constants.GetEvalDelay(),
|
EvalDelay: constants.GetEvalDelay(),
|
||||||
SQLStore: sqlstore,
|
|
||||||
OrgGetter: orgGetter,
|
OrgGetter: orgGetter,
|
||||||
Alertmanager: alertmanager,
|
Alertmanager: alertmanager,
|
||||||
|
RuleStore: ruleStore,
|
||||||
|
MaintenanceStore: maintenanceStore,
|
||||||
|
SqlStore: sqlstore,
|
||||||
}
|
}
|
||||||
|
|
||||||
// create Manager
|
// create Manager
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import (
|
|||||||
querierV5 "github.com/SigNoz/signoz/pkg/querier"
|
querierV5 "github.com/SigNoz/signoz/pkg/querier"
|
||||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
|
||||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||||
"github.com/SigNoz/signoz/pkg/types"
|
"github.com/SigNoz/signoz/pkg/types"
|
||||||
@ -98,8 +97,10 @@ type ManagerOptions struct {
|
|||||||
PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error)
|
PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error)
|
||||||
PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError)
|
PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError)
|
||||||
Alertmanager alertmanager.Alertmanager
|
Alertmanager alertmanager.Alertmanager
|
||||||
SQLStore sqlstore.SQLStore
|
|
||||||
OrgGetter organization.Getter
|
OrgGetter organization.Getter
|
||||||
|
RuleStore ruletypes.RuleStore
|
||||||
|
MaintenanceStore ruletypes.MaintenanceStore
|
||||||
|
SqlStore sqlstore.SQLStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Manager manages recording and alerting rules.
|
// The Manager manages recording and alerting rules.
|
||||||
@ -207,14 +208,12 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
|||||||
// by calling the Run method.
|
// by calling the Run method.
|
||||||
func NewManager(o *ManagerOptions) (*Manager, error) {
|
func NewManager(o *ManagerOptions) (*Manager, error) {
|
||||||
o = defaultOptions(o)
|
o = defaultOptions(o)
|
||||||
ruleStore := sqlrulestore.NewRuleStore(o.SQLStore)
|
|
||||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(o.SQLStore)
|
|
||||||
|
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
tasks: map[string]Task{},
|
tasks: map[string]Task{},
|
||||||
rules: map[string]Rule{},
|
rules: map[string]Rule{},
|
||||||
ruleStore: ruleStore,
|
ruleStore: o.RuleStore,
|
||||||
maintenanceStore: maintenanceStore,
|
maintenanceStore: o.MaintenanceStore,
|
||||||
opts: o,
|
opts: o,
|
||||||
block: make(chan struct{}),
|
block: make(chan struct{}),
|
||||||
logger: o.Logger,
|
logger: o.Logger,
|
||||||
@ -223,8 +222,8 @@ func NewManager(o *ManagerOptions) (*Manager, error) {
|
|||||||
prepareTaskFunc: o.PrepareTaskFunc,
|
prepareTaskFunc: o.PrepareTaskFunc,
|
||||||
prepareTestRuleFunc: o.PrepareTestRuleFunc,
|
prepareTestRuleFunc: o.PrepareTestRuleFunc,
|
||||||
alertmanager: o.Alertmanager,
|
alertmanager: o.Alertmanager,
|
||||||
sqlstore: o.SQLStore,
|
|
||||||
orgGetter: o.OrgGetter,
|
orgGetter: o.OrgGetter,
|
||||||
|
sqlstore: o.SqlStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
@ -896,33 +895,37 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// storedRule holds the current stored rule from DB
|
storedRule := ruletypes.PostableRule{}
|
||||||
patchedRule := ruletypes.PostableRule{}
|
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
|
||||||
if err := json.Unmarshal([]byte(ruleStr), &patchedRule); err != nil {
|
zap.L().Error("failed to unmarshal rule from db", zap.String("id", id.StringValue()), zap.Error(err))
|
||||||
zap.L().Error("failed to unmarshal stored rule with given id", zap.String("id", id.StringValue()), zap.Error(err))
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
|
||||||
|
zap.L().Error("failed to unmarshal patched rule with given id", zap.String("id", id.StringValue()), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// deploy or un-deploy task according to patched (new) rule state
|
// deploy or un-deploy task according to patched (new) rule state
|
||||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &patchedRule); err != nil {
|
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||||
zap.L().Error("failed to sync stored rule state with the task", zap.String("taskName", taskName), zap.Error(err))
|
zap.L().Error("failed to sync stored rule state with the task", zap.String("taskName", taskName), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepare rule json to write to update db
|
newStoredJson, err := json.Marshal(&storedRule)
|
||||||
patchedRuleBytes, err := json.Marshal(patchedRule)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
zap.L().Error("failed to marshal new stored rule with given id", zap.String("id", id.StringValue()), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
storedJSON.Data = string(patchedRuleBytes)
|
storedJSON.Data = string(newStoredJson)
|
||||||
storedJSON.UpdatedBy = claims.Email
|
storedJSON.UpdatedBy = claims.Email
|
||||||
storedJSON.UpdatedAt = now
|
storedJSON.UpdatedAt = now
|
||||||
|
|
||||||
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
|
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &patchedRule); err != nil {
|
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||||
zap.L().Error("failed to restore rule after patch failure", zap.String("taskName", taskName), zap.Error(err))
|
zap.L().Error("failed to restore rule after patch failure", zap.String("taskName", taskName), zap.Error(err))
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -931,7 +934,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
|||||||
// prepare http response
|
// prepare http response
|
||||||
response := ruletypes.GettableRule{
|
response := ruletypes.GettableRule{
|
||||||
Id: id.StringValue(),
|
Id: id.StringValue(),
|
||||||
PostableRule: patchedRule,
|
PostableRule: storedRule,
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch state of rule from memory
|
// fetch state of rule from memory
|
||||||
|
|||||||
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Time series with group by on materialized column",
|
||||||
|
requestType: qbtypes.RequestTypeTimeSeries,
|
||||||
|
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||||
|
Signal: telemetrytypes.SignalLogs,
|
||||||
|
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||||
|
Aggregations: []qbtypes.LogAggregation{
|
||||||
|
{
|
||||||
|
Expression: "count()",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Filter: &qbtypes.Filter{
|
||||||
|
Expression: "service.name = 'cartservice'",
|
||||||
|
},
|
||||||
|
Limit: 10,
|
||||||
|
GroupBy: []qbtypes.GroupByKey{
|
||||||
|
{
|
||||||
|
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||||
|
Name: "materialized.key.name",
|
||||||
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||||
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: qbtypes.Statement{
|
||||||
|
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`materialized.key.name`) GLOBAL IN (SELECT `materialized.key.name` FROM __limit_cte) GROUP BY ts, `materialized.key.name`",
|
||||||
|
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fm := NewFieldMapper()
|
fm := NewFieldMapper()
|
||||||
|
|||||||
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