diff --git a/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.spec.ts b/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.spec.ts index 6c22414c75..28ea569820 100644 --- a/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.spec.ts +++ b/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.spec.ts @@ -32,7 +32,8 @@ import { ViewerMoreActionsComponent, ViewerToolbarActionsComponent, NoopAuthModule, - NoopTranslateModule + NoopTranslateModule, + UnitTestingUtils } from '@alfresco/adf-core'; import { NodesApiService } from '../../common/services/nodes-api.service'; import { UploadService } from '../../common/services/upload.service'; @@ -177,6 +178,7 @@ describe('AlfrescoViewerComponent', () => { let renditionService: RenditionService; let viewUtilService: ViewUtilService; let nodeActionsService: NodeActionsService; + let testingUtils: UnitTestingUtils; beforeEach(() => { TestBed.configureTestingModule({ @@ -197,6 +199,7 @@ describe('AlfrescoViewerComponent', () => { fixture = TestBed.createComponent(AlfrescoViewerComponent); element = fixture.nativeElement; component = fixture.componentInstance; + testingUtils = new UnitTestingUtils(fixture.debugElement); uploadService = TestBed.inject(UploadService); nodesApiService = TestBed.inject(NodesApiService); dialog = TestBed.inject(MatDialog); @@ -398,12 +401,16 @@ describe('AlfrescoViewerComponent', () => { component.nodeId = 'id1'; component.showViewer = true; component.versionId = null; - component.ngOnChanges(getSimpleChanges('id1')); - await fixture.whenStable(); + await fixture.ngZone.run(async () => { + component.ngOnChanges(getSimpleChanges('id1')); + await fixture.whenStable(); + }); + fixture.detectChanges(); - expect(component.fileName).toBe('file1.pdf'); - expect(component.blobFileContent).toBe(mockBlob); + const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent; + expect(viewer.fileName).toBe('file1.pdf'); + expect(viewer.blobFile).toBe(mockBlob); }); it('should change display name every time node`s version changes', fakeAsync(() => { @@ -1063,4 +1070,127 @@ describe('AlfrescoViewerComponent', () => { }); }); }); + + describe('PDF blob fetch and atomic state assignment', () => { + it('should assign blobFileContent and null urlFileContent when PDF blob fetch succeeds', async () => { + const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' }); + const mockResponse = { ok: true, blob: () => Promise.resolve(mockBlob) } as Response; + spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse)); + + spyOn(component.nodesApi, 'getNode').and.returnValue( + Promise.resolve( + new NodeEntry({ + entry: new Node({ name: 'test.pdf', id: 'node-1', content: new ContentInfo({ mimeType: 'application/pdf' }) }) + }) + ) + ); + + component.nodeId = 'node-1'; + component.showViewer = true; + + await fixture.ngZone.run(async () => { + component.ngOnChanges(getSimpleChanges('node-1')); + await fixture.whenStable(); + }); + fixture.detectChanges(); + + const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent; + expect(viewer.blobFile).toBe(mockBlob); + expect(viewer.urlFile).toBeFalsy(); + expect(viewer.mimeType).toBe('application/pdf'); + }); + + it('should fall back to URL-based viewing when PDF blob fetch fails', async () => { + spyOn(window, 'fetch').and.returnValue(Promise.reject(new Error('Network error'))); + spyOn(component.contentApi, 'getContentUrl').and.returnValue('/content/url'); + + spyOn(component.nodesApi, 'getNode').and.returnValue( + Promise.resolve( + new NodeEntry({ + entry: new Node({ + name: 'test.pdf', + id: 'node-1', + content: new ContentInfo({ mimeType: 'application/pdf' }), + properties: { 'cm:versionLabel': '1.0' } + }) + }) + ) + ); + + component.nodeId = 'node-1'; + component.showViewer = true; + + await fixture.ngZone.run(async () => { + component.ngOnChanges(getSimpleChanges('node-1')); + await fixture.whenStable(); + }); + fixture.detectChanges(); + + const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent; + expect(viewer.blobFile).toBeFalsy(); + expect(viewer.urlFile).toBeTruthy(); + expect(viewer.mimeType).toBe('application/pdf'); + }); + + it('should fall back to URL-based viewing when PDF fetch returns non-ok response', async () => { + const mockResponse = { ok: false, status: 403 } as Response; + spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse)); + spyOn(component.contentApi, 'getContentUrl').and.returnValue('/content/url'); + + spyOn(component.nodesApi, 'getNode').and.returnValue( + Promise.resolve( + new NodeEntry({ + entry: new Node({ + name: 'test.pdf', + id: 'node-1', + content: new ContentInfo({ mimeType: 'application/pdf' }), + properties: { 'cm:versionLabel': '1.0' } + }) + }) + ) + ); + + component.nodeId = 'node-1'; + component.showViewer = true; + + await fixture.ngZone.run(async () => { + component.ngOnChanges(getSimpleChanges('node-1')); + await fixture.whenStable(); + }); + fixture.detectChanges(); + + const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent; + expect(viewer.blobFile).toBeFalsy(); + expect(viewer.urlFile).toBeTruthy(); + expect(viewer.mimeType).toBe('application/pdf'); + }); + + it('should not expose intermediate null state during node setup', async () => { + const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' }); + const mockResponse = { ok: true, blob: () => Promise.resolve(mockBlob) } as Response; + spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse)); + + spyOn(component.nodesApi, 'getNode').and.returnValue( + Promise.resolve( + new NodeEntry({ + entry: new Node({ name: 'test.pdf', id: 'node-1', content: new ContentInfo({ mimeType: 'application/pdf' }) }) + }) + ) + ); + + component.nodeId = 'node-1'; + component.showViewer = true; + + await fixture.ngZone.run(async () => { + component.ngOnChanges(getSimpleChanges('node-1')); + await fixture.whenStable(); + }); + fixture.detectChanges(); + + const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent; + expect(viewer.blobFile).toBe(mockBlob); + expect(viewer.urlFile).toBeFalsy(); + expect(viewer.fileName).toBe('test.pdf'); + }); + }); }); diff --git a/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.ts b/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.ts index f20c80e66d..43fb56cf0a 100644 --- a/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.ts +++ b/lib/content-services/src/lib/viewer/components/alfresco-viewer.component.ts @@ -275,9 +275,9 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit { private async onNodeUpdated(node: Node) { if (node && node.id === this.nodeId) { this.generateCacheBusterNumber(); - this.blobFileContent = null; await this.setUpNodeFile(node); + this.cdr.detectChanges(); } } @@ -300,7 +300,6 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit { private async setupNode() { try { - this.blobFileContent = null; this.nodeEntry = await this.nodesApi.getNode(this.nodeId, { include: ['allowableOperations'] }); if (this.versionId) { this.versionEntry = await this.versionsApi.getVersion(this.nodeId, this.versionId); @@ -318,6 +317,7 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit { this.canEditNode = this.contentService.hasAllowableOperations(nodeData, 'update'); let mimeType: string; let urlFileContent: string; + let blobContent: Blob = null; if (versionData?.content) { mimeType = versionData.content.mimeType; @@ -352,32 +352,24 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit { } else if (viewerType === 'media') { this.tracks = await this.renditionService.generateMediaTracksRendition(this.nodeId); } else if (viewerType === 'pdf') { - try { - const contentUrl = versionData - ? this.contentApi.getVersionContentUrl(this.nodeId, versionData.id) - : this.contentApi.getContentUrl(this.nodeId); + const contentUrl = versionData + ? this.contentApi.getVersionContentUrl(this.nodeId, versionData.id) + : this.contentApi.getContentUrl(this.nodeId); - // Fetch the content as Blob using fetch API with credentials - const response = await fetch(contentUrl, { - credentials: 'include', - headers: { - 'Cache-Control': 'no-cache' - } - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + const blob = await fetch(contentUrl, { credentials: 'include', headers: { 'Cache-Control': 'no-cache' } }) + .then((response) => (response.ok ? response.blob() : null)) + .catch(() => null); - this.blobFileContent = await response.blob(); + if (blob) { + blobContent = blob; urlFileContent = null; - } catch (error) { - console.error('[ADF DEBUG] Failed to fetch PDF as blob, falling back to URL', error); } } this.mimeType = mimeType; this.nodeMimeType = nodeMimeType; this.fileName = versionData ? versionData.name : nodeData.name; + this.blobFileContent = blobContent; this.urlFileContent = urlFileContent ? urlFileContent + (this.cacheBusterNumber ? '&' + this.cacheBusterNumber : '') : null; this.sidebarRightTemplateContext.node = nodeData; this.sidebarLeftTemplateContext.node = nodeData; diff --git a/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.spec.ts b/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.spec.ts index 221afd903d..d8415eace2 100644 --- a/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.spec.ts +++ b/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.spec.ts @@ -539,7 +539,7 @@ describe('ViewerComponent', () => { fixture.detectChanges(); expect(getMainLoader()).toBeNull(); - expect(component.viewerType).toBe('media'); + expect(testingUtils.getByDirective(MediaPlayerComponent)).not.toBeNull(); }); it('should display spinner when viewerType is pdf', () => { @@ -553,7 +553,7 @@ describe('ViewerComponent', () => { imgViewer.triggerEventHandler('pagesLoaded', null); fixture.detectChanges(); - expect(component.viewerType).toBe('pdf'); + expect(testingUtils.getByDirective(MockPdfViewerComponent)).not.toBeNull(); }); it('should show spinner until renderer calls markAsLoaded', () => { @@ -570,7 +570,7 @@ describe('ViewerComponent', () => { fixture.detectChanges(); expect(getMainLoader()).toBeNull(); - expect(component.viewerType).toBe('image'); + expect(testingUtils.getByDirective(ImgViewerComponent)).not.toBeNull(); expect(component.markAsLoaded).toHaveBeenCalled(); }); @@ -582,7 +582,77 @@ describe('ViewerComponent', () => { component.onUnsupportedFile(); fixture.detectChanges(); expect(getMainLoader()).toBeNull(); - expect(component.viewerType).toBe('unknown'); + expect(testingUtils.getByCSS('adf-viewer-unknown-format')).not.toBeNull(); + }); + }); + + describe('Blob MIME type handling', () => { + it('should resolve viewerType from blob type when blob has valid MIME type', () => { + component.blobFile = new Blob(['content'], { type: 'application/pdf' }); + component.ngOnChanges(); + fixture.detectChanges(); + + expect(testingUtils.getByDirective(MockPdfViewerComponent)).not.toBeNull(); + }); + + it('should fall back to mimeType input when blob type resolves to unknown', () => { + component.blobFile = new Blob(['content'], { type: '' }); + component.mimeType = 'application/pdf'; + component.ngOnChanges(); + fixture.detectChanges(); + + expect(testingUtils.getByDirective(MockPdfViewerComponent)).not.toBeNull(); + }); + + it('should remain unknown when both blob type and mimeType input are empty', () => { + component.blobFile = new Blob(['content'], { type: '' }); + component.mimeType = ''; + component.ngOnChanges(); + fixture.detectChanges(); + + expect(testingUtils.getByCSS('adf-viewer-unknown-format')).not.toBeNull(); + }); + + it('should strip MIME type parameters (e.g. charset) from blob type before resolving viewer type', () => { + component.blobFile = new Blob(['content'], { type: 'application/pdf;charset=utf-8' }); + component.ngOnChanges(); + fixture.detectChanges(); + + expect(testingUtils.getByDirective(MockPdfViewerComponent)).not.toBeNull(); + }); + + it('should strip MIME type parameters from mimeType input during fallback', () => { + component.blobFile = new Blob(['content'], { type: '' }); + component.mimeType = 'application/pdf;charset=utf-8'; + component.ngOnChanges(); + fixture.detectChanges(); + + expect(testingUtils.getByDirective(MockPdfViewerComponent)).not.toBeNull(); + }); + + it('should resolve image viewer from blob type with parameters', () => { + component.blobFile = new Blob(['content'], { type: 'image/png; charset=binary' }); + component.ngOnChanges(); + fixture.detectChanges(); + + expect(testingUtils.getByDirective(ImgViewerComponent)).not.toBeNull(); + }); + + it('should emit extensionChange with blob MIME type when available', () => { + spyOn(component.extensionChange, 'emit'); + component.blobFile = new Blob(['content'], { type: 'application/pdf' }); + component.ngOnChanges(); + + expect(component.extensionChange.emit).toHaveBeenCalledWith('application/pdf'); + }); + + it('should emit extensionChange with mimeType input when blob type is empty', () => { + spyOn(component.extensionChange, 'emit'); + component.blobFile = new Blob(['content'], { type: '' }); + component.mimeType = 'application/pdf'; + component.ngOnChanges(); + + expect(component.extensionChange.emit).toHaveBeenCalledWith('application/pdf'); }); }); }); diff --git a/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts b/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts index 32001f2b0d..1101594290 100644 --- a/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts +++ b/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts @@ -241,15 +241,23 @@ export class ViewerRenderComponent implements OnChanges, OnInit { private setUpBlobData() { this.internalFileName = this.fileName; - this.viewerType = this.viewUtilService.getViewerTypeByMimeType(this.blobFile.type); + const blobMimeType = this.extractMimeTypeEssence(this.blobFile.type); + this.viewerType = this.viewUtilService.getViewerTypeByMimeType(blobMimeType); + if (this.viewerType === 'unknown' && this.mimeType) { + this.viewerType = this.viewUtilService.getViewerTypeByMimeType(this.extractMimeTypeEssence(this.mimeType)); + } if (this.viewerType === 'unknown') { this.isLoading = false; } - this.extensionChange.emit(this.blobFile.type); + this.extensionChange.emit(blobMimeType || this.extractMimeTypeEssence(this.mimeType)); this.scrollTop(); } + private extractMimeTypeEssence(mimeType: string): string { + return (mimeType || '').split(';')[0].trim(); + } + private setUpUrlFile() { this.internalFileName = this.fileName ? this.fileName : this.viewUtilService.getFilenameFromUrl(this.urlFile); this.extension = this.viewUtilService.getFileExtension(this.internalFileName); diff --git a/lib/core/src/lib/viewer/components/viewer.component.spec.ts b/lib/core/src/lib/viewer/components/viewer.component.spec.ts index b5ee2d42ac..279f79ad20 100644 --- a/lib/core/src/lib/viewer/components/viewer.component.spec.ts +++ b/lib/core/src/lib/viewer/components/viewer.component.spec.ts @@ -34,6 +34,7 @@ import { ViewerWithCustomToolbarComponent } from './mock/adf-viewer-container-to import { ViewerComponent } from './viewer.component'; import { ThumbnailService } from '../../common/services/thumbnail.service'; import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { ViewerRenderComponent } from './viewer-render/viewer-render.component'; @Component({ selector: 'adf-dialog-dummy', @@ -113,8 +114,57 @@ describe('ViewerComponent', () => { }; component.ngOnChanges(mockSimpleChanges); + fixture.detectChanges(); - expect(component.mimeType).toBe('image/png'); + const viewerRender = testingUtils.getByDirective(ViewerRenderComponent).componentInstance as ViewerRenderComponent; + expect(viewerRender.mimeType).toBe('image/png'); + }); + + it('should strip MIME type parameters from blob type', () => { + const mockSimpleChanges: SimpleChanges = { + blobFile: new SimpleChange(null, { type: 'application/pdf;charset=utf-8' }, true) + }; + + component.ngOnChanges(mockSimpleChanges); + fixture.detectChanges(); + + const viewerRender = testingUtils.getByDirective(ViewerRenderComponent).componentInstance as ViewerRenderComponent; + expect(viewerRender.mimeType).toBe('application/pdf'); + }); + + it('should strip MIME type parameters with spaces from blob type', () => { + const mockSimpleChanges: SimpleChanges = { + blobFile: new SimpleChange(null, { type: 'image/png; charset=binary' }, true) + }; + + component.ngOnChanges(mockSimpleChanges); + fixture.detectChanges(); + + const viewerRender = testingUtils.getByDirective(ViewerRenderComponent).componentInstance as ViewerRenderComponent; + expect(viewerRender.mimeType).toBe('image/png'); + }); + + it('should handle empty blob type gracefully', () => { + const mockSimpleChanges: SimpleChanges = { + blobFile: new SimpleChange(null, { type: '' }, true) + }; + + component.ngOnChanges(mockSimpleChanges); + fixture.detectChanges(); + + const viewerRender = testingUtils.getByDirective(ViewerRenderComponent).componentInstance as ViewerRenderComponent; + expect(viewerRender.mimeType).toBe(''); + }); + + it('should set mimeTypeIconUrl with stripped MIME type from blob', () => { + spyOn(thumbnailService, 'getMimeTypeIcon').and.returnValue('icon-url'); + const mockSimpleChanges: SimpleChanges = { + blobFile: new SimpleChange(null, { type: 'application/pdf;charset=utf-8' }, true) + }; + + component.ngOnChanges(mockSimpleChanges); + + expect(thumbnailService.getMimeTypeIcon).toHaveBeenCalledWith('application/pdf'); }); it('should set mimeTypeIconUrl when mimeType changes and no nodeMimeType is provided', () => { diff --git a/lib/core/src/lib/viewer/components/viewer.component.ts b/lib/core/src/lib/viewer/components/viewer.component.ts index 4b084da0f6..ff8ca7eee5 100644 --- a/lib/core/src/lib/viewer/components/viewer.component.ts +++ b/lib/core/src/lib/viewer/components/viewer.component.ts @@ -347,8 +347,9 @@ export class ViewerComponent implements OnDestroy, OnInit, OnChanges { const { blobFile, urlFile, mimeType, nodeMimeType } = changes; if (blobFile?.currentValue) { - this.mimeType = blobFile.currentValue.type; - this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(blobFile.currentValue.type); + const blobMimeType = this.extractMimeTypeEssence(blobFile.currentValue.type); + this.mimeType = blobMimeType; + this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(blobMimeType); } if (urlFile?.currentValue) { @@ -356,14 +357,18 @@ export class ViewerComponent implements OnDestroy, OnInit, OnChanges { } if (mimeType?.currentValue && !nodeMimeType?.currentValue) { - this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(mimeType.currentValue); + this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(this.extractMimeTypeEssence(mimeType.currentValue)); } if (nodeMimeType?.currentValue) { - this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(nodeMimeType.currentValue); + this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(this.extractMimeTypeEssence(nodeMimeType.currentValue)); } } + private extractMimeTypeEssence(mimeType: string): string { + return (mimeType || '').split(';')[0].trim(); + } + ngOnInit(): void { this.closeOverlayManager(); this.configureAndInitDownloadPrompt(); diff --git a/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.spec.ts b/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.spec.ts index 3ddc7ce33a..5fa6bf6af2 100644 --- a/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.spec.ts +++ b/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.spec.ts @@ -27,6 +27,7 @@ import { PdfThumbListComponent } from '../pdf-viewer-thumbnails/pdf-viewer-thumb import { PDFJS_MODULE, PDFJS_VIEWER_MODULE, PdfViewerComponent } from './pdf-viewer.component'; import pdfjsLibraryMock, { annotations } from '../mock/pdfjs-lib.mock'; import { TranslateService } from '@ngx-translate/core'; +import { PDFDocumentLoadingTask } from 'pdfjs-dist/types/src/display/api'; declare const pdfjsLib: { PasswordResponses: { @@ -354,6 +355,54 @@ describe('Test PdfViewer component', () => { }); }); +describe('Test PdfViewer - executePdf error handling', () => { + let fixture: ComponentFixture; + let component: PdfViewerComponent; + + beforeEach(() => { + const failingLoadingTask = { + promise: Promise.reject(new Error('PDF load failed')), + onPassword: null, + onProgress: null, + destroy: () => Promise.resolve() + } as PDFDocumentLoadingTask; + + const pdfjsLibMock = { + GlobalWorkerOptions: {}, + getDocument: () => failingLoadingTask + }; + + TestBed.configureTestingModule({ + imports: [PdfViewerComponent], + providers: [ + provideCoreAuthTesting(), + { + provide: MatDialog, + useValue: { + open: () => {} + } + }, + RenderingQueueServices, + { provide: PDFJS_MODULE, useValue: pdfjsLibMock } + ] + }); + + fixture = TestBed.createComponent(PdfViewerComponent); + component = fixture.componentInstance; + }); + + it('should emit error event when PDF loading fails', fakeAsync(() => { + spyOn(component.error, 'emit'); + // Pre-set the worker URL so the real setupPdfJsWorker logic runs without performing a network fetch + component.pdfJsWorkerUrl = URL.createObjectURL(new Blob([''], { type: 'application/javascript' })); + + component.executePdf({ data: new ArrayBuffer(0) }); + flush(); + + expect(component.error.emit).toHaveBeenCalled(); + })); +}); + describe('Test PdfViewer - Zoom customization', () => { let fixture: ComponentFixture; let component: PdfViewerComponent; diff --git a/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.ts b/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.ts index bc38bb7507..9a5c11c3e4 100644 --- a/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.ts +++ b/lib/core/viewer/pdf/src/lib/components/pdf-viewer/pdf-viewer.component.ts @@ -292,7 +292,9 @@ export class PdfViewerComponent implements OnChanges, OnDestroy { .then(() => { setTimeout(() => this.scalePage('init')); }) - .catch(() => this.error.emit()); + .catch(() => { + this.error.emit(); + }); }); }