diff --git a/ee/authz/openfgaschema/base.fga b/ee/authz/openfgaschema/base.fga new file mode 100644 index 000000000000..17cbaec7d87d --- /dev/null +++ b/ee/authz/openfgaschema/base.fga @@ -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] diff --git a/ee/authz/openfgaschema/schema.go b/ee/authz/openfgaschema/schema.go new file mode 100644 index 000000000000..605cad0501f1 --- /dev/null +++ b/ee/authz/openfgaschema/schema.go @@ -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, + }, + } +} diff --git a/ee/http/middleware/authz.go b/ee/http/middleware/authz.go new file mode 100644 index 000000000000..c6b20eda39c1 --- /dev/null +++ b/ee/http/middleware/authz.go @@ -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) + }) +} diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index b83dd4f51b10..a963cf4e33f2 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -8,6 +8,8 @@ import ( "net/http" _ "net/http/pprof" // http profiler + "github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore" + "github.com/gorilla/handlers" "github.com/SigNoz/signoz/ee/query-service/app/api" @@ -334,6 +336,8 @@ func makeRulesManager( querier querier.Querier, logger *slog.Logger, ) (*baserules.Manager, error) { + ruleStore := sqlrulestore.NewRuleStore(sqlstore) + maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore) // create manager opts managerOpts := &baserules.ManagerOptions{ TelemetryStore: telemetryStore, @@ -348,8 +352,10 @@ func makeRulesManager( PrepareTaskFunc: rules.PrepareTaskFunc, PrepareTestRuleFunc: rules.TestNotification, Alertmanager: alertmanager, - SQLStore: sqlstore, OrgGetter: orgGetter, + RuleStore: ruleStore, + MaintenanceStore: maintenanceStore, + SqlStore: sqlstore, } // create Manager diff --git a/frontend/.cursorrules b/frontend/.cursorrules new file mode 100644 index 000000000000..9cfa908ba60f --- /dev/null +++ b/frontend/.cursorrules @@ -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` +- 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(, 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(, 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(, 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 { + data: T; + status: number; + message: string; +} + +// Type the mock functions +const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise>>; +const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise>>; + +// 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 = ({ 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(); + + // 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 => ({ + 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": "/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; +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 { + data: T; + status: number; +} + +const mockFetchData = jest.fn() as jest.MockedFunction< + (endpoint: string) => Promise> +>; + +// Usage +mockFetchData('/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; + +const renderTestComponent = (props: Partial = {}): RenderResult => { + const defaultProps: TestComponentProps = { + title: 'Test', + data: [], + onSelect: jest.fn(), + ...props + }; + + return render(); +}; +``` + +### Error Handling with Types +```ts +// ✅ GOOD - Typed error handling +interface ApiError { + message: string; + code: number; + details?: Record; +} + +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` +- [ ] 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) +})); +``` \ No newline at end of file diff --git a/frontend/__mocks__/uplotMock.ts b/frontend/__mocks__/uplotMock.ts new file mode 100644 index 000000000000..9cf9add9f0c2 --- /dev/null +++ b/frontend/__mocks__/uplotMock.ts @@ -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; diff --git a/frontend/__mocks__/useSafeNavigate.ts b/frontend/__mocks__/useSafeNavigate.ts new file mode 100644 index 000000000000..a1044da052c7 --- /dev/null +++ b/frontend/__mocks__/useSafeNavigate.ts @@ -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 + >, +}); diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 18b0989113ff..1d9255a329e8 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -1,5 +1,7 @@ import type { Config } from '@jest/types'; +const USE_SAFE_NAVIGATE_MOCK_PATH = '/__mocks__/useSafeNavigate.ts'; + const config: Config.InitialOptions = { clearMocks: true, coverageDirectory: 'coverage', @@ -10,6 +12,10 @@ const config: Config.InitialOptions = { moduleNameMapper: { '\\.(css|less|scss)$': '/__mocks__/cssMock.ts', '\\.md$': '/__mocks__/cssMock.ts', + '^uplot$': '/__mocks__/uplotMock.ts', + '^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, + '^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, + '^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, }, globals: { extensionsToTreatAsEsm: ['.ts'], diff --git a/frontend/package.json b/frontend/package.json index 40d009b5903f..6316f6198914 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -139,6 +139,7 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "rehype-raw": "7.0.0", + "rrule": "2.8.1", "stream": "^0.0.2", "style-loader": "1.3.0", "styled-components": "^5.3.11", diff --git a/frontend/src/components/CustomTimePicker/timezoneUtils.ts b/frontend/src/components/CustomTimePicker/timezoneUtils.ts index 92da405ba480..bca511e49707 100644 --- a/frontend/src/components/CustomTimePicker/timezoneUtils.ts +++ b/frontend/src/components/CustomTimePicker/timezoneUtils.ts @@ -119,7 +119,9 @@ const filterAndSortTimezones = ( return createTimezoneEntry(normalizedTz, offset); }); -const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => { +export const generateTimezoneData = ( + includeEtcTimezones = false, +): Timezone[] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const allTimezones = (Intl as any).supportedValuesOf('timeZone'); const timezones: Timezone[] = []; diff --git a/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx index bfe099a0ed59..f33db568a3b7 100644 --- a/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx +++ b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx @@ -19,20 +19,6 @@ beforeAll(() => { }); }); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('react-dnd', () => ({ useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), diff --git a/frontend/src/components/ErrorModal/ErrorModal.test.tsx b/frontend/src/components/ErrorModal/ErrorModal.test.tsx index 64f880e8cece..cb39768c7d2c 100644 --- a/frontend/src/components/ErrorModal/ErrorModal.test.tsx +++ b/frontend/src/components/ErrorModal/ErrorModal.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; import APIError from 'types/api/error'; import ErrorModal from './ErrorModal'; @@ -56,9 +56,8 @@ describe('ErrorModal Component', () => { // Click the close button const closeButton = screen.getByTestId('close-button'); - act(() => { - fireEvent.click(closeButton); - }); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(closeButton); // Check if onClose was called expect(onCloseMock).toHaveBeenCalledTimes(1); @@ -149,9 +148,8 @@ it('should open the modal when the trigger component is clicked', async () => { // Click the trigger component const triggerButton = screen.getByText('Open Error Modal'); - act(() => { - fireEvent.click(triggerButton); - }); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(triggerButton); // Check if the modal is displayed expect(screen.getByText('An error occurred')).toBeInTheDocument(); @@ -170,18 +168,15 @@ it('should close the modal when the onCancel event is triggered', async () => { // Click the trigger component const triggerButton = screen.getByText('error'); - act(() => { - fireEvent.click(triggerButton); - }); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(triggerButton); await waitFor(() => { expect(screen.getByText('An error occurred')).toBeInTheDocument(); }); // Trigger the onCancel event - act(() => { - fireEvent.click(screen.getByTestId('close-button')); - }); + await user.click(screen.getByTestId('close-button')); // Check if the modal is closed expect(onCloseMock).toHaveBeenCalledTimes(1); diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx index c70ca59c2df6..efa1ee536e81 100644 --- a/frontend/src/components/QuickFilters/QuickFilters.tsx +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { filterConfig, isDynamicFilters, customFilters, - setIsStale, + refetchCustomFilters, isCustomFiltersLoading, } = useFilterConfig({ signal, config }); @@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { signal={signal} setIsSettingsOpen={setIsSettingsOpen} customFilters={customFilters} - setIsStale={setIsStale} + refetchCustomFilters={refetchCustomFilters} /> )} diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx b/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx index aa78d2610781..20e8e5b581a4 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx @@ -14,12 +14,12 @@ function QuickFiltersSettings({ signal, setIsSettingsOpen, customFilters, - setIsStale, + refetchCustomFilters, }: { signal: SignalType | undefined; setIsSettingsOpen: (isSettingsOpen: boolean) => void; customFilters: FilterType[]; - setIsStale: (isStale: boolean) => void; + refetchCustomFilters: () => void; }): JSX.Element { const { handleSettingsClose, @@ -34,7 +34,7 @@ function QuickFiltersSettings({ } = useQuickFilterSettings({ setIsSettingsOpen, customFilters, - setIsStale, + refetchCustomFilters, signal, }); diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx b/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx index bf4406c3045a..42be1bece827 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx @@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters'; interface UseQuickFilterSettingsProps { setIsSettingsOpen: (isSettingsOpen: boolean) => void; customFilters: FilterType[]; - setIsStale: (isStale: boolean) => void; + refetchCustomFilters: () => void; signal?: SignalType; } @@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn { const useQuickFilterSettings = ({ customFilters, setIsSettingsOpen, - setIsStale, + refetchCustomFilters, signal, }: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => { const [inputValue, setInputValue] = useState(''); @@ -46,7 +46,7 @@ const useQuickFilterSettings = ({ } = useMutation(updateCustomFiltersAPI, { onSuccess: () => { setIsSettingsOpen(false); - setIsStale(true); + refetchCustomFilters(); logEvent('Quick Filters Settings: changes saved', { addedFilters, }); diff --git a/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx b/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx index fb2659a6817b..2f7d0bc70ed1 100644 --- a/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx +++ b/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx @@ -1,12 +1,8 @@ import getCustomFilters from 'api/quickFilters/getCustomFilters'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useQuery } from 'react-query'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { - Filter as FilterType, - PayloadProps, -} from 'types/api/quickFilters/getCustomFilters'; +import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters'; import { IQuickFiltersConfig, SignalType } from '../types'; import { getFilterConfig } from '../utils'; @@ -18,37 +14,34 @@ interface UseFilterConfigProps { interface UseFilterConfigReturn { filterConfig: IQuickFiltersConfig[]; customFilters: FilterType[]; - setCustomFilters: React.Dispatch>; isCustomFiltersLoading: boolean; isDynamicFilters: boolean; - setIsStale: React.Dispatch>; + refetchCustomFilters: () => void; } const useFilterConfig = ({ signal, config, }: UseFilterConfigProps): UseFilterConfigReturn => { - const [customFilters, setCustomFilters] = useState([]); - const [isStale, setIsStale] = useState(true); + const { + isFetching: isCustomFiltersLoading, + data: customFilters = [], + refetch, + } = useQuery( + [REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal], + async () => { + const res = await getCustomFilters({ signal: signal || '' }); + return 'payload' in res && res.payload?.filters ? res.payload.filters : []; + }, + { + enabled: !!signal, + }, + ); + const isDynamicFilters = useMemo(() => customFilters.length > 0, [ customFilters, ]); - const { isFetching: isCustomFiltersLoading } = useQuery< - SuccessResponse | ErrorResponse, - Error - >( - [REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal], - () => getCustomFilters({ signal: signal || '' }), - { - onSuccess: (data) => { - if ('payload' in data && data.payload?.filters) { - setCustomFilters(data.payload.filters || ([] as FilterType[])); - } - setIsStale(false); - }, - enabled: !!signal && isStale, - }, - ); + const filterConfig = useMemo( () => getFilterConfig(signal, customFilters, config), [config, customFilters, signal], @@ -57,10 +50,9 @@ const useFilterConfig = ({ return { filterConfig, customFilters, - setCustomFilters, isCustomFiltersLoading, isDynamicFilters, - setIsStale, + refetchCustomFilters: refetch, }; }; diff --git a/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx index 252a4d23084a..bf01c8e8923d 100644 --- a/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx +++ b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx @@ -1,15 +1,6 @@ import '@testing-library/jest-dom'; -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; import { ENVIRONMENT } from 'constants/env'; -import ROUTES from 'constants/routes'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { otherFiltersResponse, @@ -18,8 +9,7 @@ import { } from 'mocks-server/__mockdata__/customQuickFilters'; import { server } from 'mocks-server/server'; import { rest } from 'msw'; -import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; -import { USER_ROLES } from 'types/roles'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; import QuickFilters from '../QuickFilters'; import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types'; @@ -29,21 +19,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ useQueryBuilder: jest.fn(), })); -// eslint-disable-next-line sonarjs/no-duplicate-string -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: (): { pathname: string } => ({ - pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`, - }), -})); - -const userRole = USER_ROLES.ADMIN; - -// mock useAppContext -jest.mock('providers/App/App', () => ({ - useAppContext: jest.fn(() => ({ user: { role: userRole } })), -})); - const handleFilterVisibilityChange = jest.fn(); const redirectWithQueryBuilderData = jest.fn(); const putHandler = jest.fn(); @@ -78,11 +53,10 @@ const setupServer = (): void => { putHandler(await req.json()); return res(ctx.status(200), ctx.json({})); }), - - rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) => + rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) => res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)), ), - rest.get(fieldsValuesURL, (req, res, ctx) => + rest.get(fieldsValuesURL, (_req, res, ctx) => res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)), ), ); @@ -96,14 +70,12 @@ function TestQuickFilters({ config?: IQuickFiltersConfig[]; }): JSX.Element { return ( - - - + ); } @@ -118,11 +90,11 @@ beforeAll(() => { afterEach(() => { server.resetHandlers(); + jest.clearAllMocks(); }); afterAll(() => { server.close(); - cleanup(); }); beforeEach(() => { @@ -151,9 +123,13 @@ describe('Quick Filters', () => { }); it('should add filter data to query when checkbox is clicked', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); - const checkbox = screen.getByText('mq-kafka'); - fireEvent.click(checkbox); + + // Prefer role if possible; if label text isn’t wired to input, clicking the label text is OK + const target = await screen.findByText('mq-kafka'); + await user.click(target); await waitFor(() => { expect(redirectWithQueryBuilderData).toHaveBeenCalledWith( @@ -182,16 +158,20 @@ describe('Quick Filters', () => { describe('Quick Filters with custom filters', () => { it('loads the custom filters correctly', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); + expect(screen.getByText('Filters for')).toBeInTheDocument(); expect(screen.getByText(QUERY_NAME)).toBeInTheDocument(); + await screen.findByText(FILTER_SERVICE_NAME); const allByText = await screen.findAllByText('otel-demo'); - // since 2 filter collapse are open, there are 2 filter items visible expect(allByText).toHaveLength(2); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); expect(await screen.findByText('Edit quick filters')).toBeInTheDocument(); @@ -207,16 +187,19 @@ describe('Quick Filters with custom filters', () => { }); it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME); const addButton = otherFilterItem.parentElement?.querySelector('button'); expect(addButton).not.toBeNull(); - fireEvent.click(addButton as HTMLButtonElement); + await user.click(addButton as HTMLButtonElement); const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; await waitFor(() => { @@ -225,17 +208,21 @@ describe('Quick Filters with custom filters', () => { }); it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; const target = await screen.findByText(FILTER_OS_DESCRIPTION); const removeBtn = target.parentElement?.querySelector('button'); expect(removeBtn).not.toBeNull(); - fireEvent.click(removeBtn as HTMLButtonElement); + + await user.click(removeBtn as HTMLButtonElement); await waitFor(() => { expect(addedSection).not.toContainElement( @@ -250,17 +237,20 @@ describe('Quick Filters with custom filters', () => { }); it('restores original filter state on Discard', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; const target = await screen.findByText(FILTER_OS_DESCRIPTION); const removeBtn = target.parentElement?.querySelector('button'); expect(removeBtn).not.toBeNull(); - fireEvent.click(removeBtn as HTMLButtonElement); + await user.click(removeBtn as HTMLButtonElement); const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!; await waitFor(() => { @@ -272,7 +262,11 @@ describe('Quick Filters with custom filters', () => { ); }); - fireEvent.click(screen.getByText(DISCARD_TEXT)); + const discardBtn = screen + .getByText(DISCARD_TEXT) + .closest('button') as HTMLButtonElement; + expect(discardBtn).not.toBeNull(); + await user.click(discardBtn); await waitFor(() => { expect(addedSection).toContainElement( @@ -285,18 +279,25 @@ describe('Quick Filters with custom filters', () => { }); it('saves the updated filters by calling PUT with correct payload', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const target = await screen.findByText(FILTER_OS_DESCRIPTION); const removeBtn = target.parentElement?.querySelector('button'); expect(removeBtn).not.toBeNull(); - fireEvent.click(removeBtn as HTMLButtonElement); + await user.click(removeBtn as HTMLButtonElement); - fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT)); + const saveBtn = screen + .getByText(SAVE_CHANGES_TEXT) + .closest('button') as HTMLButtonElement; + expect(saveBtn).not.toBeNull(); + await user.click(saveBtn); await waitFor(() => { expect(putHandler).toHaveBeenCalled(); @@ -311,31 +312,36 @@ describe('Quick Filters with custom filters', () => { expect(requestBody.signal).toBe(SIGNAL); }); - // render duration filter it('should render duration slider for duration_nono filter', async () => { - // Set up fake timers **before rendering** + // Use fake timers only in this test (for debounce), and wire them to userEvent jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: (ms) => jest.advanceTimersByTime(ms), + pointerEventsCheck: 0, + }); const { getByTestId } = render(); await screen.findByText(FILTER_SERVICE_NAME); expect(screen.getByText('Duration')).toBeInTheDocument(); - // click to open the duration filter - fireEvent.click(screen.getByText('Duration')); + // Open the duration section (use role if it’s a button/collapse) + await user.click(screen.getByText('Duration')); const minDuration = getByTestId('min-input') as HTMLInputElement; const maxDuration = getByTestId('max-input') as HTMLInputElement; + expect(minDuration).toHaveValue(null); expect(minDuration).toHaveProperty('placeholder', '0'); expect(maxDuration).toHaveValue(null); expect(maxDuration).toHaveProperty('placeholder', '100000000'); - await act(async () => { - // set values - fireEvent.change(minDuration, { target: { value: '10000' } }); - fireEvent.change(maxDuration, { target: { value: '20000' } }); - jest.advanceTimersByTime(2000); - }); + // Type values and advance debounce + await user.clear(minDuration); + await user.type(minDuration, '10000'); + await user.clear(maxDuration); + await user.type(maxDuration, '20000'); + jest.advanceTimersByTime(2000); + await waitFor(() => { expect(redirectWithQueryBuilderData).toHaveBeenCalledWith( expect.objectContaining({ @@ -363,6 +369,144 @@ describe('Quick Filters with custom filters', () => { ); }); - jest.useRealTimers(); // Clean up + jest.useRealTimers(); + }); +}); + +describe('Quick Filters refetch behavior', () => { + it('fetches custom filters on every mount when signal is provided', async () => { + let getCalls = 0; + + server.use( + rest.get(quickFiltersListURL, (_req, res, ctx) => { + getCalls += 1; + return res(ctx.status(200), ctx.json(quickFiltersListResponse)); + }), + ); + + const { unmount } = render(); + expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument(); + + unmount(); + + render(); + 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(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByText('No filters found')).toBeInTheDocument(); }); }); diff --git a/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx index 32f597a82a12..bf8d5398c21d 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx @@ -22,6 +22,8 @@ jest.mock('react-router-dom', () => ({ describe('Alert Channels Settings List page', () => { beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-10-20')); render(); await waitFor(() => expect(screen.getByText('sending_channels_note')).toBeInTheDocument(), @@ -29,6 +31,7 @@ describe('Alert Channels Settings List page', () => { }); afterEach(() => { jest.restoreAllMocks(); + jest.useRealTimers(); }); describe('Should display the Alert Channels page properly', () => { it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => { diff --git a/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx index 162f0fb8fbdc..cbdcf223e026 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx @@ -28,6 +28,7 @@ jest.mock('react-router-dom', () => ({ describe('Alert Channels Settings List page (Normal User)', () => { beforeEach(async () => { + jest.useFakeTimers(); render(); await waitFor(() => expect(screen.getByText('sending_channels_note')).toBeInTheDocument(), @@ -35,6 +36,7 @@ describe('Alert Channels Settings List page (Normal User)', () => { }); afterEach(() => { jest.restoreAllMocks(); + jest.useRealTimers(); }); describe('Should display the Alert Channels page properly', () => { it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => { diff --git a/frontend/src/container/BillingContainer/BillingContainer.test.tsx b/frontend/src/container/BillingContainer/BillingContainer.test.tsx index a52e32dd1aa5..efec53d79778 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.test.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.test.tsx @@ -9,22 +9,6 @@ import { getFormattedDate } from 'utils/timeUtils'; import BillingContainer from './BillingContainer'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - - const uplotMock = jest.fn(() => ({ - paths, - })); - - return { - paths, - default: uplotMock, - }; -}); - window.ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ @@ -67,78 +51,103 @@ describe('BillingContainer', () => { expect(currentBill).toBeInTheDocument(); }); - test('OnTrail', async () => { - await act(async () => { - render(, undefined, undefined, { - trialInfo: licensesSuccessResponse.data, + describe('Trial scenarios', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-10-20')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('OnTrail', async () => { + // Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining" + + render( + , + {}, + { appContextOverrides: { trialInfo: licensesSuccessResponse.data } }, + ); + + // If the component schedules any setTimeout on mount, flush them: + jest.runOnlyPendingTimers(); + + expect(await screen.findByText('Free Trial')).toBeInTheDocument(); + expect(await screen.findByText('billing')).toBeInTheDocument(); + expect(await screen.findByText(/\$0/i)).toBeInTheDocument(); + + expect( + await screen.findByText( + /You are in free trial period. Your free trial will end on 20 Oct 2023/i, + ), + ).toBeInTheDocument(); + + expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument(); + + const upgradeButtons = await screen.findAllByRole('button', { + name: /upgrade_plan/i, }); + expect(upgradeButtons).toHaveLength(2); + expect(upgradeButtons[1]).toBeInTheDocument(); + + expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument(); + expect( + await screen.findByRole('link', { name: /here/i }), + ).toBeInTheDocument(); }); - const freeTrailText = await screen.findByText('Free Trial'); - expect(freeTrailText).toBeInTheDocument(); - - const currentBill = await screen.findByText('billing'); - expect(currentBill).toBeInTheDocument(); - - const dollar0 = await screen.findByText(/\$0/i); - expect(dollar0).toBeInTheDocument(); - const onTrail = await screen.findByText( - /You are in free trial period. Your free trial will end on 20 Oct 2023/i, - ); - expect(onTrail).toBeInTheDocument(); - - const numberOfDayRemaining = await screen.findByText(/1 days_remaining/i); - expect(numberOfDayRemaining).toBeInTheDocument(); - const upgradeButton = await screen.findAllByRole('button', { - name: /upgrade_plan/i, - }); - expect(upgradeButton[1]).toBeInTheDocument(); - expect(upgradeButton.length).toBe(2); - const checkPaidPlan = await screen.findByText(/checkout_plans/i); - expect(checkPaidPlan).toBeInTheDocument(); - - const link = await screen.findByRole('link', { name: /here/i }); - expect(link).toBeInTheDocument(); - }); - - test('OnTrail but trialConvertedToSubscription', async () => { - await act(async () => { - render(, undefined, undefined, { - trialInfo: trialConvertedToSubscriptionResponse.data, + test('OnTrail but trialConvertedToSubscription', async () => { + await act(async () => { + render( + , + {}, + { + appContextOverrides: { + trialInfo: trialConvertedToSubscriptionResponse.data, + }, + }, + ); }); + + const currentBill = await screen.findByText('billing'); + expect(currentBill).toBeInTheDocument(); + + const dollar0 = await screen.findByText(/\$0/i); + expect(dollar0).toBeInTheDocument(); + + const onTrail = await screen.findByText( + /You are in free trial period. Your free trial will end on 20 Oct 2023/i, + ); + expect(onTrail).toBeInTheDocument(); + + const receivedCardDetails = await screen.findByText( + /card_details_recieved_and_billing_info/i, + ); + expect(receivedCardDetails).toBeInTheDocument(); + + const manageBillingButton = await screen.findByRole('button', { + name: /manage_billing/i, + }); + expect(manageBillingButton).toBeInTheDocument(); + + const dayRemainingInBillingPeriod = await screen.findByText( + /1 days_remaining/i, + ); + expect(dayRemainingInBillingPeriod).toBeInTheDocument(); }); - - const currentBill = await screen.findByText('billing'); - expect(currentBill).toBeInTheDocument(); - - const dollar0 = await screen.findByText(/\$0/i); - expect(dollar0).toBeInTheDocument(); - - const onTrail = await screen.findByText( - /You are in free trial period. Your free trial will end on 20 Oct 2023/i, - ); - expect(onTrail).toBeInTheDocument(); - - const receivedCardDetails = await screen.findByText( - /card_details_recieved_and_billing_info/i, - ); - expect(receivedCardDetails).toBeInTheDocument(); - - const manageBillingButton = await screen.findByRole('button', { - name: /manage_billing/i, - }); - expect(manageBillingButton).toBeInTheDocument(); - - const dayRemainingInBillingPeriod = await screen.findByText( - /1 days_remaining/i, - ); - expect(dayRemainingInBillingPeriod).toBeInTheDocument(); }); test('Not on ontrail', async () => { - const { findByText } = render(, undefined, undefined, { - trialInfo: notOfTrailResponse.data, - }); + const { findByText } = render( + , + {}, + { + appContextOverrides: { + trialInfo: notOfTrailResponse.data, + }, + }, + ); const billingPeriodText = `Your current billing period is from ${getFormattedDate( billingSuccessResponse.data.billingPeriodStart, diff --git a/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx b/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx index f7a1b1fb0f07..daa68df3adbf 100644 --- a/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx +++ b/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx @@ -1,7 +1,6 @@ import ROUTES from 'constants/routes'; import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions'; import CreateAlertPage from 'pages/CreateAlert'; -import { MemoryRouter, Route } from 'react-router-dom'; import { act, fireEvent, render } from 'tests/test-utils'; import { AlertTypes } from 'types/api/alerts/alertTypes'; @@ -14,20 +13,6 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('hooks/useSafeNavigate', () => ({ useSafeNavigate: (): any => ({ safeNavigate: jest.fn(), @@ -84,11 +69,11 @@ describe('Alert rule documentation redirection', () => { beforeEach(() => { act(() => { renderResult = render( - - - - - , + , + {}, + { + initialRoute: ROUTES.ALERTS_NEW, + }, ); }); }); diff --git a/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx b/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx index 783f6f658531..6b7e43a4bcd3 100644 --- a/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx +++ b/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx @@ -15,20 +15,6 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - window.ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.scss b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.scss new file mode 100644 index 000000000000..90306dcac286 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.scss @@ -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; + } +} diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx new file mode 100644 index 000000000000..a8609642df60 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx @@ -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): 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): 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): 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, + 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 ( +
+ handleKeyDown(e, 'hours')} + disabled={disabled} + maxLength={2} + className="time-input-field" + placeholder="00" + /> + : + handleKeyDown(e, 'minutes')} + disabled={disabled} + maxLength={2} + className="time-input-field" + placeholder="00" + /> + : + handleKeyDown(e, 'seconds')} + disabled={disabled} + maxLength={2} + className="time-input-field" + placeholder="00" + /> +
+ ); +} + +TimeInput.defaultProps = { + value: '00:00:00', + onChange: undefined, + disabled: false, + className: '', +}; + +export default TimeInput; diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/index.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/index.ts new file mode 100644 index 000000000000..6ce0eced8a96 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/index.ts @@ -0,0 +1,3 @@ +import TimeInput from './TimeInput'; + +export default TimeInput; diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/TimeInput.test.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/TimeInput.test.tsx new file mode 100644 index 000000000000..9101647e5150 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/TimeInput.test.tsx @@ -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(); + + expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds + }); + + it('should render with provided value', () => { + render(); + + expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours + expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes + expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds + }); + + it('should handle hours changes', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '12' } }); + + expect(mockOnChange).toHaveBeenCalledWith('12:00:00'); + }); + + it('should handle minutes changes', () => { + render(); + + const minutesInput = screen.getAllByDisplayValue('00')[1]; + fireEvent.change(minutesInput, { target: { value: '30' } }); + + expect(mockOnChange).toHaveBeenCalledWith('00:30:00'); + }); + + it('should handle seconds changes', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const inputs = screen.getAllByRole('textbox'); + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + }); + + it('should update internal state when value prop changes', () => { + const { rerender } = render(); + + expect(screen.getByDisplayValue('01')).toBeInTheDocument(); + expect(screen.getByDisplayValue('02')).toBeInTheDocument(); + expect(screen.getByDisplayValue('03')).toBeInTheDocument(); + + rerender(); + + expect(screen.getByDisplayValue('04')).toBeInTheDocument(); + expect(screen.getByDisplayValue('05')).toBeInTheDocument(); + expect(screen.getByDisplayValue('06')).toBeInTheDocument(); + }); + + it('should handle partial time values', () => { + render(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const secondsInput = screen.getAllByDisplayValue('00')[2]; + fireEvent.change(secondsInput, { target: { value: '59' } }); + + expect(secondsInput).toHaveValue('59'); + expect(mockOnChange).toHaveBeenCalledWith('00:00:59'); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/utils.test.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/utils.test.ts new file mode 100644 index 000000000000..32eb21cd1c3d --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/utils.test.ts @@ -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)[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); + }); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts new file mode 100644 index 000000000000..9e92d667a3ce --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts @@ -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, +})); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts new file mode 100644 index 000000000000..490fa7e0c94b --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts @@ -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; + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export interface IEvaluationWindowDetailsProps { + evaluationWindow: EvaluationWindowState; + setEvaluationWindow: Dispatch; +} + +export interface IEvaluationCadenceDetailsProps { + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export interface TimeInputProps { + value?: string; // Format: "HH:MM:SS" + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/utils.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/utils.tsx new file mode 100644 index 000000000000..4788c4aee157 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/utils.tsx @@ -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; + } +} diff --git a/frontend/src/container/CreateAlertV2/context/constants.ts b/frontend/src/container/CreateAlertV2/context/constants.ts index b13821322796..44788e174c75 100644 --- a/frontend/src/container/CreateAlertV2/context/constants.ts +++ b/frontend/src/container/CreateAlertV2/context/constants.ts @@ -1,13 +1,18 @@ import { Color } from '@signozhq/design-tokens'; +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; +import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/constants'; +import dayjs from 'dayjs'; import getRandomColor from 'lib/getRandomColor'; import { v4 } from 'uuid'; import { + AdvancedOptionsState, AlertState, AlertThresholdMatchType, AlertThresholdOperator, AlertThresholdState, Algorithm, + EvaluationWindowState, Seasonality, Threshold, TimeDuration, @@ -70,6 +75,49 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = { thresholds: [INITIAL_CRITICAL_THRESHOLD], }; +export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = { + sendNotificationIfDataIsMissing: { + toleranceLimit: 15, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + enforceMinimumDatapoints: { + minimumDatapoints: 0, + }, + delayEvaluation: { + delay: 5, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + evaluationCadence: { + mode: 'default', + default: { + value: 1, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + custom: { + repeatEvery: 'week', + startAt: '00:00:00', + occurence: [], + timezone: TIMEZONE_DATA[0].value, + }, + rrule: { + date: dayjs(), + startAt: '00:00:00', + rrule: '', + }, + }, +}; + +export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = { + windowType: 'rolling', + timeframe: '5m0s', + startingAt: { + time: '00:00:00', + number: '1', + timezone: TIMEZONE_DATA[0].value, + unit: UniversalYAxisUnit.MINUTES, + }, +}; + export const THRESHOLD_OPERATOR_OPTIONS = [ { value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' }, { value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' }, @@ -115,3 +163,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [ { value: Seasonality.DAILY, label: 'Daily' }, { value: Seasonality.WEEKLY, label: 'Weekly' }, ]; + +export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [ + { value: UniversalYAxisUnit.SECONDS, label: 'Seconds' }, + { value: UniversalYAxisUnit.MINUTES, label: 'Minutes' }, + { value: UniversalYAxisUnit.HOURS, label: 'Hours' }, + { value: UniversalYAxisUnit.DAYS, label: 'Days' }, +]; diff --git a/frontend/src/container/CreateAlertV2/context/index.tsx b/frontend/src/container/CreateAlertV2/context/index.tsx index 31fe57a2eb46..14221ba25130 100644 --- a/frontend/src/container/CreateAlertV2/context/index.tsx +++ b/frontend/src/container/CreateAlertV2/context/index.tsx @@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { + INITIAL_ADVANCED_OPTIONS_STATE, INITIAL_ALERT_STATE, INITIAL_ALERT_THRESHOLD_STATE, + INITIAL_EVALUATION_WINDOW_STATE, } from './constants'; import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types'; import { + advancedOptionsReducer, alertCreationReducer, alertThresholdReducer, buildInitialAlertDef, + evaluationWindowReducer, getInitialAlertTypeFromURL, } from './utils'; @@ -80,6 +84,16 @@ export function CreateAlertProvider( INITIAL_ALERT_THRESHOLD_STATE, ); + const [evaluationWindow, setEvaluationWindow] = useReducer( + evaluationWindowReducer, + INITIAL_EVALUATION_WINDOW_STATE, + ); + + const [advancedOptions, setAdvancedOptions] = useReducer( + advancedOptionsReducer, + INITIAL_ADVANCED_OPTIONS_STATE, + ); + useEffect(() => { setThresholdState({ type: 'RESET', @@ -94,8 +108,19 @@ export function CreateAlertProvider( setAlertType: handleAlertTypeChange, thresholdState, setThresholdState, + evaluationWindow, + setEvaluationWindow, + advancedOptions, + setAdvancedOptions, }), - [alertState, alertType, handleAlertTypeChange, thresholdState], + [ + alertState, + alertType, + handleAlertTypeChange, + thresholdState, + evaluationWindow, + advancedOptions, + ], ); return ( diff --git a/frontend/src/container/CreateAlertV2/context/types.ts b/frontend/src/container/CreateAlertV2/context/types.ts index 6b5db05de623..a76909054cc4 100644 --- a/frontend/src/container/CreateAlertV2/context/types.ts +++ b/frontend/src/container/CreateAlertV2/context/types.ts @@ -1,3 +1,4 @@ +import { Dayjs } from 'dayjs'; import { Dispatch } from 'react'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { Labels } from 'types/api/alerts/def'; @@ -9,6 +10,10 @@ export interface ICreateAlertContextProps { setAlertType: Dispatch; thresholdState: AlertThresholdState; setThresholdState: Dispatch; + advancedOptions: AdvancedOptionsState; + setAdvancedOptions: Dispatch; + evaluationWindow: EvaluationWindowState; + setEvaluationWindow: Dispatch; } export interface ICreateAlertProviderProps { @@ -101,3 +106,87 @@ export type AlertThresholdAction = | { type: 'SET_SEASONALITY'; payload: string } | { type: 'SET_THRESHOLDS'; payload: Threshold[] } | { type: 'RESET' }; + +export interface AdvancedOptionsState { + sendNotificationIfDataIsMissing: { + toleranceLimit: number; + timeUnit: string; + }; + enforceMinimumDatapoints: { + minimumDatapoints: number; + }; + delayEvaluation: { + delay: number; + timeUnit: string; + }; + evaluationCadence: { + mode: EvaluationCadenceMode; + default: { + value: number; + timeUnit: string; + }; + custom: { + repeatEvery: string; + startAt: string; + occurence: string[]; + timezone: string; + }; + rrule: { + date: Dayjs | null; + startAt: string; + rrule: string; + }; + }; +} + +export type AdvancedOptionsAction = + | { + type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING'; + payload: { toleranceLimit: number; timeUnit: string }; + } + | { + type: 'SET_ENFORCE_MINIMUM_DATAPOINTS'; + payload: { minimumDatapoints: number }; + } + | { + type: 'SET_DELAY_EVALUATION'; + payload: { delay: number; timeUnit: string }; + } + | { + type: 'SET_EVALUATION_CADENCE'; + payload: { + default: { value: number; timeUnit: string }; + custom: { + repeatEvery: string; + startAt: string; + timezone: string; + occurence: string[]; + }; + rrule: { date: Dayjs | null; startAt: string; rrule: string }; + }; + } + | { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode } + | { type: 'RESET' }; + +export interface EvaluationWindowState { + windowType: 'rolling' | 'cumulative'; + timeframe: string; + startingAt: { + time: string; + number: string; + timezone: string; + unit: string; + }; +} + +export type EvaluationWindowAction = + | { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' } + | { type: 'SET_TIMEFRAME'; payload: string } + | { + type: 'SET_STARTING_AT'; + payload: { time: string; number: string; timezone: string; unit: string }; + } + | { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode } + | { type: 'RESET' }; + +export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule'; diff --git a/frontend/src/container/CreateAlertV2/context/utils.tsx b/frontend/src/container/CreateAlertV2/context/utils.tsx index aa3bf8b91096..aa295262c012 100644 --- a/frontend/src/container/CreateAlertV2/context/utils.tsx +++ b/frontend/src/container/CreateAlertV2/context/utils.tsx @@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; -import { INITIAL_ALERT_THRESHOLD_STATE } from './constants'; import { + INITIAL_ADVANCED_OPTIONS_STATE, + INITIAL_ALERT_THRESHOLD_STATE, + INITIAL_EVALUATION_WINDOW_STATE, +} from './constants'; +import { + AdvancedOptionsAction, + AdvancedOptionsState, AlertState, AlertThresholdAction, AlertThresholdState, CreateAlertAction, + EvaluationWindowAction, + EvaluationWindowState, } from './types'; export const alertCreationReducer = ( @@ -110,3 +118,57 @@ export const alertThresholdReducer = ( return state; } }; + +export const advancedOptionsReducer = ( + state: AdvancedOptionsState, + action: AdvancedOptionsAction, +): AdvancedOptionsState => { + switch (action.type) { + case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING': + return { ...state, sendNotificationIfDataIsMissing: action.payload }; + case 'SET_ENFORCE_MINIMUM_DATAPOINTS': + return { ...state, enforceMinimumDatapoints: action.payload }; + case 'SET_DELAY_EVALUATION': + return { ...state, delayEvaluation: action.payload }; + case 'SET_EVALUATION_CADENCE': + return { + ...state, + evaluationCadence: { ...state.evaluationCadence, ...action.payload }, + }; + case 'SET_EVALUATION_CADENCE_MODE': + return { + ...state, + evaluationCadence: { ...state.evaluationCadence, mode: action.payload }, + }; + case 'RESET': + return INITIAL_ADVANCED_OPTIONS_STATE; + default: + return state; + } +}; + +export const evaluationWindowReducer = ( + state: EvaluationWindowState, + action: EvaluationWindowAction, +): EvaluationWindowState => { + switch (action.type) { + case 'SET_WINDOW_TYPE': + return { + ...state, + windowType: action.payload, + startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt, + timeframe: + action.payload === 'rolling' + ? INITIAL_EVALUATION_WINDOW_STATE.timeframe + : 'currentHour', + }; + case 'SET_TIMEFRAME': + return { ...state, timeframe: action.payload }; + case 'SET_STARTING_AT': + return { ...state, startingAt: action.payload }; + case 'RESET': + return INITIAL_EVALUATION_WINDOW_STATE; + default: + return state; + } +}; diff --git a/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx b/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx index 1f0522546bb5..463a3b0a2191 100644 --- a/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx @@ -30,20 +30,6 @@ jest.mock('hooks/useSafeNavigate', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - // Mock data const mockProps: WidgetGraphComponentProps = { widget: { diff --git a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx index 270b2a8cd660..fb55a67df77b 100644 --- a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx +++ b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx @@ -33,19 +33,6 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({ const queryClient = new QueryClient(); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: (): any => ({ diff --git a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx index 8736b1740d87..a385850093dd 100644 --- a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx +++ b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx @@ -3,20 +3,6 @@ import { render, screen } from '@testing-library/react'; import HostsListTable from '../HostsListTable'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container'; describe('HostsListTable', () => { diff --git a/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx b/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx index a38681f37256..c06b7483c688 100644 --- a/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx +++ b/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx @@ -4,20 +4,6 @@ import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils'; const PROGRESS_BAR_CLASS = '.progress-bar'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('InfraMonitoringHosts utils', () => { describe('formatDataForTable', () => { it('should format host data correctly', () => { diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx index 0c3bc5793dab..453b8c7b8b57 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx @@ -44,20 +44,6 @@ const verifyEntityLogsPayload = ({ return queryData; }; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock( 'components/OverlayScrollbar/OverlayScrollbar', () => diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx index 7b32c9226ada..0a650a2dd043 100644 --- a/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx @@ -4,14 +4,8 @@ import setupCommonMocks from '../../commonMocks'; setupCommonMocks(); -import { fireEvent, render, screen } from '@testing-library/react'; import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails'; -import { QueryClient, QueryClientProvider } from 'react-query'; -import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; -import store from 'store'; - -const queryClient = new QueryClient(); +import { fireEvent, render, screen } from 'tests/test-utils'; describe('JobDetails', () => { const mockJob = { @@ -24,13 +18,7 @@ describe('JobDetails', () => { it('should render modal with relevant metadata', () => { render( - - - - - - - , + , ); const jobNameElements = screen.getAllByText('test-job'); @@ -44,13 +32,7 @@ describe('JobDetails', () => { it('should render modal with 4 tabs', () => { render( - - - - - - - , + , ); const metricsTab = screen.getByText('Metrics'); @@ -68,13 +50,7 @@ describe('JobDetails', () => { it('default tab should be metrics', () => { render( - - - - - - - , + , ); const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); @@ -83,13 +59,7 @@ describe('JobDetails', () => { it('should switch to events tab when events tab is clicked', () => { render( - - - - - - - , + , ); const eventsTab = screen.getByRole('radio', { name: 'Events' }); @@ -100,13 +70,7 @@ describe('JobDetails', () => { it('should close modal when close button is clicked', () => { render( - - - - - - - , + , ); const closeButton = screen.getByRole('button', { name: 'Close' }); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts b/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts index 41bd0782ace1..244e569248bd 100644 --- a/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts @@ -56,19 +56,6 @@ const setupCommonMocks = (): void => { useNavigationType: (): any => 'PUSH', })); - jest.mock('hooks/useUrlQuery', () => ({ - __esModule: true, - default: jest.fn(() => ({ - set: jest.fn(), - delete: jest.fn(), - get: jest.fn(), - has: jest.fn(), - entries: jest.fn(() => []), - append: jest.fn(), - toString: jest.fn(() => ''), - })), - })); - jest.mock('lib/getMinMax', () => ({ __esModule: true, default: jest.fn().mockImplementation(() => ({ diff --git a/frontend/src/container/LogDetailedView/ContextView/__tests__/ContextLogRenderer.test.tsx b/frontend/src/container/LogDetailedView/ContextView/__tests__/ContextLogRenderer.test.tsx index c69e83c030db..5066ca0df609 100644 --- a/frontend/src/container/LogDetailedView/ContextView/__tests__/ContextLogRenderer.test.tsx +++ b/frontend/src/container/LogDetailedView/ContextView/__tests__/ContextLogRenderer.test.tsx @@ -32,20 +32,6 @@ import { // Mock the useContextLogData hook const mockHandleRunQuery = jest.fn(); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('container/OptionsMenu', () => ({ useOptionsMenu: (): any => ({ options: { diff --git a/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx b/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx index d9fee0694bcd..f30864174c22 100644 --- a/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx +++ b/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx @@ -73,20 +73,6 @@ jest.mock('hooks/useSafeNavigate', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({ __esModule: true, useGetExplorerQueryRange: jest.fn(), diff --git a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx index f536bf6c6a73..20cb3fc62b8f 100644 --- a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx +++ b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx @@ -7,18 +7,16 @@ import { logsresponse } from 'mocks-server/__mockdata__/query_range'; import { server } from 'mocks-server/server'; import { rest } from 'msw'; import LogsExplorer from 'pages/LogsExplorer'; -import { QueryBuilderContext } from 'providers/QueryBuilder'; import React from 'react'; -import { I18nextProvider } from 'react-i18next'; -import { MemoryRouter } from 'react-router-dom-v5-compat'; import { VirtuosoMockContext } from 'react-virtuoso'; -import i18n from 'ReactI18'; import { act, + AllTheProviders, fireEvent, render, RenderResult, screen, + userEvent, waitFor, } from 'tests/test-utils'; import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData'; @@ -91,20 +89,6 @@ getStateSpy.mockImplementation(() => { return originalState; }); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: (): { search: string; pathname: string } => ({ @@ -277,9 +261,7 @@ describe.skip('LogsExplorerViews Pagination', () => { act(() => { renderResult = render( - - - + , ); }); @@ -453,13 +435,14 @@ function LogsExplorerWithMockContext({ ); return ( - - - - - - - + + + + + ); } @@ -536,13 +519,12 @@ describe('Logs Explorer -> stage and run query', () => { const initialEnd = initialPayload.end; // Click the Stage & Run Query button - await act(async () => { - fireEvent.click( - screen.getByRole('button', { - name: /stage & run query/i, - }), - ); - }); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click( + screen.getByRole('button', { + name: /stage & run query/i, + }), + ); // Wait for additional API calls to be made after clicking Stage & Run Query await waitFor( diff --git a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx index be14befffd71..bf9690fb7651 100644 --- a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx +++ b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx @@ -33,20 +33,6 @@ const lodsQueryServerRequest = (): void => ), ); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - // mocking the graph components in this test as this should be handled separately jest.mock( 'container/TimeSeriesView/TimeSeriesView', diff --git a/frontend/src/container/LogsPanelTable/__tests__/LogsPanelComponent.test.tsx b/frontend/src/container/LogsPanelTable/__tests__/LogsPanelComponent.test.tsx index f3fdb1756d3d..816c6d5d997e 100644 --- a/frontend/src/container/LogsPanelTable/__tests__/LogsPanelComponent.test.tsx +++ b/frontend/src/container/LogsPanelTable/__tests__/LogsPanelComponent.test.tsx @@ -18,10 +18,6 @@ const MOCK_SEARCH_PARAMS = '?graphType=list&widgetId=36a7b342-c642-4b92-abe4-cb833a244786&compositeQuery=%7B%22id%22%3A%22b325ac88-5e75-4117-a38c-1a2a7caf8115%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateOperator%22%3A%22noop%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22------%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22isColumn%22%3Afalse%2C%22type%22%3A%22%22%2C%22isJSON%22%3Afalse%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3A60%2C%22having%22%3A%5B%5D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%7B%22columnName%22%3A%22timestamp%22%2C%22order%22%3A%22desc%22%7D%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%2C%22offset%22%3A0%2C%22pageSize%22%3A100%7D%5D%2C%22queryFormulas%22%3A%5B%5D%7D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22queryType%22%3A%22builder%22%7D&relativeTime=30m&options=%7B%22selectColumns%22%3A%5B%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22list%22%2C%22fontSize%22%3A%22small%22%7D'; // Mocks -jest.mock('uplot', () => ({ - paths: { spline: jest.fn(), bars: jest.fn() }, - default: jest.fn(() => ({ paths: { spline: jest.fn(), bars: jest.fn() } })), -})); jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({ __esModule: true, diff --git a/frontend/src/container/MetricsExplorer/Explorer/__tests__/Explorer.test.tsx b/frontend/src/container/MetricsExplorer/Explorer/__tests__/Explorer.test.tsx index 0457a5cc7b57..8916144ad93c 100644 --- a/frontend/src/container/MetricsExplorer/Explorer/__tests__/Explorer.test.tsx +++ b/frontend/src/container/MetricsExplorer/Explorer/__tests__/Explorer.test.tsx @@ -68,19 +68,6 @@ jest.mock('hooks/useNotifications', () => ({ }, }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: (): any => ({ diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphView.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphView.test.tsx index ab57573501bd..6cb51d2b72f6 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphView.test.tsx +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/GraphView.test.tsx @@ -15,12 +15,6 @@ import { TimeAggregationOptions, } from '../types'; -jest.mock('uplot', () => - jest.fn().mockImplementation(() => ({ - destroy: jest.fn(), - })), -); - const mockResizeObserver = jest.fn(); mockResizeObserver.mockImplementation(() => ({ observe: (): void => undefined, diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/Inspect.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/Inspect.test.tsx index 22a8a474c978..be3bf38f36b7 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/__tests__/Inspect.test.tsx +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/Inspect.test.tsx @@ -76,12 +76,6 @@ jest isLoading: false, } as any); -jest.mock('uplot', () => - jest.fn().mockImplementation(() => ({ - destroy: jest.fn(), - })), -); - jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: (): { pathname: string } => ({ diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/__tests__/DashboardsAndAlertsPopover.test.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/__tests__/DashboardsAndAlertsPopover.test.tsx index 1f924519d649..f339a6e95260 100644 --- a/frontend/src/container/MetricsExplorer/MetricDetails/__tests__/DashboardsAndAlertsPopover.test.tsx +++ b/frontend/src/container/MetricsExplorer/MetricDetails/__tests__/DashboardsAndAlertsPopover.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { QueryParams } from 'constants/query'; -import * as useSafeNavigate from 'hooks/useSafeNavigate'; import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover'; @@ -24,9 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2]; const mockDashboards = [mockDashboard1, mockDashboard2]; const mockSafeNavigate = jest.fn(); -jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({ - safeNavigate: mockSafeNavigate, -}); +jest.mock('hooks/useSafeNavigate', () => ({ + useSafeNavigate: (): any => ({ + safeNavigate: mockSafeNavigate, + }), +})); const mockSetQuery = jest.fn(); const mockUrlQuery = { diff --git a/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx b/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx index 276111c77c6d..48522c686875 100644 --- a/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx @@ -11,19 +11,6 @@ import store from 'store'; import Summary from '../Summary'; import { TreemapViewType } from '../types'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); jest.mock('d3-hierarchy', () => ({ stratify: jest.fn().mockReturnValue({ id: jest.fn().mockReturnValue({ diff --git a/frontend/src/container/PipelinePage/Layouts/ChangeHistory/tests/ChangeHistory.test.tsx b/frontend/src/container/PipelinePage/Layouts/ChangeHistory/tests/ChangeHistory.test.tsx index f805efed689a..5b675039a85c 100644 --- a/frontend/src/container/PipelinePage/Layouts/ChangeHistory/tests/ChangeHistory.test.tsx +++ b/frontend/src/container/PipelinePage/Layouts/ChangeHistory/tests/ChangeHistory.test.tsx @@ -10,20 +10,6 @@ import store from 'store'; import ChangeHistory from '../index'; import { pipelineData, pipelineDataHistory } from './testUtils'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/frontend/src/container/PipelinePage/tests/AddNewPipeline.test.tsx b/frontend/src/container/PipelinePage/tests/AddNewPipeline.test.tsx index 9e9f245e1b04..36702718f2e1 100644 --- a/frontend/src/container/PipelinePage/tests/AddNewPipeline.test.tsx +++ b/frontend/src/container/PipelinePage/tests/AddNewPipeline.test.tsx @@ -5,20 +5,6 @@ import { PipelineData } from 'types/api/pipeline/def'; import { pipelineMockData } from '../mocks/pipeline'; import AddNewPipeline from '../PipelineListsView/AddNewPipeline'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - export function matchMedia(): void { Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/frontend/src/container/PipelinePage/tests/AddNewProcessor.test.tsx b/frontend/src/container/PipelinePage/tests/AddNewProcessor.test.tsx index e1e961e125aa..2f51b7ba892f 100644 --- a/frontend/src/container/PipelinePage/tests/AddNewProcessor.test.tsx +++ b/frontend/src/container/PipelinePage/tests/AddNewProcessor.test.tsx @@ -11,20 +11,6 @@ jest.mock('../PipelineListsView/AddNewProcessor/config', () => ({ DEFAULT_PROCESSOR_TYPE: 'json_parser', })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - const selectedProcessorData = { id: '1', orderId: 1, diff --git a/frontend/src/container/PipelinePage/tests/DeleteAction.test.tsx b/frontend/src/container/PipelinePage/tests/DeleteAction.test.tsx index 3b2fdfeb34af..451ef8807f6a 100644 --- a/frontend/src/container/PipelinePage/tests/DeleteAction.test.tsx +++ b/frontend/src/container/PipelinePage/tests/DeleteAction.test.tsx @@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom'; import i18n from 'ReactI18'; import store from 'store'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('PipelinePage container test', () => { it('should render DeleteAction section', () => { const { asFragment } = render( diff --git a/frontend/src/container/PipelinePage/tests/DragAction.test.tsx b/frontend/src/container/PipelinePage/tests/DragAction.test.tsx index 9f64714072a8..168b3f042f32 100644 --- a/frontend/src/container/PipelinePage/tests/DragAction.test.tsx +++ b/frontend/src/container/PipelinePage/tests/DragAction.test.tsx @@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom'; import i18n from 'ReactI18'; import store from 'store'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('PipelinePage container test', () => { it('should render DragAction section', () => { const { asFragment } = render( diff --git a/frontend/src/container/PipelinePage/tests/EditAction.test.tsx b/frontend/src/container/PipelinePage/tests/EditAction.test.tsx index 56dd77960094..c52991bf6d96 100644 --- a/frontend/src/container/PipelinePage/tests/EditAction.test.tsx +++ b/frontend/src/container/PipelinePage/tests/EditAction.test.tsx @@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom'; import i18n from 'ReactI18'; import store from 'store'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('PipelinePage container test', () => { it('should render EditAction section', () => { const { asFragment } = render( diff --git a/frontend/src/container/PipelinePage/tests/PipelineActions.test.tsx b/frontend/src/container/PipelinePage/tests/PipelineActions.test.tsx index d472f4745c12..83f503107b8f 100644 --- a/frontend/src/container/PipelinePage/tests/PipelineActions.test.tsx +++ b/frontend/src/container/PipelinePage/tests/PipelineActions.test.tsx @@ -8,20 +8,6 @@ import store from 'store'; import { pipelineMockData } from '../mocks/pipeline'; import PipelineActions from '../PipelineListsView/TableComponents/PipelineActions'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('PipelinePage container test', () => { it('should render PipelineActions section', () => { const { asFragment } = render( diff --git a/frontend/src/container/PipelinePage/tests/PipelineExpandView.test.tsx b/frontend/src/container/PipelinePage/tests/PipelineExpandView.test.tsx index 818156a72397..b077481619b8 100644 --- a/frontend/src/container/PipelinePage/tests/PipelineExpandView.test.tsx +++ b/frontend/src/container/PipelinePage/tests/PipelineExpandView.test.tsx @@ -3,20 +3,6 @@ import { render } from 'tests/test-utils'; import { pipelineMockData } from '../mocks/pipeline'; import PipelineExpandView from '../PipelineListsView/PipelineExpandView'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - beforeAll(() => { Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/frontend/src/container/PipelinePage/tests/PipelineListsView.test.tsx b/frontend/src/container/PipelinePage/tests/PipelineListsView.test.tsx index 3542e0b3165c..f0cba76f621d 100644 --- a/frontend/src/container/PipelinePage/tests/PipelineListsView.test.tsx +++ b/frontend/src/container/PipelinePage/tests/PipelineListsView.test.tsx @@ -6,20 +6,6 @@ import { findByText, fireEvent, render, waitFor } from 'tests/test-utils'; import { pipelineApiResponseMockData } from '../mocks/pipeline'; import PipelineListsView from '../PipelineListsView'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - // Mock useUrlQuery hook const mockUrlQuery = { get: jest.fn(), diff --git a/frontend/src/container/PipelinePage/tests/PipelinePageLayout.test.tsx b/frontend/src/container/PipelinePage/tests/PipelinePageLayout.test.tsx index f99f98368cfc..ff924ff8acb8 100644 --- a/frontend/src/container/PipelinePage/tests/PipelinePageLayout.test.tsx +++ b/frontend/src/container/PipelinePage/tests/PipelinePageLayout.test.tsx @@ -4,20 +4,6 @@ import { v4 } from 'uuid'; import PipelinePageLayout from '../Layouts/Pipeline'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - beforeAll(() => { Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/frontend/src/container/PipelinePage/tests/TagInput.test.tsx b/frontend/src/container/PipelinePage/tests/TagInput.test.tsx index e95efb6715ed..24cedc2eb0c8 100644 --- a/frontend/src/container/PipelinePage/tests/TagInput.test.tsx +++ b/frontend/src/container/PipelinePage/tests/TagInput.test.tsx @@ -7,20 +7,6 @@ import store from 'store'; import TagInput from '../components/TagInput'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('Pipeline Page', () => { it('should render TagInput section', () => { const { asFragment } = render( diff --git a/frontend/src/container/PipelinePage/tests/Tags.test.tsx b/frontend/src/container/PipelinePage/tests/Tags.test.tsx index 5dc362e957c2..050c6975aef7 100644 --- a/frontend/src/container/PipelinePage/tests/Tags.test.tsx +++ b/frontend/src/container/PipelinePage/tests/Tags.test.tsx @@ -1,24 +1,11 @@ -import { render } from '@testing-library/react'; import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags'; -import { I18nextProvider } from 'react-i18next'; -import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; -import i18n from 'ReactI18'; -import store from 'store'; +import { render } from 'tests/test-utils'; const tags = ['server', 'app']; describe('PipelinePage container test', () => { it('should render Tags section', () => { - const { asFragment } = render( - - - - - - - , - ); + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/frontend/src/container/PipelinePage/tests/utils.test.ts b/frontend/src/container/PipelinePage/tests/utils.test.ts index 707ad06c2d1d..c21e8c5a4b59 100644 --- a/frontend/src/container/PipelinePage/tests/utils.test.ts +++ b/frontend/src/container/PipelinePage/tests/utils.test.ts @@ -11,20 +11,6 @@ import { getTableColumn, } from '../PipelineListsView/utils'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('Utils testing of Pipeline Page', () => { test('it should be check form field of add pipeline', () => { expect(pipelineFields.length).toBe(3); diff --git a/frontend/src/container/PlannedDowntime/__test__/PlannedDowntime.test.tsx b/frontend/src/container/PlannedDowntime/__test__/PlannedDowntime.test.tsx index 7e1bda705f3a..9ded9fdc8e07 100644 --- a/frontend/src/container/PlannedDowntime/__test__/PlannedDowntime.test.tsx +++ b/frontend/src/container/PlannedDowntime/__test__/PlannedDowntime.test.tsx @@ -6,7 +6,7 @@ import { PlannedDowntime } from '../PlannedDowntime'; describe('PlannedDowntime Component', () => { it('renders the PlannedDowntime component properly', () => { - render(, {}, 'ADMIN'); + render(, {}, { role: 'ADMIN' }); // Check if title is rendered expect(screen.getByText('Planned Downtime')).toBeInTheDocument(); @@ -30,7 +30,7 @@ describe('PlannedDowntime Component', () => { }); it('disables the "New downtime" button for users with VIEWER role', () => { - render(, {}, USER_ROLES.VIEWER); + render(, {}, { role: USER_ROLES.VIEWER }); // Check if "New downtime" button is disabled for VIEWER const newDowntimeButton = screen.getByRole('button', { diff --git a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss index 059c6cabdcdf..880125d84d08 100644 --- a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss +++ b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss @@ -58,12 +58,16 @@ padding: 2px 8px; align-items: center; width: fit-content; - max-width: calc(100% - 120px); /* Reserve space for action buttons */ + max-width: 100%; gap: 8px; border-radius: 50px; border: 1px solid var(--bg-slate-400); background: var(--bg-slate-500); + .copy-wrapper { + overflow: hidden; + } + .item-value { color: var(--bg-vanilla-400); font-family: Inter; diff --git a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx index 9ff49bcac5b4..e6a909d02281 100644 --- a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx +++ b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx @@ -97,11 +97,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
- - - {item.value} - {' '} - +
+ + + {item.value} + + +
trace details top bar border-left: 1px solid var(--bg-slate-400); - overflow-y: auto; + overflow-y: auto !important; &:not(&-docked) { min-width: 450px; } diff --git a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx index 9a57d8756fd0..8ca7435d7be1 100644 --- a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx +++ b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx @@ -1,7 +1,5 @@ -import { fireEvent, screen } from '@testing-library/react'; -import { useSafeNavigate } from 'hooks/useSafeNavigate'; import useUrlQuery from 'hooks/useUrlQuery'; -import { render } from 'tests/test-utils'; +import { fireEvent, render, screen } from 'tests/test-utils'; import { Span } from 'types/api/trace/getTraceV2'; import { SpanDuration } from '../Success'; @@ -15,7 +13,6 @@ const DIMMED_SPAN_CLASS = 'dimmed-span'; const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span'; // Mock the hooks -jest.mock('hooks/useSafeNavigate'); jest.mock('hooks/useUrlQuery'); jest.mock('@signozhq/badge', () => ({ Badge: jest.fn(), @@ -52,24 +49,17 @@ const mockTraceMetadata = { hasMissingSpans: false, }; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); +const mockSafeNavigate = jest.fn(); + +jest.mock('hooks/useSafeNavigate', () => ({ + useSafeNavigate: (): any => ({ + safeNavigate: mockSafeNavigate, + }), +})); describe('SpanDuration', () => { const mockSetSelectedSpan = jest.fn(); const mockUrlQuerySet = jest.fn(); - const mockSafeNavigate = jest.fn(); const mockUrlQueryGet = jest.fn(); beforeEach(() => { @@ -81,11 +71,6 @@ describe('SpanDuration', () => { get: mockUrlQueryGet, toString: () => 'spanId=test-span-id', }); - - // Mock safe navigate hook - (useSafeNavigate as jest.Mock).mockReturnValue({ - safeNavigate: mockSafeNavigate, - }); }); it('updates URL and selected span when clicked', () => { diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 7cb99ad0761a..25cfbfb5abaa 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -16,8 +16,6 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, - staleTime: 2 * 60 * 1000, // 2 minutes - data becomes stale after 2 minutes - cacheTime: 5 * 60 * 1000, // 5 minutes - cache entries are garbage collected after 5 minutes retry(failureCount, error): boolean { if ( // in case of manually throwing errors please make sure to send error.response.status diff --git a/frontend/src/mocks-server/server.ts b/frontend/src/mocks-server/server.ts index 096e00d32336..e0721635a374 100644 --- a/frontend/src/mocks-server/server.ts +++ b/frontend/src/mocks-server/server.ts @@ -1,7 +1,10 @@ // src/mocks/server.js +import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { handlers } from './handlers'; // This configures a request mocking server with the given request handlers. export const server = setupServer(...handlers); + +export { rest }; diff --git a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx index cd7b6e7069ea..7ee4ab7512dd 100644 --- a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx +++ b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx @@ -28,20 +28,6 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - // mocking the graph components in this test as this should be handled separately jest.mock( 'container/TimeSeriesView/TimeSeriesView', diff --git a/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx b/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx index f5af6a705c9d..5db650a70083 100644 --- a/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx +++ b/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx @@ -35,21 +35,16 @@ jest.mock('container/TraceFlameGraph/index.tsx', () => ({ default: (): JSX.Element =>
TraceFlameGraph
, })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('TraceDetail', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-10-20')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('should render tracedetail', async () => { const { findByText, getByText, getAllByText, getByPlaceholderText } = render( diff --git a/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx b/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx index a146b97740ea..f813679ebd2d 100644 --- a/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx +++ b/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx @@ -127,7 +127,11 @@ function TraceDetailsV2(): JSX.Element { ]; return ( - + ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - - const uplotMock = jest.fn(() => ({ - paths, - })); - - return { - paths, - default: uplotMock, - }; -}); - jest.mock( 'components/Uplot/Uplot', () => @@ -181,32 +164,31 @@ const checkFilterValues = ( }; const renderWithTracesExplorerRouter = ( - component: React.ReactNode, + component: React.ReactElement, initialEntries: string[] = [ '/traces-explorer/?panelType=list&selectedExplorerView=list', ], ): ReturnType => render( - - - {component} - - , + component, + {}, + { + initialRoute: initialEntries[0], + queryBuilderOverrides: qbProviderValue, + }, ); describe('TracesExplorer - Filters', () => { // Initial filter panel rendering // Test the initial state like which filters section are opened, default state of duration slider, etc. it('should render the Trace filter', async () => { - const { getByText, getAllByText, getByTestId } = render( - - - , - ); + const { + getByText, + getAllByText, + getByTestId, + } = renderWithTracesExplorerRouter(, [ + `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/?panelType=list&selectedExplorerView=list`, + ]); checkFilterValues(getByText, getAllByText); @@ -249,8 +231,12 @@ describe('TracesExplorer - Filters', () => { it('filter panel actions', async () => { const { getByTestId } = render( - + , , + {}, + { + initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list', + }, ); // Check if the section is closed @@ -275,23 +261,21 @@ describe('TracesExplorer - Filters', () => { }); it('checking filters should update the query', async () => { - const { getByText } = renderWithTracesExplorerRouter( - , + {}, + { + queryBuilderOverrides: { + ...qbProviderValue, + currentQuery: { + ...initialQueriesMap.traces, + builder: { + ...initialQueriesMap.traces.builder, + queryData: [initialQueryBuilderFormValues], }, - redirectWithQueryBuilderData, - } as any - } - > - - , + }, + }, + }, ); const okCheckbox = getByText('Ok'); @@ -343,9 +327,7 @@ describe('TracesExplorer - Filters', () => { .spyOn(compositeQueryHook, 'useGetCompositeQueryParam') .mockReturnValue(compositeQuery); - const { findByText, getByTestId } = renderWithTracesExplorerRouter( - , - ); + const { findByText, getByTestId } = render(); // check if the default query is applied - composite query has filters - serviceName : demo-app and name : HTTP GET /customer expect(await findByText('demo-app')).toBeInTheDocument(); @@ -369,8 +351,12 @@ describe('TracesExplorer - Filters', () => { }, }); - const { getByText, getAllByText } = renderWithTracesExplorerRouter( + const { getByText, getAllByText } = render( , + {}, + { + initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list', + }, ); checkFilterValues(getByText, getAllByText); @@ -394,31 +380,28 @@ describe('TracesExplorer - Filters', () => { }, }); - const { getByText, getAllByText } = renderWithTracesExplorerRouter( - , - ); + const { getByText, getAllByText } = render(); checkFilterValues(getByText, getAllByText); }); it('should clear filter on clear & reset button click', async () => { - const { getByText, getByTestId } = renderWithTracesExplorerRouter( - , + {}, + { + initialRoute: '/traces-explorer/?panelType=list&selectedExplorerView=list', + queryBuilderOverrides: { + currentQuery: { + ...initialQueriesMap.traces, + builder: { + ...initialQueriesMap.traces.builder, + queryData: [initialQueryBuilderFormValues], }, - redirectWithQueryBuilderData, - } as any - } - > - - , + }, + redirectWithQueryBuilderData, + }, + }, ); // check for the status section content diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx index fd279af05cfc..972a27d0b0af 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx @@ -55,7 +55,7 @@ describe('WorkspaceLocked', () => { ), ); - render(, {}, 'VIEWER'); + render(, {}, { role: 'VIEWER' }); const updateCreditCardBtn = await screen.queryByRole('button', { name: /Continue My Journey/i, }); diff --git a/frontend/src/providers/Dashboard/Dashboard.tsx b/frontend/src/providers/Dashboard/Dashboard.tsx index a51ac9439f42..c8aa3c7d51b0 100644 --- a/frontend/src/providers/Dashboard/Dashboard.tsx +++ b/frontend/src/providers/Dashboard/Dashboard.tsx @@ -269,7 +269,7 @@ export function DashboardProvider({ return data; }; const dashboardResponse = useQuery( - [REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params], + [REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params, dashboardId], { enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn, queryFn: async () => { diff --git a/frontend/src/providers/Dashboard/__tests__/Dashboard.test.tsx b/frontend/src/providers/Dashboard/__tests__/Dashboard.test.tsx new file mode 100644 index 000000000000..feb7905cbb2c --- /dev/null +++ b/frontend/src/providers/Dashboard/__tests__/Dashboard.test.tsx @@ -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 ( +
+
{dashboardId}
+
{dashboardResponse.status}
+
{dashboardResponse.isLoading.toString()}
+
+ {dashboardResponse.isFetching.toString()} +
+
+ ); +} + +// 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( + + + + + + + , + ); +} + +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( + + + + + + + , + ); + + 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( + + + + + + + , + ); + + await waitFor(() => { + expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId1 }); + }); + + // Second dashboard + mockUseRouteMatch.mockReturnValue({ + path: ROUTES.DASHBOARD, + url: `/dashboard/${dashboardId2}`, + isExact: true, + params: { dashboardId: dashboardId2 }, + }); + + rerender( + + + + + + + , + ); + + 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, + ]); + }); + }); +}); diff --git a/frontend/src/tests/README.md b/frontend/src/tests/README.md new file mode 100644 index 000000000000..e759b3045f3a --- /dev/null +++ b/frontend/src/tests/README.md @@ -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(, 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`, 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(, 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. diff --git a/frontend/src/tests/test-utils.tsx b/frontend/src/tests/test-utils.tsx index 3ca6efcc412b..94d36643373b 100644 --- a/frontend/src/tests/test-utils.tsx +++ b/frontend/src/tests/test-utils.tsx @@ -2,18 +2,20 @@ import { render, RenderOptions, RenderResult } from '@testing-library/react'; import { FeatureKeys } from 'constants/features'; import { ORG_PREFERENCES } from 'constants/orgPreferences'; -import ROUTES from 'constants/routes'; import { ResourceProvider } from 'hooks/useResourceAttribute'; import { AppContext } from 'providers/App/App'; import { IAppContext } from 'providers/App/types'; import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; -import { QueryBuilderProvider } from 'providers/QueryBuilder'; +import { + QueryBuilderContext, + QueryBuilderProvider, +} from 'providers/QueryBuilder'; import TimezoneProvider from 'providers/Timezone'; import React, { ReactElement } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { Provider } from 'react-redux'; -import { BrowserRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import store from 'store'; @@ -23,24 +25,27 @@ import { LicenseState, LicenseStatus, } from 'types/api/licensesV3/getActive'; +import { QueryBuilderContextType } from 'types/common/queryBuilder'; import { ROLES, USER_ROLES } from 'types/roles'; +// import { MemoryRouter as V5MemoryRouter } from 'react-router-dom-v5-compat'; const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, + retry: false, }, }, }); beforeEach(() => { - jest.useFakeTimers(); + // jest.useFakeTimers(); jest.setSystemTime(new Date('2023-10-20')); }); afterEach(() => { queryClient.clear(); - jest.useRealTimers(); + // jest.useRealTimers(); }); const mockStore = configureStore([thunk]); @@ -85,24 +90,6 @@ jest.mock('react-i18next', () => ({ }), })); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: (): { pathname: string } => ({ - pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`, - }), -})); - -jest.mock('hooks/useSafeNavigate', () => ({ - useSafeNavigate: (): any => ({ - safeNavigate: jest.fn(), - }), -})); - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useNavigationType: (): any => 'PUSH', -})); - export function getAppContextMock( role: string, appContextOverrides?: Partial, @@ -253,48 +240,96 @@ export function getAppContextMock( export function AllTheProviders({ children, - role, // Accept the role as a prop + role, appContextOverrides, + queryBuilderOverrides, + initialRoute, }: { children: React.ReactNode; - role: string; // Define the role prop - appContextOverrides: Partial; + role?: string; + appContextOverrides?: Partial; + queryBuilderOverrides?: Partial; + initialRoute?: string; }): ReactElement { + // Set default values + const roleValue = role || 'ADMIN'; + const appContextOverridesValue = appContextOverrides || {}; + const initialRouteValue = initialRoute || '/'; + + const queryBuilderContent = queryBuilderOverrides ? ( + + {children} + + ) : ( + {children} + ); + return ( - - - - - - + + + + + + - {children} + {queryBuilderContent} - - - - - - + + + + + +
); } +AllTheProviders.defaultProps = { + role: 'ADMIN', + appContextOverrides: {}, + queryBuilderOverrides: undefined, + initialRoute: '/', +}; + +interface ProviderProps { + role?: string; + appContextOverrides?: Partial; + queryBuilderOverrides?: Partial; + initialRoute?: string; +} + const customRender = ( ui: ReactElement, options?: Omit, - role = 'ADMIN', // Set a default role - appContextOverrides?: Partial, -): RenderResult => - render(ui, { + providerProps: ProviderProps = {}, +): RenderResult => { + const { + role = 'ADMIN', + appContextOverrides = {}, + queryBuilderOverrides, + initialRoute = '/', + } = providerProps; + + return render(ui, { wrapper: () => ( - + {ui} ), ...options, }); +}; export * from '@testing-library/react'; +export { default as userEvent } from '@testing-library/user-event'; export { customRender as render }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 13f6e261702e..16da26eb0bc5 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -16122,6 +16122,13 @@ robust-predicates@^3.0.2: resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== +rrule@2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e" + integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw== + dependencies: + tslib "^2.4.0" + rtl-css-js@^1.14.0, rtl-css-js@^1.16.1: version "1.16.1" resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz" diff --git a/pkg/authz/authz.go b/pkg/authz/authz.go index 52eb59392def..26be6a9d39d4 100644 --- a/pkg/authz/authz.go +++ b/pkg/authz/authz.go @@ -4,6 +4,7 @@ import ( "context" "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/types/authtypes" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -12,4 +13,7 @@ type AuthZ interface { // Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o). Check(context.Context, *openfgav1.CheckRequestTupleKey) error + + // CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does. + CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, authtypes.Typeable, ...authtypes.Selector) error } diff --git a/pkg/authz/openfgaauthz/provider.go b/pkg/authz/openfgaauthz/provider.go index bc66d5a41105..8903656ef5ac 100644 --- a/pkg/authz/openfgaauthz/provider.go +++ b/pkg/authz/openfgaauthz/provider.go @@ -194,3 +194,40 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe return nil } + +func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selector authtypes.Selector, parentTypeable authtypes.Typeable, parentSelectors ...authtypes.Selector) error { + subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{}) + if err != nil { + return err + } + + tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...) + if err != nil { + return err + } + + check, err := provider.sequentialCheck(ctx, tuples) + if err != nil { + return err + } + if !check { + return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", subject, relation.StringValue(), typeable.Type().StringValue()) + } + + return nil +} + +func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) { + for _, tupleReq := range tuplesReq { + err := provider.Check(ctx, tupleReq) + if err == nil { + return true, nil + } + if errors.Ast(err, errors.TypeInternal) { + // return at the first internal error as the evaluation will be incorrect + return false, err + } + } + + return false, nil +} diff --git a/pkg/authz/openfgaschema/base.fga b/pkg/authz/openfgaschema/base.fga index 7275c3137c59..192009b751a7 100644 --- a/pkg/authz/openfgaschema/base.fga +++ b/pkg/authz/openfgaschema/base.fga @@ -8,6 +8,7 @@ type role type organisation relations - define admin: [role#assignee] - define editor: [role#assignee] or admin - define viewer: [role#assignee] or editor \ No newline at end of file + define create: [role#assignee] + define read: [role#assignee] + define update: [role#assignee] + define delete: [role#assignee] \ No newline at end of file diff --git a/pkg/http/middleware/authz.go b/pkg/http/middleware/authz.go index 63dd785ce983..6abd9aeaecbc 100644 --- a/pkg/http/middleware/authz.go +++ b/pkg/http/middleware/authz.go @@ -106,11 +106,15 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc { }) } -// each individual APIs should be responsible for defining the relation and the object being accessed, subject will be derived from the request -func (middleware *AuthZ) Check(next http.HandlerFunc, relation string) http.HandlerFunc { +func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ authtypes.Typeable, _ authtypes.SelectorCallbackFn) http.HandlerFunc { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - checkRequestTupleKey := authtypes.NewTuple("", "", "") - err := middleware.authzService.Check(req.Context(), checkRequestTupleKey) + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID), nil) if err != nil { render.Error(rw, err) return diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index b81db76d1cce..2549810be594 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -8,6 +8,8 @@ import ( "net/http" _ "net/http/pprof" // http profiler + "github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore" + "github.com/gorilla/handlers" "github.com/SigNoz/signoz/pkg/alertmanager" @@ -308,20 +310,24 @@ func makeRulesManager( querier querier.Querier, logger *slog.Logger, ) (*rules.Manager, error) { + ruleStore := sqlrulestore.NewRuleStore(sqlstore) + maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore) // create manager opts managerOpts := &rules.ManagerOptions{ - TelemetryStore: telemetryStore, - Prometheus: prometheus, - Context: context.Background(), - Logger: zap.L(), - Reader: ch, - Querier: querier, - SLogger: logger, - Cache: cache, - EvalDelay: constants.GetEvalDelay(), - SQLStore: sqlstore, - OrgGetter: orgGetter, - Alertmanager: alertmanager, + TelemetryStore: telemetryStore, + Prometheus: prometheus, + Context: context.Background(), + Logger: zap.L(), + Reader: ch, + Querier: querier, + SLogger: logger, + Cache: cache, + EvalDelay: constants.GetEvalDelay(), + OrgGetter: orgGetter, + Alertmanager: alertmanager, + RuleStore: ruleStore, + MaintenanceStore: maintenanceStore, + SqlStore: sqlstore, } // create Manager diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index 5264b28f85ff..1a57414f13e2 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -22,7 +22,6 @@ import ( querierV5 "github.com/SigNoz/signoz/pkg/querier" "github.com/SigNoz/signoz/pkg/query-service/interfaces" "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/telemetrystore" "github.com/SigNoz/signoz/pkg/types" @@ -98,8 +97,10 @@ type ManagerOptions struct { PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error) PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError) Alertmanager alertmanager.Alertmanager - SQLStore sqlstore.SQLStore OrgGetter organization.Getter + RuleStore ruletypes.RuleStore + MaintenanceStore ruletypes.MaintenanceStore + SqlStore sqlstore.SQLStore } // The Manager manages recording and alerting rules. @@ -207,14 +208,12 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { // by calling the Run method. func NewManager(o *ManagerOptions) (*Manager, error) { o = defaultOptions(o) - ruleStore := sqlrulestore.NewRuleStore(o.SQLStore) - maintenanceStore := sqlrulestore.NewMaintenanceStore(o.SQLStore) m := &Manager{ tasks: map[string]Task{}, rules: map[string]Rule{}, - ruleStore: ruleStore, - maintenanceStore: maintenanceStore, + ruleStore: o.RuleStore, + maintenanceStore: o.MaintenanceStore, opts: o, block: make(chan struct{}), logger: o.Logger, @@ -223,8 +222,8 @@ func NewManager(o *ManagerOptions) (*Manager, error) { prepareTaskFunc: o.PrepareTaskFunc, prepareTestRuleFunc: o.PrepareTestRuleFunc, alertmanager: o.Alertmanager, - sqlstore: o.SQLStore, orgGetter: o.OrgGetter, + sqlstore: o.SqlStore, } return m, nil @@ -896,33 +895,37 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID) return nil, err } - // storedRule holds the current stored rule from DB - patchedRule := ruletypes.PostableRule{} - if err := json.Unmarshal([]byte(ruleStr), &patchedRule); err != nil { - zap.L().Error("failed to unmarshal stored rule with given id", zap.String("id", id.StringValue()), zap.Error(err)) + storedRule := ruletypes.PostableRule{} + if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil { + zap.L().Error("failed to unmarshal rule from db", zap.String("id", id.StringValue()), zap.Error(err)) + return nil, err + } + + if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil { + zap.L().Error("failed to unmarshal patched rule with given id", zap.String("id", id.StringValue()), zap.Error(err)) return nil, err } // deploy or un-deploy task according to patched (new) rule state - if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &patchedRule); err != nil { + if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil { zap.L().Error("failed to sync stored rule state with the task", zap.String("taskName", taskName), zap.Error(err)) return nil, err } - // prepare rule json to write to update db - patchedRuleBytes, err := json.Marshal(patchedRule) + newStoredJson, err := json.Marshal(&storedRule) if err != nil { + zap.L().Error("failed to marshal new stored rule with given id", zap.String("id", id.StringValue()), zap.Error(err)) return nil, err } now := time.Now() - storedJSON.Data = string(patchedRuleBytes) + storedJSON.Data = string(newStoredJson) storedJSON.UpdatedBy = claims.Email storedJSON.UpdatedAt = now err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil }) if err != nil { - if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &patchedRule); err != nil { + if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil { zap.L().Error("failed to restore rule after patch failure", zap.String("taskName", taskName), zap.Error(err)) } return nil, err @@ -931,7 +934,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID) // prepare http response response := ruletypes.GettableRule{ Id: id.StringValue(), - PostableRule: patchedRule, + PostableRule: storedRule, } // fetch state of rule from memory diff --git a/pkg/query-service/rules/manager_test.go b/pkg/query-service/rules/manager_test.go new file mode 100644 index 000000000000..795a918345b1 --- /dev/null +++ b/pkg/query-service/rules/manager_test.go @@ -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()) + }) + } +} diff --git a/pkg/ruler/rulestore/rulestoretest/rule.go b/pkg/ruler/rulestore/rulestoretest/rule.go new file mode 100644 index 000000000000..01a2c3a87f8f --- /dev/null +++ b/pkg/ruler/rulestore/rulestoretest/rule.go @@ -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() +} diff --git a/pkg/telemetrylogs/stmt_builder_test.go b/pkg/telemetrylogs/stmt_builder_test.go index 1610a32a10cf..b19a4d86f10c 100644 --- a/pkg/telemetrylogs/stmt_builder_test.go +++ b/pkg/telemetrylogs/stmt_builder_test.go @@ -142,6 +142,36 @@ func TestStatementBuilderTimeSeries(t *testing.T) { }, expectedErr: nil, }, + { + name: "Time series with group by on materialized column", + requestType: qbtypes.RequestTypeTimeSeries, + query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{ + Signal: telemetrytypes.SignalLogs, + StepInterval: qbtypes.Step{Duration: 30 * time.Second}, + Aggregations: []qbtypes.LogAggregation{ + { + Expression: "count()", + }, + }, + Filter: &qbtypes.Filter{ + Expression: "service.name = 'cartservice'", + }, + Limit: 10, + GroupBy: []qbtypes.GroupByKey{ + { + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{ + Name: "materialized.key.name", + FieldContext: telemetrytypes.FieldContextAttribute, + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + }, + }, + }, + expected: qbtypes.Statement{ + Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`materialized.key.name`) GLOBAL IN (SELECT `materialized.key.name` FROM __limit_cte) GROUP BY ts, `materialized.key.name`", + Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)}, + }, + }, } fm := NewFieldMapper() diff --git a/pkg/types/authtypes/name.go b/pkg/types/authtypes/name.go new file mode 100644 index 000000000000..32994f54f568 --- /dev/null +++ b/pkg/types/authtypes/name.go @@ -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 +} diff --git a/pkg/types/authtypes/organization.go b/pkg/types/authtypes/organization.go new file mode 100644 index 000000000000..d1510cdf62c5 --- /dev/null +++ b/pkg/types/authtypes/organization.go @@ -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 +} diff --git a/pkg/types/authtypes/relation.go b/pkg/types/authtypes/relation.go new file mode 100644 index 000000000000..e0c1b8236d1c --- /dev/null +++ b/pkg/types/authtypes/relation.go @@ -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 } diff --git a/pkg/types/authtypes/resource.go b/pkg/types/authtypes/resource.go new file mode 100644 index 000000000000..0bbfc5f9c28d --- /dev/null +++ b/pkg/types/authtypes/resource.go @@ -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 +} diff --git a/pkg/types/authtypes/resources.go b/pkg/types/authtypes/resources.go new file mode 100644 index 000000000000..6213b5dd8e09 --- /dev/null +++ b/pkg/types/authtypes/resources.go @@ -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 +} diff --git a/pkg/types/authtypes/role.go b/pkg/types/authtypes/role.go new file mode 100644 index 000000000000..9467adb193f9 --- /dev/null +++ b/pkg/types/authtypes/role.go @@ -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 +} diff --git a/pkg/types/authtypes/selector.go b/pkg/types/authtypes/selector.go new file mode 100644 index 000000000000..1480dbdaca1f --- /dev/null +++ b/pkg/types/authtypes/selector.go @@ -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 +} diff --git a/pkg/types/authtypes/subject.go b/pkg/types/authtypes/subject.go new file mode 100644 index 000000000000..79ace130758a --- /dev/null +++ b/pkg/types/authtypes/subject.go @@ -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 +} diff --git a/pkg/types/authtypes/tuple.go b/pkg/types/authtypes/tuple.go deleted file mode 100644 index 7911954b45d7..000000000000 --- a/pkg/types/authtypes/tuple.go +++ /dev/null @@ -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} -} diff --git a/pkg/types/authtypes/typeable.go b/pkg/types/authtypes/typeable.go new file mode 100644 index 000000000000..2b07b2613710 --- /dev/null +++ b/pkg/types/authtypes/typeable.go @@ -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 } diff --git a/pkg/types/authtypes/user.go b/pkg/types/authtypes/user.go new file mode 100644 index 000000000000..9de8fd0c2cb6 --- /dev/null +++ b/pkg/types/authtypes/user.go @@ -0,0 +1,31 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(user) + +type user struct{} + +func (user *user) 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{TypeUser.StringValue(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + + return tuples, nil +} + +func (user *user) Type() Type { + return TypeUser +} diff --git a/pkg/types/ruletypes/api_params.go b/pkg/types/ruletypes/api_params.go index 9285b070fbe6..ff5e8917b6d1 100644 --- a/pkg/types/ruletypes/api_params.go +++ b/pkg/types/ruletypes/api_params.go @@ -23,6 +23,10 @@ const ( AlertTypeExceptions AlertType = "EXCEPTIONS_BASED_ALERT" ) +const ( + DefaultSchemaVersion = "v1" +) + type RuleDataKind string const ( @@ -51,11 +55,16 @@ type PostableRule struct { Version string `json:"version,omitempty"` - Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"` + Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"` + SchemaVersion string `json:"schemaVersion,omitempty"` } func (r *PostableRule) processRuleDefaults() error { + if r.SchemaVersion == "" { + r.SchemaVersion = DefaultSchemaVersion + } + if r.EvalWindow == 0 { r.EvalWindow = Duration(5 * time.Minute) } @@ -79,7 +88,7 @@ func (r *PostableRule) processRuleDefaults() error { } } //added alerts v2 fields - if r.RuleCondition.Thresholds == nil { + if r.SchemaVersion == DefaultSchemaVersion { thresholdName := CriticalThresholdName if r.Labels != nil { if severity, ok := r.Labels["severity"]; ok { @@ -98,15 +107,33 @@ func (r *PostableRule) processRuleDefaults() error { }}, } r.RuleCondition.Thresholds = &thresholdData + r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}} } } - if r.Evaluation == nil { - r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}} - } return r.Validate() } +func (r *PostableRule) MarshalJSON() ([]byte, error) { + type Alias PostableRule + + switch r.SchemaVersion { + case DefaultSchemaVersion: + copyStruct := *r + aux := Alias(copyStruct) + if aux.RuleCondition != nil { + aux.RuleCondition.Thresholds = nil + } + aux.Evaluation = nil + aux.SchemaVersion = "" + return json.Marshal(aux) + default: + copyStruct := *r + aux := Alias(copyStruct) + return json.Marshal(aux) + } +} + func (r *PostableRule) UnmarshalJSON(bytes []byte) error { type Alias PostableRule aux := (*Alias)(r) @@ -263,3 +290,23 @@ type GettableRule struct { UpdatedAt *time.Time `json:"updateAt"` UpdatedBy *string `json:"updateBy"` } + +func (g *GettableRule) MarshalJSON() ([]byte, error) { + type Alias GettableRule + + switch g.SchemaVersion { + case DefaultSchemaVersion: + copyStruct := *g + aux := Alias(copyStruct) + if aux.RuleCondition != nil { + aux.RuleCondition.Thresholds = nil + } + aux.Evaluation = nil + aux.SchemaVersion = "" + return json.Marshal(aux) + default: + copyStruct := *g + aux := Alias(copyStruct) + return json.Marshal(aux) + } +} diff --git a/pkg/types/ruletypes/api_params_test.go b/pkg/types/ruletypes/api_params_test.go index 27ec5883714e..74d58fdb39a0 100644 --- a/pkg/types/ruletypes/api_params_test.go +++ b/pkg/types/ruletypes/api_params_test.go @@ -240,6 +240,338 @@ func TestParseIntoRule(t *testing.T) { } } +func TestParseIntoRuleSchemaVersioning(t *testing.T) { + tests := []struct { + name string + initRule PostableRule + content []byte + kind RuleDataKind + expectError bool + validate func(*testing.T, *PostableRule) + }{ + { + name: "schema v1 - threshold name from severity label", + initRule: PostableRule{}, + content: []byte(`{ + "alert": "SeverityLabelTest", + "schemaVersion": "v1", + "condition": { + "compositeQuery": { + "queryType": "builder", + "builderQueries": { + "A": { + "aggregateAttribute": { + "key": "cpu_usage" + } + } + }, + "unit": "percent" + }, + "target": 85.0, + "targetUnit": "%", + "matchType": "1", + "op": "1" + }, + "labels": { + "severity": "warning", + "team": "platform" + } + }`), + kind: RuleDataKindJson, + expectError: false, + validate: func(t *testing.T, rule *PostableRule) { + if rule.RuleCondition.Thresholds == nil { + t.Fatal("Expected Thresholds to be populated for v1") + } + + threshold := rule.RuleCondition.Thresholds + if threshold.Kind != BasicThresholdKind { + t.Errorf("Expected BasicThresholdKind, got %s", threshold.Kind) + } + + specs, ok := threshold.Spec.(BasicRuleThresholds) + if !ok { + t.Fatalf("Expected BasicRuleThresholds, got %T", threshold.Spec) + } + + if len(specs) != 1 { + t.Fatalf("Expected 1 threshold spec, got %d", len(specs)) + } + + spec := specs[0] + if spec.Name != "warning" { + t.Errorf("Expected threshold name 'warning' from severity label, got '%s'", spec.Name) + } + + // Verify all fields are copied from RuleCondition + if spec.RuleUnit != "percent" { + t.Errorf("Expected RuleUnit 'percent', got '%s'", spec.RuleUnit) + } + if spec.TargetUnit != "%" { + t.Errorf("Expected TargetUnit '%%', got '%s'", spec.TargetUnit) + } + if *spec.TargetValue != 85.0 { + t.Errorf("Expected TargetValue 85.0, got %v", *spec.TargetValue) + } + if spec.MatchType != rule.RuleCondition.MatchType { + t.Error("Expected MatchType to be copied from RuleCondition") + } + if spec.CompareOp != rule.RuleCondition.CompareOp { + t.Error("Expected CompareOp to be copied from RuleCondition") + } + + // Verify evaluation envelope is populated + if rule.Evaluation == nil { + t.Fatal("Expected Evaluation to be populated for v1") + } + if rule.Evaluation.Kind != RollingEvaluation { + t.Errorf("Expected RollingEvaluation, got %s", rule.Evaluation.Kind) + } + + // Verify evaluation window matches rule settings + if window, ok := rule.Evaluation.Spec.(RollingWindow); ok { + if window.EvalWindow != rule.EvalWindow { + t.Errorf("Expected Evaluation EvalWindow %v, got %v", rule.EvalWindow, window.EvalWindow) + } + if window.Frequency != rule.Frequency { + t.Errorf("Expected Evaluation Frequency %v, got %v", rule.Frequency, window.Frequency) + } + } else { + t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec) + } + }, + }, + { + name: "schema v1 - uses critical threshold when no labels", + initRule: PostableRule{}, + content: []byte(`{ + "alert": "NoLabelsTest", + "schemaVersion": "v1", + "condition": { + "compositeQuery": { + "queryType": "builder", + "builderQueries": { + "A": { + "aggregateAttribute": { + "key": "memory_usage" + } + } + } + }, + "target": 90.0, + "matchType": "1", + "op": "1" + } + }`), + kind: RuleDataKindJson, + expectError: false, + validate: func(t *testing.T, rule *PostableRule) { + if rule.RuleCondition.Thresholds == nil { + t.Fatal("Expected Thresholds to be populated") + } + + specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds) + if !ok { + t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec) + } + spec := specs[0] + // Should default to CriticalThresholdName when no severity label + if spec.Name != CriticalThresholdName { + t.Errorf("Expected threshold name '%s', got '%s'", CriticalThresholdName, spec.Name) + } + }, + }, + { + name: "schema v1 - overwrites existing thresholds and evaluation", + initRule: PostableRule{}, + content: []byte(`{ + "alert": "OverwriteTest", + "schemaVersion": "v1", + "condition": { + "compositeQuery": { + "queryType": "builder", + "builderQueries": { + "A": { + "aggregateAttribute": { + "key": "cpu_usage" + } + } + }, + "unit": "percent" + }, + "target": 80.0, + "targetUnit": "%", + "matchType": "1", + "op": "1", + "thresholds": { + "kind": "basic", + "spec": [{ + "name": "existing_threshold", + "target": 50.0, + "targetUnit": "MB", + "ruleUnit": "bytes", + "matchType": "1", + "op": "1" + }] + } + }, + "evaluation": { + "kind": "rolling", + "spec": { + "evalWindow": "10m", + "frequency": "2m" + } + }, + "frequency":"7m", + "evalWindow":"11m", + "labels": { + "severity": "critical" + } + }`), + kind: RuleDataKindJson, + expectError: false, + validate: func(t *testing.T, rule *PostableRule) { + if rule.RuleCondition.Thresholds == nil { + t.Fatal("Expected Thresholds to be populated") + } + + specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds) + if !ok { + t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec) + } + + if len(specs) != 1 { + t.Fatalf("Expected 1 threshold spec, got %d", len(specs)) + } + + spec := specs[0] + if spec.Name != "critical" { + t.Errorf("Expected threshold name 'critical' (overwritten), got '%s'", spec.Name) + } + + if *spec.TargetValue != 80.0 { + t.Errorf("Expected TargetValue 80.0 (overwritten), got %v", *spec.TargetValue) + } + if spec.TargetUnit != "%" { + t.Errorf("Expected TargetUnit '%%' (overwritten), got '%s'", spec.TargetUnit) + } + if spec.RuleUnit != "percent" { + t.Errorf("Expected RuleUnit 'percent' (overwritten), got '%s'", spec.RuleUnit) + } + + if rule.Evaluation == nil { + t.Fatal("Expected Evaluation to be populated") + } + if window, ok := rule.Evaluation.Spec.(RollingWindow); ok { + if window.EvalWindow != rule.EvalWindow { + t.Errorf("Expected Evaluation EvalWindow to be overwritten to %v, got %v", rule.EvalWindow, window.EvalWindow) + } + if window.Frequency != rule.Frequency { + t.Errorf("Expected Evaluation Frequency to be overwritten to %v, got %v", rule.Frequency, window.Frequency) + } + } else { + t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec) + } + }, + }, + { + name: "schema v2 - does not populate thresholds and evaluation", + initRule: PostableRule{}, + content: []byte(`{ + "alert": "V2Test", + "schemaVersion": "v2", + "condition": { + "compositeQuery": { + "queryType": "builder", + "builderQueries": { + "A": { + "aggregateAttribute": { + "key": "test_metric" + } + } + } + }, + "target": 100.0, + "matchType": "1", + "op": "1" + } + }`), + kind: RuleDataKindJson, + expectError: false, + validate: func(t *testing.T, rule *PostableRule) { + if rule.SchemaVersion != "v2" { + t.Errorf("Expected schemaVersion 'v2', got '%s'", rule.SchemaVersion) + } + + if rule.RuleCondition.Thresholds != nil { + t.Error("Expected Thresholds to be nil for v2") + } + if rule.Evaluation != nil { + t.Error("Expected Evaluation to be nil for v2") + } + + if rule.EvalWindow != Duration(5*time.Minute) { + t.Error("Expected default EvalWindow to be applied") + } + if rule.RuleType != RuleTypeThreshold { + t.Error("Expected RuleType to be auto-detected") + } + }, + }, + { + name: "default schema version - defaults to v1 behavior", + initRule: PostableRule{}, + content: []byte(`{ + "alert": "DefaultSchemaTest", + "condition": { + "compositeQuery": { + "queryType": "builder", + "builderQueries": { + "A": { + "aggregateAttribute": { + "key": "test_metric" + } + } + } + }, + "target": 75.0, + "matchType": "1", + "op": "1" + } + }`), + kind: RuleDataKindJson, + expectError: false, + validate: func(t *testing.T, rule *PostableRule) { + if rule.SchemaVersion != DefaultSchemaVersion { + t.Errorf("Expected default schemaVersion '%s', got '%s'", DefaultSchemaVersion, rule.SchemaVersion) + } + if rule.RuleCondition.Thresholds == nil { + t.Error("Expected Thresholds to be populated for default schema version") + } + if rule.Evaluation == nil { + t.Error("Expected Evaluation to be populated for default schema version") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule := tt.initRule + err := json.Unmarshal(tt.content, &rule) + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if tt.validate != nil && err == nil { + tt.validate(t, &rule) + } + }) + } +} + func TestParseIntoRuleThresholdGeneration(t *testing.T) { content := []byte(`{ "alert": "TestThresholds", @@ -310,6 +642,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) { func TestParseIntoRuleMultipleThresholds(t *testing.T) { content := []byte(`{ + "schemaVersion": "v2", "alert": "MultiThresholdAlert", "ruleType": "threshold_rule", "condition": { diff --git a/pkg/types/ruletypes/threshold.go b/pkg/types/ruletypes/threshold.go index 4c47c790dc40..fba9765d5793 100644 --- a/pkg/types/ruletypes/threshold.go +++ b/pkg/types/ruletypes/threshold.go @@ -2,13 +2,14 @@ package ruletypes import ( "encoding/json" - "github.com/SigNoz/signoz/pkg/errors" - "github.com/SigNoz/signoz/pkg/query-service/converter" - "github.com/SigNoz/signoz/pkg/query-service/model/v3" - "github.com/SigNoz/signoz/pkg/query-service/utils/labels" - "github.com/SigNoz/signoz/pkg/valuer" "math" "sort" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/query-service/converter" + v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" + "github.com/SigNoz/signoz/pkg/query-service/utils/labels" + "github.com/SigNoz/signoz/pkg/valuer" ) type ThresholdKind struct { diff --git a/pkg/types/telemetrytypes/field.go b/pkg/types/telemetrytypes/field.go index 398fc19854a3..ac5aee727d91 100644 --- a/pkg/types/telemetrytypes/field.go +++ b/pkg/types/telemetrytypes/field.go @@ -24,7 +24,7 @@ type TelemetryFieldKey struct { Signal Signal `json:"signal,omitempty"` FieldContext FieldContext `json:"fieldContext,omitempty"` FieldDataType FieldDataType `json:"fieldDataType,omitempty"` - Materialized bool `json:"materialized,omitempty"` + Materialized bool `json:"-"` } func (f TelemetryFieldKey) String() string {