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