From aaa148a24e332b7eeb707dd12da48a92ac2f3701 Mon Sep 17 00:00:00 2001 From: Josh McLeod Date: Fri, 27 Feb 2026 15:26:46 -0500 Subject: [PATCH] Decode url correctly in pdf download --- package.json | 2 +- src/components/pdf-viewer/pdf-viewer.js | 5 +- test/components/pdf-viewer.test.js | 109 ++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d3a3ab2..404adea 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@rolemodel/spider", "description": "Shared high level web components for RoleModel Software and beyond", "packageManager": "yarn@4.12.0", - "version": "0.0.5", + "version": "0.0.6", "author": "RoleModel Software", "license": "MIT", "type": "module", diff --git a/src/components/pdf-viewer/pdf-viewer.js b/src/components/pdf-viewer/pdf-viewer.js index 8674f74..7171e77 100644 --- a/src/components/pdf-viewer/pdf-viewer.js +++ b/src/components/pdf-viewer/pdf-viewer.js @@ -310,9 +310,12 @@ export default class PDFViewer extends RoleModelElement { const blob = await response.blob() const blobUrl = URL.createObjectURL(blob) + const url = new URL(this.src, window.location.href) + const filename = url.pathname.split('/').pop() || 'document.pdf' + const link = document.createElement('a') link.href = blobUrl - link.download = this.src.split('/').pop() || 'document.pdf' + link.download = decodeURIComponent(filename) link.click() URL.revokeObjectURL(blobUrl) diff --git a/test/components/pdf-viewer.test.js b/test/components/pdf-viewer.test.js index efc1b8e..41db421 100644 --- a/test/components/pdf-viewer.test.js +++ b/test/components/pdf-viewer.test.js @@ -145,6 +145,115 @@ describe('PDFViewer Component', () => { }) }) + describe('Download PDF', () => { + let createdLink + let originalFetch + + beforeEach(async () => { + element = await createViewer({ src: '/documents/report.pdf', open: true }) + + originalFetch = global.fetch + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: vi.fn().mockReturnValue('application/pdf') }, + blob: vi.fn().mockResolvedValue(new Blob(['%PDF'], { type: 'application/pdf' })) + }) + + global.URL.createObjectURL = vi.fn().mockReturnValue('blob:mock-url') + global.URL.revokeObjectURL = vi.fn() + + createdLink = null + const originalCreateElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = originalCreateElement(tag) + if (tag === 'a') createdLink = el + return el + }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should extract the filename from a plain URL', async () => { + await element.downloadPDF() + + expect(createdLink.download).toBe('report.pdf') + expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled() + }) + + it('should strip query parameters from the filename', async () => { + element.src = '/documents/KP%20Covenant.pdf?disposition=inline' + await element.updateComplete + + await element.downloadPDF() + + expect(createdLink.download).toBe('KP Covenant.pdf') + }) + + it('should decode URL-encoded characters in the filename', async () => { + element.src = '/files/My%20Document%20%282024%29.pdf' + await element.updateComplete + + await element.downloadPDF() + + expect(createdLink.download).toBe('My Document (2024).pdf') + }) + + it('should fall back to "document.pdf" when the URL has no filename', async () => { + element.src = 'https://example.com/' + await element.updateComplete + + await element.downloadPDF() + + expect(createdLink.download).toBe('document.pdf') + }) + + it('should create a blob URL and revoke it after download', async () => { + await element.downloadPDF() + + expect(URL.createObjectURL).toHaveBeenCalled() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') + }) + + it('should not fetch when src is empty', async () => { + element.src = '' + await element.updateComplete + + await element.downloadPDF() + + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('should log an error when the fetch response is not ok', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await element.downloadPDF() + + expect(consoleSpy).toHaveBeenCalled() + }) + + it('should log an error when the response content type is not PDF', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: vi.fn().mockReturnValue('text/html') }, + blob: vi.fn() + }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await element.downloadPDF() + + expect(consoleSpy).toHaveBeenCalled() + }) + }) + describe('Toolbar', () => { beforeEach(async () => { element = await createViewer({ src: '/test.pdf', open: true })