refactor(decnet_web/Webhooks): add useWebhooks data hook

This commit is contained in:
2026-05-09 06:02:37 -04:00
parent 1ac64d2ae2
commit 7408a04a90
2 changed files with 230 additions and 0 deletions

View 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);
});
});

View 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 };
}