refactor(decnet_web/PersonaGeneration): add usePersonaGeneration data hook
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user