refactor(decnet_web/PersonaGeneration): add usePersonaGeneration data hook

This commit is contained in:
2026-05-09 05:46:08 -04:00
parent 97e72d975b
commit c1a65bf9a3
2 changed files with 201 additions and 0 deletions

View File

@@ -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> = {}): 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<ReturnType<typeof result.current.persistPersonas>> | 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<ReturnType<typeof result.current.persistPersonas>> | 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);
});
});

View File

@@ -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<void>;
persistPersonas: (next: EmailPersona[]) => Promise<PersistResult>;
}
/** 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<EmailPersona[]>([]);
const [path, setPath] = useState('');
const [topoName, setTopoName] = useState('');
const [languageDefault, setLanguageDefault] = useState('en');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<PersonasResponse>(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<PersistResult> => {
setError(null);
try {
const res = await api.put<PersonasResponse>(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,
};
}