From c1a65bf9a3c9ce6f30ef990aed060bb4bd9c625f Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:46:08 -0400 Subject: [PATCH] refactor(decnet_web/PersonaGeneration): add usePersonaGeneration data hook --- .../usePersonaGeneration.test.ts | 119 ++++++++++++++++++ .../PersonaGeneration/usePersonaGeneration.ts | 82 ++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 decnet_web/src/components/PersonaGeneration/usePersonaGeneration.test.ts create mode 100644 decnet_web/src/components/PersonaGeneration/usePersonaGeneration.ts diff --git a/decnet_web/src/components/PersonaGeneration/usePersonaGeneration.test.ts b/decnet_web/src/components/PersonaGeneration/usePersonaGeneration.test.ts new file mode 100644 index 00000000..3d8e2948 --- /dev/null +++ b/decnet_web/src/components/PersonaGeneration/usePersonaGeneration.test.ts @@ -0,0 +1,119 @@ +/** + * @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 { usePersonaGeneration } from './usePersonaGeneration'; +import { BLANK } from './helpers'; +import type { EmailPersona } from './types'; + +const persona = (over: Partial = {}): EmailPersona => ({ + ...BLANK, name: 'Jane', email: 'jane@example.com', role: 'admin', ...over, +}); + +describe('usePersonaGeneration', () => { + it('loads global personas from /realism/personas on mount', async () => { + server.use( + http.get(apiUrl('/realism/personas'), () => + HttpResponse.json({ + path: '/etc/decnet/email_personas.json', + language_default: 'en', + personas: [persona({ name: 'Alice', email: 'a@x.com' })], + }), + ), + ); + const { result } = renderHook(() => usePersonaGeneration()); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.personas).toHaveLength(1); + expect(result.current.path).toBe('/etc/decnet/email_personas.json'); + expect(result.current.languageDefault).toBe('en'); + expect(result.current.error).toBeNull(); + }); + + it('loads topology-bound personas from the topology endpoint', async () => { + server.use( + http.get(apiUrl('/topologies/topo-1/personas'), () => + HttpResponse.json({ + topology_name: 'corp', + language_default: 'pt', + personas: [], + }), + ), + ); + const { result } = renderHook(() => usePersonaGeneration('topo-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.topoName).toBe('corp'); + expect(result.current.languageDefault).toBe('pt'); + }); + + it('surfaces error when load fails', async () => { + server.use( + http.get(apiUrl('/realism/personas'), () => + HttpResponse.json({ detail: 'forbidden' }, { status: 403 }), + ), + ); + const { result } = renderHook(() => usePersonaGeneration()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('forbidden'); + }); + + it('persistPersonas adopts server response on 200', async () => { + server.use( + http.get(apiUrl('/realism/personas'), () => + HttpResponse.json({ personas: [] }), + ), + http.put(apiUrl('/realism/personas'), () => + HttpResponse.json({ + path: '/p.json', + language_default: 'en', + personas: [persona({ name: 'Bob', email: 'b@x.com' })], + }), + ), + ); + const { result } = renderHook(() => usePersonaGeneration()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { + r = await result.current.persistPersonas([persona({ email: 'b@x.com' })]); + }); + expect(r).toEqual({ ok: true }); + expect(result.current.personas[0]?.name).toBe('Bob'); + expect(result.current.path).toBe('/p.json'); + }); + + it('persistPersonas returns reason on server error', async () => { + server.use( + http.get(apiUrl('/realism/personas'), () => + HttpResponse.json({ personas: [] }), + ), + http.put(apiUrl('/realism/personas'), () => + HttpResponse.json({ detail: 'boom' }, { status: 400 }), + ), + ); + const { result } = renderHook(() => usePersonaGeneration()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { r = await result.current.persistPersonas([]); }); + expect(r).toEqual({ ok: false, reason: 'boom' }); + expect(result.current.error).toBe('boom'); + }); + + it('reload re-fetches the endpoint', async () => { + let calls = 0; + server.use( + http.get(apiUrl('/realism/personas'), () => { + calls += 1; + return HttpResponse.json({ personas: [] }); + }), + ); + const { result } = renderHook(() => usePersonaGeneration()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(calls).toBe(1); + await act(async () => { await result.current.reload(); }); + expect(calls).toBe(2); + }); +}); diff --git a/decnet_web/src/components/PersonaGeneration/usePersonaGeneration.ts b/decnet_web/src/components/PersonaGeneration/usePersonaGeneration.ts new file mode 100644 index 00000000..307e9274 --- /dev/null +++ b/decnet_web/src/components/PersonaGeneration/usePersonaGeneration.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from 'react'; +import api from '../../utils/api'; +import { extractErrorDetail } from './helpers'; +import type { EmailPersona, PersonasResponse } from './types'; + +export interface PersistResult { + ok: boolean; + /** populated only on failure; already user-friendly. */ + reason?: string; +} + +export interface UsePersonaGeneration { + personas: EmailPersona[]; + path: string; + topoName: string; + languageDefault: string; + loading: boolean; + error: string | null; + setError: (s: string | null) => void; + reload: () => Promise; + persistPersonas: (next: EmailPersona[]) => Promise; +} + +/** Owns the GET/PUT pair for the persona list. The endpoint flips + * between the global pool and a topology-bound list based on the + * optional topologyId — both share the same wire shape. The hook + * returns the discriminated `{ ok, reason }` result so the page + * can decide what to toast without leaking axios into the UI. */ +export function usePersonaGeneration(topologyId?: string): UsePersonaGeneration { + const endpoint = topologyId + ? `/topologies/${topologyId}/personas` + : '/realism/personas'; + + const [personas, setPersonas] = useState([]); + const [path, setPath] = useState(''); + const [topoName, setTopoName] = useState(''); + const [languageDefault, setLanguageDefault] = useState('en'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await api.get(endpoint); + setPersonas(res.data.personas ?? []); + setPath(res.data.path ?? ''); + setTopoName(res.data.topology_name ?? ''); + setLanguageDefault(res.data.language_default ?? 'en'); + } catch (err) { + setError(extractErrorDetail(err, 'Failed to load personas')); + } finally { + setLoading(false); + } + }, [endpoint]); + + useEffect(() => { void reload(); }, [reload]); + + const persistPersonas = useCallback( + async (next: EmailPersona[]): Promise => { + setError(null); + try { + const res = await api.put(endpoint, { personas: next }); + setPersonas(res.data.personas ?? []); + setPath(res.data.path ?? ''); + setTopoName(res.data.topology_name ?? ''); + setLanguageDefault(res.data.language_default ?? 'en'); + return { ok: true }; + } catch (err) { + const msg = extractErrorDetail(err, 'Failed to save personas'); + setError(msg); + return { ok: false, reason: msg }; + } + }, + [endpoint], + ); + + return { + personas, path, topoName, languageDefault, loading, error, setError, + reload, persistPersonas, + }; +}