From 7408a04a90da64fa4022698748f2569863c7e36a Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 06:02:37 -0400 Subject: [PATCH] refactor(decnet_web/Webhooks): add useWebhooks data hook --- .../components/Webhooks/useWebhooks.test.ts | 118 ++++++++++++++++++ .../src/components/Webhooks/useWebhooks.ts | 112 +++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 decnet_web/src/components/Webhooks/useWebhooks.test.ts create mode 100644 decnet_web/src/components/Webhooks/useWebhooks.ts diff --git a/decnet_web/src/components/Webhooks/useWebhooks.test.ts b/decnet_web/src/components/Webhooks/useWebhooks.test.ts new file mode 100644 index 00000000..1e899df6 --- /dev/null +++ b/decnet_web/src/components/Webhooks/useWebhooks.test.ts @@ -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 => ({ + 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> | 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> | 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> | 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> | 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); + }); +}); diff --git a/decnet_web/src/components/Webhooks/useWebhooks.ts b/decnet_web/src/components/Webhooks/useWebhooks.ts new file mode 100644 index 00000000..26d9683f --- /dev/null +++ b/decnet_web/src/components/Webhooks/useWebhooks.ts @@ -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 { + 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; + createWebhook: (payload: WebhookSavePayload) => Promise>; + updateWebhook: (uuid: string, payload: WebhookSavePayload) => Promise; + removeWebhook: (uuid: string) => Promise; + testWebhook: (uuid: string) => Promise>; +} + +/** 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + try { + const res = await api.get('/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> => { + try { + const res = await api.post('/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 => { + 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 => { + 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> => { + try { + const res = await api.post(`/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 }; +}