diff --git a/frontend/src/app/pages/cloud/cloud.component.html b/frontend/src/app/pages/cloud/cloud.component.html index 68947300..1eec0883 100644 --- a/frontend/src/app/pages/cloud/cloud.component.html +++ b/frontend/src/app/pages/cloud/cloud.component.html @@ -17,6 +17,18 @@

Cloud Page

+ Cloud Page ariaLabel="Create share link" > { expect(component.isEditorDirty).toBeFalse(); }); }); + + describe('folder download', () => { + let component: CloudComponent; + let cloudMock: jasmine.SpyObj; + let toastMock: jasmine.SpyObj; + + beforeEach(() => { + jasmine.clock().uninstall(); + cloudMock = jasmine.createSpyObj('CloudService', [ + 'getFolderArchive', + ]); + toastMock = jasmine.createSpyObj('UiToastService', [ + 'success', + 'info', + 'warn', + 'error', + ]); + component = new CloudComponent( + cloudMock, + {} as never, + toastMock as never, + {} as never, + {} as never, + ); + component.rootPath = '/root'; + component.currentFolder = { path: '/root/sub', name: 'sub' } as never; + }); + + it('downloads the folder as a zip archive and triggers browser download', () => { + const mockBlob = new Blob(['zip content'], { type: 'application/zip' }); + cloudMock.getFolderArchive.and.returnValue(of(mockBlob)); + + const mockAnchor = jasmine.createSpyObj( + 'HTMLAnchorElement', + ['click'], + ); + spyOn(document, 'createElement').and.returnValue(mockAnchor as any); + spyOn(URL, 'createObjectURL').and.returnValue('blob:mock-url'); + spyOn(URL, 'revokeObjectURL'); + + component.downloadFolder('/root/sub', 'sub'); + + expect(cloudMock.getFolderArchive).toHaveBeenCalledWith('sub'); + expect(document.createElement).toHaveBeenCalledWith('a'); + expect(mockAnchor.href).toBe('blob:mock-url'); + expect(mockAnchor.download).toBe('sub.zip'); + expect(mockAnchor.click).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + expect(toastMock.info).toHaveBeenCalledWith( + 'Download started', + '"sub.zip" is downloading.', + ); + }); + + it('surfaces folder archive backend errors using toast notifications', (done) => { + const mockErrorResponse = { + error: new Blob([JSON.stringify({ message: 'Rate limit exceeded' })], { + type: 'application/json', + }), + }; + spyOn(component, 'getErrorMessage').and.returnValue( + 'Rate limit exceeded', + ); + cloudMock.getFolderArchive.and.returnValue( + throwError(() => mockErrorResponse), + ); + + component.downloadFolder('/root/sub', 'sub'); + + setTimeout(() => { + expect(toastMock.error).toHaveBeenCalledWith( + 'Download failed', + 'Rate limit exceeded', + ); + done(); + }, 50); + }); + + it('falls back to generic error message if error blob is not parseable', (done) => { + const mockErrorResponse = { + error: new Blob(['invalid json'], { type: 'application/json' }), + }; + spyOn(component, 'getErrorMessage').and.returnValue( + 'Request failed. Please try again.', + ); + cloudMock.getFolderArchive.and.returnValue( + throwError(() => mockErrorResponse), + ); + + component.downloadFolder('/root/sub', 'sub'); + + setTimeout(() => { + expect(toastMock.error).toHaveBeenCalledWith( + 'Download failed', + 'Request failed. Please try again.', + ); + done(); + }, 50); + }); + }); }); describe('CloudComponent Secure Send Flow', () => { diff --git a/frontend/src/app/pages/cloud/cloud.component.ts b/frontend/src/app/pages/cloud/cloud.component.ts index a5bfbc74..01d0274e 100644 --- a/frontend/src/app/pages/cloud/cloud.component.ts +++ b/frontend/src/app/pages/cloud/cloud.component.ts @@ -31,7 +31,7 @@ import { ScanJobDto } from '../../models/dtos/ScanJobDto'; import { FileScanResultDto } from '../../models/dtos/FileScanResultDto'; import { SecureSendLinkDto } from '../../models/dtos/SecureSendLinkDto'; import { CloudService } from '../../services/cloud.service'; -import { finalize, firstValueFrom } from 'rxjs'; +import { finalize, firstValueFrom, Observable, of } from 'rxjs'; import { UiToastService } from '../../core/services/ui-toast.service'; interface Breadcrumb { @@ -1030,6 +1030,77 @@ export class CloudComponent implements OnInit, OnDestroy { this.downloadFile(this.toFileRef(path, name)); } + downloadFolder(folderPath: string, folderName: string) { + const pathKey = folderPath; + const relativePath = this.getRelativePath(folderPath); + this.downloadingPaths.add(pathKey); + + this.cloudService + .getFolderArchive(relativePath) + .pipe( + finalize(() => { + this.downloadingPaths.delete(pathKey); + }), + ) + .subscribe({ + next: (blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const zipName = folderName ? `${folderName}.zip` : 'archive.zip'; + a.download = zipName; + a.click(); + window.URL.revokeObjectURL(url); + this.toast.info('Download started', `"${zipName}" is downloading.`); + }, + error: (err) => { + this.parseBlobError(err).subscribe((msg) => { + this.toast.error('Download failed', msg); + }); + }, + }); + } + + downloadFolderByPath(path: string, name: string) { + this.downloadFolder(path, name); + } + + downloadCurrentFolder() { + if (this.currentFolder) { + const folderName = + this.currentFolder.path === this.rootPath + ? 'cloud-root' + : this.getNameFromPath(this.currentFolder.path); + this.downloadFolder(this.currentFolder.path, folderName); + } + } + + private parseBlobError(err: unknown): Observable { + const candidate = err as { error?: unknown }; + if (candidate && candidate.error instanceof Blob) { + const reader = new FileReader(); + return new Observable((observer) => { + reader.onload = () => { + try { + const parsed = JSON.parse(reader.result as string); + observer.next( + parsed.message || 'Request failed. Please try again.', + ); + } catch { + observer.next('Request failed. Please try again.'); + } + observer.complete(); + }; + reader.onerror = () => { + observer.next('Request failed. Please try again.'); + observer.complete(); + }; + reader.readAsText(candidate.error as Blob); + }); + } + return of(this.getErrorMessage(err)); + } + previewFileByPath(path: string, name: string) { this.previewFile(this.toFileRef(path, name)); } diff --git a/frontend/src/app/services/cloud.service.spec.ts b/frontend/src/app/services/cloud.service.spec.ts index 66c45df5..993f9d4f 100644 --- a/frontend/src/app/services/cloud.service.spec.ts +++ b/frontend/src/app/services/cloud.service.spec.ts @@ -101,4 +101,13 @@ describe('CloudService Secure Send', () => { expect(req.request.method).toBe('DELETE'); req.flush(null); }); + + it('should request folders/download with path query param for folder archive', () => { + service.getFolderArchive('test-folder').subscribe(); + + const req = httpMock.expectOne((r) => r.url.endsWith('/folders/download')); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('path')).toBe('test-folder'); + req.flush(new Blob(['zip'], { type: 'application/zip' })); + }); }); diff --git a/frontend/src/app/services/cloud.service.ts b/frontend/src/app/services/cloud.service.ts index cedc446e..dac5f532 100644 --- a/frontend/src/app/services/cloud.service.ts +++ b/frontend/src/app/services/cloud.service.ts @@ -171,6 +171,14 @@ export class CloudService { }); } + getFolderArchive(relativePath: string): Observable { + const path = this.normalizePath(relativePath); + return this.http.get(`${this.apiUrl}/folders/download`, { + params: new HttpParams().set('path', path), + responseType: 'blob', + }); + } + getFileView(path: string) { return this.http.get(`${this.apiUrl}/files/view`, { params: { path },