Skip to content

Commit 5911a24

Browse files
handle cache invalidation and update tests
1 parent d411211 commit 5911a24

3 files changed

Lines changed: 845 additions & 3 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { renderHook, act, waitFor } from '@testing-library/react';
9+
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
10+
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
11+
import { createQueryClientWrapper } from '../../test_utils';
12+
import { useUpdateRule } from './use_update_rule';
13+
import type { FormValues } from '../types';
14+
15+
describe('useUpdateRule', () => {
16+
const ruleId = 'rule-abc-123';
17+
18+
const setupUseUpdateRule = () => {
19+
const http = httpServiceMock.createStartContract();
20+
const notifications = notificationServiceMock.createStartContract();
21+
const onSuccess = jest.fn();
22+
const hook = renderHook(
23+
() =>
24+
useUpdateRule({
25+
http,
26+
notifications,
27+
ruleId,
28+
onSuccess,
29+
}),
30+
{ wrapper: createQueryClientWrapper() }
31+
);
32+
33+
return { http, notifications, onSuccess, ...hook };
34+
};
35+
36+
const getLastPatchedBody = (http: ReturnType<typeof httpServiceMock.createStartContract>) => {
37+
const lastCallArgs = http.patch.mock.calls[http.patch.mock.calls.length - 1];
38+
const requestOptions = lastCallArgs[lastCallArgs.length - 1] as { body: string };
39+
return JSON.parse(requestOptions.body);
40+
};
41+
42+
const validFormData: FormValues = {
43+
kind: 'signal',
44+
metadata: {
45+
name: 'Updated Rule',
46+
enabled: true,
47+
labels: ['tag1', 'tag2'],
48+
},
49+
timeField: '@timestamp',
50+
schedule: { every: '5m', lookback: '1m' },
51+
evaluation: {
52+
query: {
53+
base: 'FROM logs | LIMIT 10',
54+
condition: '',
55+
},
56+
},
57+
grouping: { fields: ['host.name'] },
58+
};
59+
60+
it('calls the correct API endpoint with encoded ruleId', async () => {
61+
const { http, result } = setupUseUpdateRule();
62+
63+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Updated Rule' } });
64+
65+
await act(async () => {
66+
result.current.updateRule(validFormData);
67+
});
68+
69+
await waitFor(() => {
70+
expect(http.patch).toHaveBeenCalledWith(
71+
`/internal/alerting/v2/rule/${encodeURIComponent(ruleId)}`,
72+
expect.any(Object)
73+
);
74+
});
75+
});
76+
77+
it('does not include kind in the update payload', async () => {
78+
const { http, result } = setupUseUpdateRule();
79+
80+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Updated Rule' } });
81+
82+
await act(async () => {
83+
result.current.updateRule(validFormData);
84+
});
85+
86+
await waitFor(() => {
87+
const body = getLastPatchedBody(http);
88+
expect(body).not.toHaveProperty('kind');
89+
});
90+
});
91+
92+
it('coerces absent optional fields to null for explicit removal', async () => {
93+
const { http, result } = setupUseUpdateRule();
94+
95+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Minimal Rule' } });
96+
97+
const minimalFormData: FormValues = {
98+
kind: 'signal',
99+
metadata: { name: 'Minimal Rule', enabled: true },
100+
timeField: '@timestamp',
101+
schedule: { every: '5m', lookback: '1m' },
102+
evaluation: { query: { base: 'FROM logs | LIMIT 10' } },
103+
};
104+
105+
await act(async () => {
106+
result.current.updateRule(minimalFormData);
107+
});
108+
109+
await waitFor(() => {
110+
const body = getLastPatchedBody(http);
111+
expect(body.grouping).toBeNull();
112+
expect(body.recovery_policy).toBeNull();
113+
expect(body.state_transition).toBeNull();
114+
});
115+
});
116+
117+
it('sends the correctly mapped form data as JSON', async () => {
118+
const { http, result } = setupUseUpdateRule();
119+
120+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Updated Rule' } });
121+
122+
await act(async () => {
123+
result.current.updateRule(validFormData);
124+
});
125+
126+
// Note: empty condition field is omitted, absent optional fields are null
127+
const expectedPayload = {
128+
metadata: { name: 'Updated Rule', labels: ['tag1', 'tag2'] },
129+
time_field: '@timestamp',
130+
schedule: { every: '5m', lookback: '1m' },
131+
evaluation: { query: { base: 'FROM logs | LIMIT 10' } },
132+
grouping: { fields: ['host.name'] },
133+
recovery_policy: null,
134+
state_transition: null,
135+
};
136+
137+
await waitFor(() => {
138+
expect(http.patch).toHaveBeenCalledWith(
139+
`/internal/alerting/v2/rule/${encodeURIComponent(ruleId)}`,
140+
{ body: JSON.stringify(expectedPayload) }
141+
);
142+
});
143+
});
144+
145+
it('includes evaluation condition when non-empty', async () => {
146+
const { http, result } = setupUseUpdateRule();
147+
148+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Condition Rule' } });
149+
150+
const formData: FormValues = {
151+
...validFormData,
152+
evaluation: {
153+
query: {
154+
base: 'FROM logs | STATS count() BY host',
155+
condition: 'WHERE count > 100',
156+
},
157+
},
158+
};
159+
160+
await act(async () => {
161+
result.current.updateRule(formData);
162+
});
163+
164+
await waitFor(() => {
165+
const body = getLastPatchedBody(http);
166+
expect(body.evaluation.query).toEqual({
167+
base: 'FROM logs | STATS count() BY host',
168+
condition: 'WHERE count > 100',
169+
});
170+
});
171+
});
172+
173+
it('includes recovery_policy with condition-only mode using evaluation base', async () => {
174+
const { http, result } = setupUseUpdateRule();
175+
176+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Recovery Rule' } });
177+
178+
const formData: FormValues = {
179+
...validFormData,
180+
kind: 'alert',
181+
evaluation: {
182+
query: {
183+
base: 'FROM logs | STATS count() BY host',
184+
condition: 'WHERE count > 100',
185+
},
186+
},
187+
recoveryPolicy: {
188+
type: 'query',
189+
query: { condition: 'WHERE count <= 50' },
190+
},
191+
};
192+
193+
await act(async () => {
194+
result.current.updateRule(formData);
195+
});
196+
197+
await waitFor(() => {
198+
const body = getLastPatchedBody(http);
199+
expect(body.recovery_policy).toEqual({
200+
type: 'query',
201+
query: {
202+
base: 'FROM logs | STATS count() BY host',
203+
condition: 'WHERE count <= 50',
204+
},
205+
});
206+
});
207+
});
208+
209+
it('includes state_transition for alert kind', async () => {
210+
const { http, result } = setupUseUpdateRule();
211+
212+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Alert Rule' } });
213+
214+
const formData: FormValues = {
215+
...validFormData,
216+
kind: 'alert',
217+
stateTransition: { pendingCount: 3, pendingTimeframe: '10m' },
218+
};
219+
220+
await act(async () => {
221+
result.current.updateRule(formData);
222+
});
223+
224+
await waitFor(() => {
225+
const body = getLastPatchedBody(http);
226+
expect(body.state_transition).toEqual({
227+
pending_count: 3,
228+
pending_timeframe: '10m',
229+
});
230+
});
231+
});
232+
233+
it('nullifies state_transition for signal kind even when stateTransition is provided', async () => {
234+
const { http, result } = setupUseUpdateRule();
235+
236+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Signal Rule' } });
237+
238+
const formData: FormValues = {
239+
...validFormData,
240+
kind: 'signal',
241+
stateTransition: { pendingCount: 3, pendingTimeframe: '10m' },
242+
};
243+
244+
await act(async () => {
245+
result.current.updateRule(formData);
246+
});
247+
248+
await waitFor(() => {
249+
const body = getLastPatchedBody(http);
250+
// signal kind → mapStateTransition returns undefined → coerced to null
251+
expect(body.state_transition).toBeNull();
252+
});
253+
});
254+
255+
it('shows success toast and calls onSuccess callback on successful update', async () => {
256+
const { http, notifications, onSuccess, result } = setupUseUpdateRule();
257+
258+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'My Updated Rule' } });
259+
260+
await act(async () => {
261+
result.current.updateRule(validFormData);
262+
});
263+
264+
await waitFor(() => {
265+
expect(notifications.toasts.addSuccess).toHaveBeenCalledWith(
266+
"Rule 'My Updated Rule' was updated successfully"
267+
);
268+
expect(onSuccess).toHaveBeenCalled();
269+
});
270+
});
271+
272+
it('shows error toast on failure and does not call onSuccess', async () => {
273+
const { http, notifications, onSuccess, result } = setupUseUpdateRule();
274+
275+
http.patch.mockRejectedValue({
276+
body: { message: 'Conflict' },
277+
message: 'Conflict',
278+
});
279+
280+
await act(async () => {
281+
result.current.updateRule(validFormData);
282+
});
283+
284+
await waitFor(() => {
285+
expect(notifications.toasts.addDanger).toHaveBeenCalledWith('Error updating rule: Conflict');
286+
expect(onSuccess).not.toHaveBeenCalled();
287+
});
288+
});
289+
290+
it('works without an onSuccess callback', async () => {
291+
const http = httpServiceMock.createStartContract();
292+
const notifications = notificationServiceMock.createStartContract();
293+
294+
http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Test Rule' } });
295+
296+
const { result } = renderHook(
297+
() =>
298+
useUpdateRule({
299+
http,
300+
notifications,
301+
ruleId,
302+
}),
303+
{ wrapper: createQueryClientWrapper() }
304+
);
305+
306+
await act(async () => {
307+
result.current.updateRule(validFormData);
308+
});
309+
310+
await waitFor(() => {
311+
expect(notifications.toasts.addSuccess).toHaveBeenCalled();
312+
// No onSuccess callback — should not throw
313+
});
314+
});
315+
});

0 commit comments

Comments
 (0)