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