diff --git a/ui/mantine-ui/src/data/silences.test.tsx b/ui/mantine-ui/src/data/silences.test.tsx new file mode 100644 index 0000000000..a54faa19e8 --- /dev/null +++ b/ui/mantine-ui/src/data/silences.test.tsx @@ -0,0 +1,324 @@ +import React, { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSilence, useSilences } from './silences'; + +// Error boundary for capturing and testing error states in hooks +// (useSuspenseQuery throws errors that must be caught by an error boundary) +class ErrorBoundary extends React.Component< + { children: ReactNode; onError?: (error: Error) => void }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: ReactNode; onError?: (error: Error) => void }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error) { + if (this.props.onError) { + this.props.onError(error); + } + } + + render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + +// Mock data matching the Alertmanager API specification from api/v2/silences endpoint +const mockSilence = { + comment: 'test', + createdBy: 'Test User', + endsAt: '2026-03-28T20:00:33.992Z', + id: '4a1f2ba3-2d27-45ac-bcff-cb5cf04d7b68', + matchers: [ + { + isEqual: true, + isRegex: false, + name: 'alertname', + value: 'alert_annotate', + }, + { + isEqual: true, + isRegex: false, + name: 'severity', + value: 'critical', + }, + ], + startsAt: '2026-03-28T18:00:38.093Z', + status: { + state: 'active' as const, + }, + updatedAt: '2026-03-28T18:00:38.093Z', +}; + +describe('Silence API Hooks', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const getWrapper = (client: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ); + }; + + describe('useSilences - fetch all silences', () => { + it('should fetch and return array of silences with correct data structure', async () => { + // Mock the API endpoint + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify([mockSilence]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const { result } = renderHook(() => useSilences(), { + wrapper: getWrapper(queryClient), + }); + + // Wait for the hook to resolve + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Verify data is returned correctly + expect(result.current.data).toEqual([mockSilence]); + expect(Array.isArray(result.current.data)).toBe(true); + + // Verify correct API endpoint was called + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v2/silences'), + expect.any(Object) + ); + }); + + it('should handle empty response', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify([]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const { result } = renderHook(() => useSilences(), { + wrapper: getWrapper(queryClient), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); + + it('should handle API errors (e.g., server returns error status)', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'Internal server error', + status: 'error', + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const errorCallback = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => useSilences(), { wrapper }); + + // Error boundary should catch the error thrown by the hook + await waitFor(() => { + expect(errorCallback).toHaveBeenCalled(); + }); + + expect(errorCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Internal server error'), + }) + ); + }); + + it('should handle network errors (e.g., fetch fails)', async () => { + const mockFetch = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')); + global.fetch = mockFetch as unknown as typeof fetch; + + const errorCallback = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => useSilences(), { wrapper }); + + await waitFor(() => { + expect(errorCallback).toHaveBeenCalled(); + }); + }); + }); + + describe('useSilence - fetch single silence by ID', () => { + const silenceId = '4a1f2ba3-2d27-45ac-bcff-cb5cf04d7b68'; + + it('should fetch and return a single silence with correct structure', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockSilence), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const { result } = renderHook(() => useSilence(silenceId), { + wrapper: getWrapper(queryClient), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Verify returned data has all required Silence properties + expect(result.current.data).toEqual(mockSilence); + expect(result.current.data).toHaveProperty('id'); + expect(result.current.data).toHaveProperty('status'); + expect(result.current.data).toHaveProperty('matchers'); + + // Verify correct endpoint was called with the ID + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/api/v2/silence/${silenceId}`), + expect.any(Object) + ); + }); + + it('should handle different silence IDs correctly', async () => { + const customId = 'custom-silence-id-123'; + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ ...mockSilence, id: customId }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + global.fetch = mockFetch as unknown as typeof fetch; + + renderHook(() => useSilence(customId), { + wrapper: getWrapper(queryClient), + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + // Verify the custom ID was used in the API call + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/api/v2/silence/${customId}`), + expect.any(Object) + ); + }); + + it('should handle errors when fetching single silence', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'Silence not found', + status: 'error', + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const errorCallback = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => useSilence(silenceId), { wrapper }); + + await waitFor(() => { + expect(errorCallback).toHaveBeenCalled(); + }); + + expect(errorCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Silence not found'), + }) + ); + }); + + it('should create separate cache entries for different IDs', async () => { + const id1 = 'id-1'; + const id2 = 'id-2'; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ ...mockSilence, id: id1 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ...mockSilence, id: id2 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + global.fetch = mockFetch as unknown as typeof fetch; + + // Each query client maintains separate cache per ID + const wrapper = getWrapper(queryClient); + + renderHook(() => useSilence(id1), { wrapper }); + renderHook(() => useSilence(id2), { wrapper }); + + await waitFor(() => { + expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + // Verify both endpoints were called + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/api/v2/silence/${id1}`), + expect.any(Object) + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/api/v2/silence/${id2}`), + expect.any(Object) + ); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + }); + }); +});