From 44f4dd8c85af02113855359106f26af5a2a30139 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:49:34 -0400 Subject: [PATCH] refactor(decnet_web/Webhooks): extract types + helpers with tests --- .../src/components/Webhooks/helpers.test.ts | 62 +++++++++++++++ decnet_web/src/components/Webhooks/helpers.ts | 76 +++++++++++++++++++ decnet_web/src/components/Webhooks/types.ts | 37 +++++++++ 3 files changed, 175 insertions(+) create mode 100644 decnet_web/src/components/Webhooks/helpers.test.ts create mode 100644 decnet_web/src/components/Webhooks/helpers.ts create mode 100644 decnet_web/src/components/Webhooks/types.ts diff --git a/decnet_web/src/components/Webhooks/helpers.test.ts b/decnet_web/src/components/Webhooks/helpers.test.ts new file mode 100644 index 00000000..1a1ccf67 --- /dev/null +++ b/decnet_web/src/components/Webhooks/helpers.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { + BLANK_FORM, deriveSimpleEvents, formatDate, formToPayload, +} from './helpers'; + +describe('deriveSimpleEvents', () => { + it('returns the matching preset names when patterns are an exact union', () => { + expect(deriveSimpleEvents(['attacker.>'])).toEqual(['AttackerDetail']); + expect(deriveSimpleEvents(['decky.*.state', 'decky.*.traffic'])) + .toEqual(['DeckyStatus']); + expect(deriveSimpleEvents(['attacker.>', 'system.>']).sort()) + .toEqual(['AttackerDetail', 'SystemStatus']); + }); + + it('returns [] when any extra pattern leaks past the presets', () => { + expect(deriveSimpleEvents(['attacker.>', 'custom.topic'])).toEqual([]); + }); + + it('returns [] when a preset is partially matched', () => { + expect(deriveSimpleEvents(['decky.*.state'])).toEqual([]); + }); + + it('returns [] for empty input', () => { + expect(deriveSimpleEvents([])).toEqual([]); + }); +}); + +describe('formatDate', () => { + it('returns em-dash for null/empty', () => { + expect(formatDate(null)).toBe('—'); + }); + + it('returns the raw string for unparseable input', () => { + expect(formatDate('not-a-date')).toBe('not-a-date'); + }); + + it('renders YYYY-MM-DD HH:MM for a valid ISO string', () => { + const out = formatDate('2026-03-05T08:09:00Z'); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); +}); + +describe('formToPayload', () => { + it('trims name/url and splits topic_patterns by newline', () => { + const payload = formToPayload({ + ...BLANK_FORM, + name: ' shuffle ', + url: ' https://x/y ', + topic_patterns: 'attacker.>\n\n decky.*.state \n', + simple_events: ['SystemStatus'], + }); + expect(payload.name).toBe('shuffle'); + expect(payload.url).toBe('https://x/y'); + expect(payload.topic_patterns).toEqual(['attacker.>', 'decky.*.state']); + expect(payload.simple_events).toEqual(['SystemStatus']); + }); + + it('omits secret when blank, includes it when set', () => { + expect(formToPayload({ ...BLANK_FORM, secret: '' }).secret).toBeUndefined(); + expect(formToPayload({ ...BLANK_FORM, secret: 'topsecret' }).secret).toBe('topsecret'); + }); +}); diff --git a/decnet_web/src/components/Webhooks/helpers.ts b/decnet_web/src/components/Webhooks/helpers.ts new file mode 100644 index 00000000..f1c89241 --- /dev/null +++ b/decnet_web/src/components/Webhooks/helpers.ts @@ -0,0 +1,76 @@ +import type { ApiError } from '../../utils/api'; +import type { FormState, SimpleEvent } from './types'; + +// Server-side canonical expansions (mirrors decnet/webhook/enums.py). Kept +// in sync manually; this is the sugar layer, not the source of truth. +export const SIMPLE_PRESETS: Record = { + AttackerDetail: ['attacker.>'], + DeckyStatus: ['decky.*.state', 'decky.*.traffic'], + SystemStatus: ['system.>'], +}; + +export const BLANK_FORM: FormState = { + name: '', + url: '', + secret: '', + simple_events: [], + topic_patterns: '', + enabled: true, +}; + +export function extractErrorDetail(err: unknown, fallback: string): string { + const e = err as ApiError; + if (e?.response?.data?.detail) return e.response.data.detail; + if (e?.response?.status === 403) return 'Insufficient permissions (admin only)'; + if (e?.response?.status === 401) return 'Session expired — please log in again'; + if (e?.message) return e.message; + return fallback; +} + +/** Derive which simple-event checkboxes should show as ticked for a given + * persisted pattern list. Only ticks when the intersection is exact — + * mixed custom + preset leaves everything unticked and the textarea is + * the source of truth. */ +export function deriveSimpleEvents(patterns: string[]): SimpleEvent[] { + const ticked: SimpleEvent[] = []; + const remaining = new Set(patterns); + for (const [name, preset] of Object.entries(SIMPLE_PRESETS) as [SimpleEvent, string[]][]) { + if (preset.every((p) => remaining.has(p))) { + ticked.push(name); + preset.forEach((p) => remaining.delete(p)); + } + } + // If anything outside the presets remains, don't tick — user sees raw. + if (remaining.size > 0) return []; + return ticked; +} + +export function formatDate(iso: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Translate the on-screen form state into the request body the API + * expects. Splits the textarea into a clean string[]. */ +export function formToPayload(form: FormState): { + name: string; + url: string; + secret?: string; + simple_events: SimpleEvent[]; + topic_patterns: string[]; + enabled: boolean; +} { + const rawPatterns = form.topic_patterns + .split('\n').map((s) => s.trim()).filter(Boolean); + return { + name: form.name.trim(), + url: form.url.trim(), + secret: form.secret ? form.secret : undefined, + simple_events: form.simple_events, + topic_patterns: rawPatterns, + enabled: form.enabled, + }; +} diff --git a/decnet_web/src/components/Webhooks/types.ts b/decnet_web/src/components/Webhooks/types.ts new file mode 100644 index 00000000..57721f37 --- /dev/null +++ b/decnet_web/src/components/Webhooks/types.ts @@ -0,0 +1,37 @@ +export type SimpleEvent = 'AttackerDetail' | 'DeckyStatus' | 'SystemStatus'; + +export interface WebhookRow { + uuid: string; + name: string; + url: string; + topic_patterns: string[]; + enabled: boolean; + consecutive_failures: number; + last_success_at: string | null; + last_failure_at: string | null; + last_error: string | null; + auto_disabled_at: string | null; + created_at: string; + updated_at: string; + warnings: string[]; +} + +export interface FormState { + name: string; + url: string; + /** blank = server auto-generates (create) / keep existing (edit) */ + secret: string; + simple_events: SimpleEvent[]; + /** textarea: one per line */ + topic_patterns: string; + enabled: boolean; +} + +export interface WebhookSavePayload { + name: string; + url: string; + secret?: string; + simple_events: SimpleEvent[]; + topic_patterns: string[]; + enabled: boolean; +}