feat(nextcloud-files): Dateien auflisten, Vorschau, Ordner anlegen, umbenennen, verschieben und löschen
- PROPFIND-Auswertung (mehrere propstat, Kleinbuchstaben-Hex, Speicher -3 = unbegrenzt, nur Zeichenketten) - WebDAV-Schicht mit Zugangsschluessel und Aufrufsperre, Destination immer aus der Basis, Overwrite: F - mapNcFailure als einzige Fehlerabbildung (nie 401/403 an den Browser), sendUpstreamStream prueft vor dem ersten Byte - Dateidienst und Routen files, folders, move, preview im eigenen Konto; zweiter Benutzer bekommt notConnected - Web-API-Funktionen listFolder, createFolder, moveEntry, deleteEntry, previewUrl - e2e-files.sh gegen die Test-Nextcloud Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createFolder,
|
||||
deleteEntry,
|
||||
listFolder,
|
||||
moveEntry,
|
||||
NextcloudFilesRequestError,
|
||||
previewUrl,
|
||||
} from './nextcloud-files-api';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
function mockFetch(status: number, body: unknown) {
|
||||
const fn = vi.fn(async (..._args: unknown[]) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}));
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('nextcloud-files-api — Dateien', () => {
|
||||
it('listFolder codiert den Pfad in der Query und sendet das Cookie mit', async () => {
|
||||
const fetchMock = mockFetch(200, {
|
||||
path: '/Ärger & Co',
|
||||
entries: [],
|
||||
quota: {},
|
||||
truncated: false,
|
||||
});
|
||||
await listFolder('/Ärger & Co');
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`${API}/modules/nextcloud-files/files?path=%2F%C3%84rger%20%26%20Co`);
|
||||
expect(init.method).toBe('GET');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.cache).toBe('no-store');
|
||||
});
|
||||
|
||||
it('Fehlerkoerper werden zu NextcloudFilesRequestError mit Code und Zusatzfeldern', async () => {
|
||||
mockFetch(409, {
|
||||
code: 'nameTaken',
|
||||
message: 'Ein Eintrag mit diesem Namen existiert bereits.',
|
||||
existing: { etag: '"a"', size: 3, mtime: null },
|
||||
});
|
||||
const err = await moveEntry('/a', '/b').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NextcloudFilesRequestError);
|
||||
expect(err.status).toBe(409);
|
||||
expect(err.code).toBe('nameTaken');
|
||||
expect(err.message).toBe('Ein Eintrag mit diesem Namen existiert bereits.');
|
||||
expect(err.extra).toEqual({ existing: { etag: '"a"', size: 3, mtime: null } });
|
||||
});
|
||||
|
||||
it('createFolder sendet POST /folders mit { path }', async () => {
|
||||
const fetchMock = mockFetch(201, { path: '/Neu' });
|
||||
await createFolder('/Neu');
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`${API}/modules/nextcloud-files/folders`);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe('{"path":"/Neu"}');
|
||||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('moveEntry sendet POST /move mit { from, to }', async () => {
|
||||
const fetchMock = mockFetch(200, { from: '/a', to: '/b' });
|
||||
await moveEntry('/a', '/b');
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`${API}/modules/nextcloud-files/move`);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe('{"from":"/a","to":"/b"}');
|
||||
});
|
||||
|
||||
it('deleteEntry sendet DELETE /files?path= (codiert), ohne Body', async () => {
|
||||
const fetchMock = mockFetch(200, { deleted: true });
|
||||
await deleteEntry('/Ordner/a b#c.txt');
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`${API}/modules/nextcloud-files/files?path=%2FOrdner%2Fa%20b%23c.txt`);
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('previewUrl baut die Query mit encodeURIComponent; ohne Version fehlt v', () => {
|
||||
expect(previewUrl('42', '"abc 1"')).toBe(
|
||||
`${API}/modules/nextcloud-files/preview?fileId=42&v=%22abc%201%22`,
|
||||
);
|
||||
expect(previewUrl('42', null)).toBe(`${API}/modules/nextcloud-files/preview?fileId=42`);
|
||||
});
|
||||
});
|
||||
@@ -163,3 +163,67 @@ export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> {
|
||||
export function disconnectNextcloud(): Promise<{ disconnected: true }> {
|
||||
return request<{ disconnected: true }>('/connect', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// --- Dateien (Etappe 1, Aufgabe 3) -------------------------------------------------------------
|
||||
|
||||
export interface NcEntry {
|
||||
name: string;
|
||||
/** Pfad ab der Wurzel, z. B. `/Projekte/Bericht.pdf`. */
|
||||
path: string;
|
||||
type: 'folder' | 'file';
|
||||
size: number;
|
||||
mime: string | null;
|
||||
/** ISO-Zeitstempel. */
|
||||
mtime: string | null;
|
||||
etag: string | null;
|
||||
fileId: string | null;
|
||||
/** Berechtigungsbuchstaben der Nextcloud (R teilbar, D loeschbar, N umbenennbar ...). */
|
||||
permissions: string;
|
||||
hasPreview: boolean;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface NcQuota {
|
||||
used: number;
|
||||
/** null = unbegrenzt oder unbekannt. */
|
||||
available: number | null;
|
||||
}
|
||||
|
||||
export interface NcListing {
|
||||
path: string;
|
||||
entries: NcEntry[];
|
||||
quota: NcQuota;
|
||||
/** true, wenn der Ordner mehr als 5000 Eintraege hat und abgeschnitten wurde. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Ordnerinhalt samt Speicherangaben. Der Pfad geht nur in der Query, nie im URL-Pfad. */
|
||||
export function listFolder(path: string): Promise<NcListing> {
|
||||
return request<NcListing>(`/files?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
export function createFolder(path: string): Promise<{ path: string }> {
|
||||
return request<{ path: string }>('/folders', { method: 'POST', json: { path } });
|
||||
}
|
||||
|
||||
/** Verschieben oder Umbenennen; ein vorhandener Name im Ziel wird nie ueberschrieben (nameTaken). */
|
||||
export function moveEntry(from: string, to: string): Promise<{ from: string; to: string }> {
|
||||
return request<{ from: string; to: string }>('/move', { method: 'POST', json: { from, to } });
|
||||
}
|
||||
|
||||
/** Loescht in den Papierkorb der Nextcloud. */
|
||||
export function deleteEntry(path: string): Promise<{ deleted: true }> {
|
||||
return request<{ deleted: true }>(`/files?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse des Vorschaubildes fuer ein `<img>` (Tessera-Cookie genuegt). Die
|
||||
* Version (Entity-Tag) macht die Adresse je Dateiversion eindeutig und erlaubt
|
||||
* dem Browser, das Bild zu cachen.
|
||||
*/
|
||||
export function previewUrl(fileId: string, etag: string | null): string {
|
||||
const version = etag ? `&v=${encodeURIComponent(etag)}` : '';
|
||||
return `${API_URL}${BASE}/preview?fileId=${encodeURIComponent(fileId)}${version}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user