From 486819f80b122546febb2339db36ebd35824c3b6 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 22:43:32 +0200 Subject: [PATCH] fix(nextcloud-files): WR-07 Merkzettel angelegter Ordner gilt nur fuer eine Ablage - der Merkzettel wird geleert, wenn neu abgelegt wird und nichts mehr laeuft, nach jedem Loeschen, Verschieben und Umbenennen in der Ansicht und wenn ein Hochladen mit pathConflict/notFound scheitert (dann legt "Erneut versuchen" den Ordner wieder an) - innerhalb einer Ablage wird jeder Ordner weiter nur einmal angelegt - Hook-Tests fuer alle drei Wege Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/FileBrowser.tsx | 3 + .../nextcloud-files/use-transfers.test.ts | 89 +++++++++++++++++++ .../nextcloud-files/use-transfers.ts | 22 ++++- 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/nextcloud-files/use-transfers.test.ts diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx index 125627e..3c69e9c 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx @@ -475,6 +475,7 @@ export function FileBrowser({ const confirmDelete = async (list: NcEntry[]) => { const { failed, reason } = await runEach(list, (e) => deleteEntry(e.path)); + transfers.forgetFolders(); setDialog(null); dispatch({ type: 'clear' }); await load(pathRef.current, { quiet: true }); @@ -486,6 +487,7 @@ export function FileBrowser({ }; const confirmMove = async (list: NcEntry[], targetDir: string) => { + transfers.forgetFolders(); if (list.length === 1) { // Ein einzelner Eintrag: Fehler bleiben im Dialog sichtbar. await moveEntry(list[0].path, join(targetDir, list[0].name)); @@ -515,6 +517,7 @@ export function FileBrowser({ const submitRename = async (entry: NcEntry, name: string) => { const to = join(parent(entry.path), name); + transfers.forgetFolders(); await moveEntry(entry.path, to); setDialog(null); focusAfterLoad.current = to; diff --git a/apps/web/src/components/nextcloud-files/use-transfers.test.ts b/apps/web/src/components/nextcloud-files/use-transfers.test.ts new file mode 100644 index 0000000..f0b560c --- /dev/null +++ b/apps/web/src/components/nextcloud-files/use-transfers.test.ts @@ -0,0 +1,89 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextcloudFilesRequestError } from '@/lib/nextcloud-files-api'; +import { useTransfers } from './use-transfers'; + +const mockCreate = vi.fn(); +const mockUpload = vi.fn(); + +vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createFolder: (...a: unknown[]) => mockCreate(...a), + listFolder: vi.fn(), + }; +}); +vi.mock('@/lib/nextcloud-files-upload', () => ({ + uploadFile: (...a: unknown[]) => mockUpload(...a), +})); + +const file = (name: string) => new File(['x'], name); + +beforeEach(() => { + mockCreate.mockReset().mockResolvedValue({ path: '/A' }); + mockUpload.mockReset().mockResolvedValue({ path: '/A/a.txt', size: 1 }); +}); + +describe('useTransfers — Ordner-Merkzettel (WR-07)', () => { + it('eine neue Ablage nach dem Ende der vorigen legt den Ordner erneut an (er kann inzwischen geloescht sein)', async () => { + const { result } = renderHook(() => useTransfers({ quotaAvailable: null })); + act(() => result.current.enqueue([{ file: file('a.txt'), relativeDir: 'A' }], '/')); + await waitFor(() => expect(result.current.transfers[0]?.status).toBe('done')); + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(mockCreate).toHaveBeenLastCalledWith('/A'); + + act(() => result.current.enqueue([{ file: file('b.txt'), relativeDir: 'A' }], '/')); + await waitFor(() => expect(result.current.transfers[1]?.status).toBe('done')); + expect(mockCreate).toHaveBeenCalledTimes(2); + }); + + it('innerhalb einer Ablage wird jeder Ordner nur einmal angelegt', async () => { + const { result } = renderHook(() => useTransfers({ quotaAvailable: null })); + act(() => + result.current.enqueue( + [ + { file: file('a.txt'), relativeDir: 'A' }, + { file: file('b.txt'), relativeDir: 'A' }, + { file: file('c.txt'), relativeDir: 'A' }, + ], + '/', + ), + ); + await waitFor(() => + expect(result.current.transfers.every((t) => t.status === 'done')).toBe(true), + ); + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + it('forgetFolders und ein Hochladen mit pathConflict leeren den Merkzettel', async () => { + let release!: () => void; + const hold = new Promise((r) => { + release = r; + }); + mockUpload + .mockImplementationOnce(async () => { + await hold; + return { path: '/A/a.txt', size: 1 }; + }) + .mockRejectedValueOnce(new NextcloudFilesRequestError(409, 'pathConflict', 'fehlt')) + .mockResolvedValue({ path: '/A/c.txt', size: 1 }); + const { result } = renderHook(() => useTransfers({ quotaAvailable: null })); + // Die erste Datei laeuft noch: die zweite Ablage nutzt den Merkzettel. + act(() => result.current.enqueue([{ file: file('a.txt'), relativeDir: 'A' }], '/')); + await waitFor(() => expect(mockUpload).toHaveBeenCalledTimes(1)); + act(() => result.current.enqueue([{ file: file('b.txt'), relativeDir: 'A' }], '/')); + await waitFor(() => expect(result.current.transfers[1]?.status).toBe('error')); + expect(mockCreate).toHaveBeenCalledTimes(1); + // pathConflict: "Erneut versuchen" legt den Ordner wieder an. + act(() => result.current.retry(result.current.transfers[1].id)); + await waitFor(() => expect(result.current.transfers[1]?.status).toBe('done')); + expect(mockCreate).toHaveBeenCalledTimes(2); + + act(() => result.current.forgetFolders()); + act(() => result.current.enqueue([{ file: file('c.txt'), relativeDir: 'A' }], '/')); + await waitFor(() => expect(result.current.transfers[2]?.status).toBe('done')); + expect(mockCreate).toHaveBeenCalledTimes(3); + release(); + }); +}); diff --git a/apps/web/src/components/nextcloud-files/use-transfers.ts b/apps/web/src/components/nextcloud-files/use-transfers.ts index 8fdf5be..0811e72 100644 --- a/apps/web/src/components/nextcloud-files/use-transfers.ts +++ b/apps/web/src/components/nextcloud-files/use-transfers.ts @@ -12,6 +12,12 @@ import { freeName, join } from './paths'; * Dateien gleichzeitig, jede mit eigenem Abbruch. Ein vergebener Name endet nicht * als Fehler, sondern als Rueckfrage (Ersetzen, Beide behalten, Ueberspringen). * Was nicht in den freien Speicher passt, wird gar nicht erst gesendet. + * + * Angelegte Ordner werden nur fuer eine Ablage gemerkt (WR-07): Der Merkzettel wird + * geleert, sobald nichts mehr laeuft und neu abgelegt wird, nach jedem Loeschen, + * Verschieben oder Umbenennen in der Ansicht (`forgetFolders`) und wenn ein Hochladen + * mit "Zielordner fehlt" scheitert — sonst liefe ein neu abgelegter, inzwischen + * geloeschter Ordner gleichen Namens ohne MKCOL ins Leere. */ export const MAX_PARALLEL = 2; @@ -174,6 +180,13 @@ export function useTransfers(options: UseTransfersOptions) { patch(id, { status: 'done', loaded: item.size }); optsRef.current.onFolderChanged?.(item.targetDir); } catch (err) { + if ( + err instanceof NextcloudFilesRequestError && + (err.code === 'pathConflict' || err.code === 'notFound') + ) { + // Der Zielordner fehlt: beim naechsten Versuch die Ordner wieder anlegen. + folders.current.clear(); + } if (controller.signal.aborted) { // Abgebrochen: die Zeile ist bereits entfernt. } else if (err instanceof NextcloudFilesRequestError && err.code === 'nameTaken') { @@ -217,6 +230,8 @@ export function useTransfers(options: UseTransfersOptions) { const enqueue = useCallback( (files: readonly DroppedFile[], baseDir: string, emptyFolders: readonly string[] = []) => { + // Neue Ablage, waehrend nichts laeuft: Ordner von frueher koennen inzwischen geloescht sein. + if (!listRef.current.some(isActive)) folders.current.clear(); for (const folder of emptyFolders) { void ensureFolders(baseDir, folder).catch(() => undefined); } @@ -254,6 +269,11 @@ export function useTransfers(options: UseTransfersOptions) { [ensureFolders], ); + /** Nach Loeschen, Verschieben oder Umbenennen: gemerkte Ordner gelten nicht mehr. */ + const forgetFolders = useCallback(() => { + folders.current.clear(); + }, []); + const cancel = useCallback((id: string) => { const it = internals.current.get(id); it?.controller?.abort(); @@ -319,5 +339,5 @@ export function useTransfers(options: UseTransfersOptions) { }); }, []); - return { transfers, enqueue, cancel, retry, resolve, clearFinished }; + return { transfers, enqueue, cancel, retry, resolve, clearFinished, forgetFolders }; }