refactor(decnet_web/Webhooks): extract types + helpers with tests

This commit is contained in:
2026-05-09 05:49:34 -04:00
parent ac64329a13
commit 44f4dd8c85
3 changed files with 175 additions and 0 deletions

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

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

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