refactor(decnet_web/Webhooks): extract types + helpers with tests
This commit is contained in:
62
decnet_web/src/components/Webhooks/helpers.test.ts
Normal file
62
decnet_web/src/components/Webhooks/helpers.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
76
decnet_web/src/components/Webhooks/helpers.ts
Normal file
76
decnet_web/src/components/Webhooks/helpers.ts
Normal file
@@ -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<SimpleEvent, string[]> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
37
decnet_web/src/components/Webhooks/types.ts
Normal file
37
decnet_web/src/components/Webhooks/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user