refactor(decnet_web/Webhooks): add useWebhooks data hook
This commit is contained in:
118
decnet_web/src/components/Webhooks/useWebhooks.test.ts
Normal file
118
decnet_web/src/components/Webhooks/useWebhooks.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse, server, apiUrl } from '../../test/server';
|
||||
import { useWebhooks } from './useWebhooks';
|
||||
import type { WebhookRow, WebhookSavePayload } from './types';
|
||||
|
||||
const row = (over: Partial<WebhookRow> = {}): WebhookRow => ({
|
||||
uuid: 'wh-1',
|
||||
name: 'shuffle',
|
||||
url: 'https://shuffle.example.com/h/x',
|
||||
topic_patterns: ['attacker.>'],
|
||||
enabled: true,
|
||||
consecutive_failures: 0,
|
||||
last_success_at: null,
|
||||
last_failure_at: null,
|
||||
last_error: null,
|
||||
auto_disabled_at: null,
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
warnings: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const payload: WebhookSavePayload = {
|
||||
name: 'x', url: 'https://y/z', simple_events: ['SystemStatus'],
|
||||
topic_patterns: [], enabled: true,
|
||||
};
|
||||
|
||||
describe('useWebhooks', () => {
|
||||
it('loads /webhooks/ on mount', async () => {
|
||||
server.use(
|
||||
http.get(apiUrl('/webhooks/'), () => HttpResponse.json([row()])),
|
||||
);
|
||||
const { result } = renderHook(() => useWebhooks());
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.webhooks).toHaveLength(1);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces error on load failure', async () => {
|
||||
server.use(
|
||||
http.get(apiUrl('/webhooks/'), () =>
|
||||
HttpResponse.json({ detail: 'forbidden' }, { status: 403 }),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useWebhooks());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('createWebhook returns secret on success and reloads', async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(apiUrl('/webhooks/'), () => { calls += 1; return HttpResponse.json([]); }),
|
||||
http.post(apiUrl('/webhooks/'), () =>
|
||||
HttpResponse.json({ name: 'x', secret: 'plaintext-secret-once' }),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useWebhooks());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(calls).toBe(1);
|
||||
|
||||
let r: Awaited<ReturnType<typeof result.current.createWebhook>> | undefined;
|
||||
await act(async () => { r = await result.current.createWebhook(payload); });
|
||||
expect(r?.ok).toBe(true);
|
||||
expect(r?.data?.secret).toBe('plaintext-secret-once');
|
||||
expect(calls).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('updateWebhook surfaces server detail on failure', async () => {
|
||||
server.use(
|
||||
http.get(apiUrl('/webhooks/'), () => HttpResponse.json([row()])),
|
||||
http.patch(apiUrl('/webhooks/wh-1'), () =>
|
||||
HttpResponse.json({ detail: 'too long' }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useWebhooks());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let r: Awaited<ReturnType<typeof result.current.updateWebhook>> | undefined;
|
||||
await act(async () => { r = await result.current.updateWebhook('wh-1', payload); });
|
||||
expect(r).toEqual({ ok: false, reason: 'too long' });
|
||||
});
|
||||
|
||||
it('removeWebhook reports ok on 204', async () => {
|
||||
server.use(
|
||||
http.get(apiUrl('/webhooks/'), () => HttpResponse.json([row()])),
|
||||
http.delete(apiUrl('/webhooks/wh-1'), () => new HttpResponse(null, { status: 204 })),
|
||||
);
|
||||
const { result } = renderHook(() => useWebhooks());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let r: Awaited<ReturnType<typeof result.current.removeWebhook>> | undefined;
|
||||
await act(async () => { r = await result.current.removeWebhook('wh-1'); });
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('testWebhook surfaces delivery result', async () => {
|
||||
server.use(
|
||||
http.get(apiUrl('/webhooks/'), () => HttpResponse.json([row()])),
|
||||
http.post(apiUrl('/webhooks/wh-1/test'), () =>
|
||||
HttpResponse.json({ delivered: true, status_code: 200 }),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useWebhooks());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let r: Awaited<ReturnType<typeof result.current.testWebhook>> | undefined;
|
||||
await act(async () => { r = await result.current.testWebhook('wh-1'); });
|
||||
expect(r?.ok).toBe(true);
|
||||
expect(r?.data?.delivered).toBe(true);
|
||||
expect(r?.data?.status_code).toBe(200);
|
||||
});
|
||||
});
|
||||
112
decnet_web/src/components/Webhooks/useWebhooks.ts
Normal file
112
decnet_web/src/components/Webhooks/useWebhooks.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { extractErrorDetail } from './helpers';
|
||||
import type { WebhookRow, WebhookSavePayload } from './types';
|
||||
|
||||
export interface MutationResult<T = void> {
|
||||
ok: boolean;
|
||||
/** populated only on failure; already user-friendly. */
|
||||
reason?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface CreatedWebhook {
|
||||
name: string;
|
||||
/** Plaintext secret returned only on POST; never on subsequent GETs. */
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
delivered: boolean;
|
||||
status_code?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UseWebhooks {
|
||||
webhooks: WebhookRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
createWebhook: (payload: WebhookSavePayload) => Promise<MutationResult<CreatedWebhook>>;
|
||||
updateWebhook: (uuid: string, payload: WebhookSavePayload) => Promise<MutationResult>;
|
||||
removeWebhook: (uuid: string) => Promise<MutationResult>;
|
||||
testWebhook: (uuid: string) => Promise<MutationResult<TestResult>>;
|
||||
}
|
||||
|
||||
/** Owns the webhooks list and CRUD round-trips. UI concerns
|
||||
* (toasts, modal state, selection) stay in the page; the hook
|
||||
* speaks `{ ok, reason }` so callers can announce however they
|
||||
* like without leaking axios error shapes upward. */
|
||||
export function useWebhooks(): UseWebhooks {
|
||||
const [webhooks, setWebhooks] = useState<WebhookRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<WebhookRow[]>('/webhooks/');
|
||||
setWebhooks(res.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(extractErrorDetail(err, 'Failed to load webhooks'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
const createWebhook = useCallback(
|
||||
async (payload: WebhookSavePayload): Promise<MutationResult<CreatedWebhook>> => {
|
||||
try {
|
||||
const res = await api.post<CreatedWebhook>('/webhooks/', payload);
|
||||
await reload();
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: extractErrorDetail(err, 'Save failed') };
|
||||
}
|
||||
},
|
||||
[reload],
|
||||
);
|
||||
|
||||
const updateWebhook = useCallback(
|
||||
async (uuid: string, payload: WebhookSavePayload): Promise<MutationResult> => {
|
||||
try {
|
||||
await api.patch(`/webhooks/${uuid}`, payload);
|
||||
await reload();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: extractErrorDetail(err, 'Save failed') };
|
||||
}
|
||||
},
|
||||
[reload],
|
||||
);
|
||||
|
||||
const removeWebhook = useCallback(
|
||||
async (uuid: string): Promise<MutationResult> => {
|
||||
try {
|
||||
await api.delete(`/webhooks/${uuid}`);
|
||||
await reload();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: extractErrorDetail(err, 'Delete failed') };
|
||||
}
|
||||
},
|
||||
[reload],
|
||||
);
|
||||
|
||||
const testWebhook = useCallback(
|
||||
async (uuid: string): Promise<MutationResult<TestResult>> => {
|
||||
try {
|
||||
const res = await api.post<TestResult>(`/webhooks/${uuid}/test`);
|
||||
await reload();
|
||||
return { ok: true, data: res.data };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: extractErrorDetail(err, 'Test failed') };
|
||||
}
|
||||
},
|
||||
[reload],
|
||||
);
|
||||
|
||||
return { webhooks, loading, error, reload, createWebhook, updateWebhook, removeWebhook, testWebhook };
|
||||
}
|
||||
Reference in New Issue
Block a user