fix(web): admin — scan initial après création, retrait des fallbacks mock

La création d'une bibliothèque déclenche un scan initial avec état de
réessai en cas d'échec; suppression des fallbacks mock sur create/scan
pour ne masquer aucune erreur réelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 16:24:02 +02:00
parent 4aacac12da
commit a6488d160f
3 changed files with 52 additions and 13 deletions

View File

@ -31,6 +31,26 @@ describe("api fallback helpers", () => {
expect(headers.has("Content-Type")).toBe(false); expect(headers.has("Content-Type")).toBe(false);
}); });
it("surfaces create library API errors without fallback", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "Library path does not exist" }), {
status: 400,
statusText: "Bad Request",
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await expect(api.createLibrary({ name: "Books", path: "/missing", enabled: true })).rejects.toThrow("Library path does not exist");
});
it("does not fallback when scan enqueue fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
vi.stubGlobal("fetch", fetchMock);
await expect(api.scanLibrary(42)).rejects.toThrow("offline");
});
it("sends metadata source updates to the admin endpoint", async () => { it("sends metadata source updates to the admin endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue( const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), { new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), {

View File

@ -185,15 +185,14 @@ export const api = {
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> { async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
return request<LibraryDto>("/admin/libraries", { return request<LibraryDto>("/admin/libraries", {
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input)
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
}); });
}, },
async deleteLibrary(id: number): Promise<void> { async deleteLibrary(id: number): Promise<void> {
await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" }); await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" });
}, },
async scanLibrary(id: number): Promise<JobDto> { async scanLibrary(id: number): Promise<JobDto> {
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] }); return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST" });
}, },
async jobs(): Promise<JobDto[]> { async jobs(): Promise<JobDto[]> {
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs }); return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });

View File

@ -13,6 +13,7 @@ export function AdminPage() {
const [path, setPath] = useState("/library"); const [path, setPath] = useState("/library");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [success, setSuccess] = useState<string>(); const [success, setSuccess] = useState<string>();
const [scanRetryLibrary, setScanRetryLibrary] = useState<LibraryDto>();
async function refresh() { async function refresh() {
setLoading(true); setLoading(true);
@ -53,28 +54,38 @@ export function AdminPage() {
event.preventDefault(); event.preventDefault();
setError(undefined); setError(undefined);
setSuccess(undefined); setSuccess(undefined);
setScanRetryLibrary(undefined);
try { try {
await api.createLibrary({ name, path, enabled: true }); const created = await api.createLibrary({ name, path, enabled: true });
await refresh(); await refresh();
setSuccess(`Bibliothèque "${name}" ajoutée.`); setName("Bibliotheque locale");
setPath("/library");
try {
await api.scanLibrary(created.id);
await refresh();
setSuccess(`Bibliothèque "${created.name}" ajoutée. Scan initial demandé.`);
} catch (scanError) {
setScanRetryLibrary(created);
setSuccess(
`Bibliothèque "${created.name}" ajoutée, mais le scan initial n'a pas pu être demandé. Tu peux réessayer le scan.`
);
setError(scanError instanceof Error ? `Scan initial impossible : ${scanError.message}` : "Scan initial impossible.");
}
} catch (createError) { } catch (createError) {
const fallback = getApiFallback<LibraryDto>(createError); setError(createError instanceof Error ? `Création impossible : ${createError.message}` : "Création impossible.");
if (fallback) setLibraries((current) => [fallback, ...current]);
setError(fallback ? "Creation en mode secours, synchronisation a retenter." : "Creation impossible");
} }
} }
async function scan(id: number) { async function scan(id: number) {
setError(undefined); setError(undefined);
setSuccess(undefined); setSuccess(undefined);
setScanRetryLibrary(undefined);
try { try {
await api.scanLibrary(id); await api.scanLibrary(id);
await refresh(); await refresh();
setSuccess("Scan demandé."); setSuccess("Scan demandé.");
} catch (scanError) { } catch (scanError) {
const fallback = getApiFallback<JobDto>(scanError); setError(scanError instanceof Error ? `Scan impossible : ${scanError.message}` : "Scan impossible.");
if (fallback) setJobs((current) => [fallback, ...current]);
setError(fallback ? "Scan place en file de secours, statut a verifier." : "Scan impossible");
} }
} }
@ -86,6 +97,7 @@ export function AdminPage() {
setError(undefined); setError(undefined);
setSuccess(undefined); setSuccess(undefined);
setScanRetryLibrary(undefined);
try { try {
await api.deleteLibrary(library.id); await api.deleteLibrary(library.id);
setLibraries((current) => current.filter((item) => item.id !== library.id)); setLibraries((current) => current.filter((item) => item.id !== library.id));
@ -104,14 +116,22 @@ export function AdminPage() {
</div> </div>
<ErrorRibbon message={error} /> <ErrorRibbon message={error} />
{success && <div className="success-ribbon">{success}</div>} {success && <div className="success-ribbon">{success}</div>}
{error && ( {scanRetryLibrary ? (
<div className="retry-row">
<span>La bibliothèque est conservée dans la liste.</span>
<button className="ghost-button" onClick={() => scan(scanRetryLibrary.id)}>
<Play size={16} />
Réessayer le scan
</button>
</div>
) : error ? (
<div className="retry-row"> <div className="retry-row">
<span>Les formulaires restent disponibles.</span> <span>Les formulaires restent disponibles.</span>
<button className="ghost-button" onClick={() => void refresh()}> <button className="ghost-button" onClick={() => void refresh()}>
Reessayer Reessayer
</button> </button>
</div> </div>
)} ) : null}
<form className="admin-form" onSubmit={createLibrary}> <form className="admin-form" onSubmit={createLibrary}>
<label> <label>
Nom de la bibliothèque Nom de la bibliothèque