From 1f72aed7b421626bad4e333859197022b7838d7e Mon Sep 17 00:00:00 2001 From: robyngit Date: Thu, 12 Sep 2024 11:30:37 -0400 Subject: [PATCH] Modularize downloadWithCredentials in SolrResult - Separate out the logic for getting the data from DataONE from the logic for downloading that data onto the user's machine. - Allows us to use the same data fetching logic in other parts of the app, i.e. the DataONEObjectView. - Also separate out the logic for getting the file name. - Add a tests for the new functions Issue #1758 --- src/js/models/SolrResult.js | 175 +++++++++---------- test/js/specs/unit/models/SolrResult.spec.js | 106 ++++++++++- 2 files changed, 184 insertions(+), 97 deletions(-) diff --git a/src/js/models/SolrResult.js b/src/js/models/SolrResult.js index 4bf2cb00a..fd8c2ddeb 100644 --- a/src/js/models/SolrResult.js +++ b/src/js/models/SolrResult.js @@ -1,11 +1,11 @@ -define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { +define(["jquery", "underscore", "backbone"], ($, _, Backbone) => { /** * @class SolrResult * @classdesc A single result from the Solr search service * @classcategory Models * @extends Backbone.Model */ - var SolrResult = Backbone.Model.extend( + const SolrResult = Backbone.Model.extend( /** @lends SolrResult.prototype */ { // This model contains all of the attributes found in the SOLR 'docs' field inside of the SOLR response element defaults: { @@ -278,103 +278,96 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { ); }, - /* - * This method will download this object while sending the user's auth token in the request. + /** + * Download this object while sending the user's auth token in the + * request. */ - downloadWithCredentials: function () { - //if(this.get("isPublic")) return; - - //Get info about this object - var url = this.get("url"), - model = this; - - //Create an XHR - var xhr = new XMLHttpRequest(); + async downloadWithCredentials() { + const model = this; - //Open and send the request with the user's auth token - xhr.open("GET", url); + // Call the new getBlob method and handle the response + const response = await this.fetchDataObjectWithCredentials(); + const blob = await response.blob(); + const filename = this.getFileNameFromResponse(response); - if (MetacatUI.appUserModel.get("loggedIn")) xhr.withCredentials = true; - - //When the XHR is ready, create a link with the raw data (Blob) and click the link to download - xhr.onload = function () { - if (this.status == 404) { - this.onerror.call(this); - return; - } - - //Get the file name to save this file as - var filename = xhr.getResponseHeader("Content-Disposition"); - - if (!filename) { - filename = - model.get("fileName") || - model.get("title") || - model.get("id") || - "download"; - } else - filename = filename - .substring(filename.indexOf("filename=") + 9) - .replace(/"/g, ""); - - //Replace any whitespaces - filename = filename.trim().replace(/ /g, "_"); - - //For IE, we need to use the navigator API - if (navigator && navigator.msSaveOrOpenBlob) { - navigator.msSaveOrOpenBlob(xhr.response, filename); - } - //Other browsers can download it via a link - else { - var a = document.createElement("a"); - a.href = window.URL.createObjectURL(xhr.response); // xhr.response is a blob - - // Set the file name. - a.download = filename; - - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - a.remove(); - } - - model.trigger("downloadComplete"); - - // Track this event - MetacatUI.analytics?.trackEvent( - "download", - "Download DataONEObject", - model.get("id"), - ); - }; - - xhr.onerror = function (e) { - model.trigger("downloadError"); + // For IE, we need to use the navigator API + if (navigator && navigator.msSaveOrOpenBlob) { + navigator.msSaveOrOpenBlob(blob, filename); + } else { + // Other browsers can download it via a link + const a = document.createElement("a"); + a.href = window.URL.createObjectURL(blob); + a.download = filename; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + a.remove(); + } - // Track the error - MetacatUI.analytics?.trackException( - `Download DataONEObject error: ${e || ""}`, - model.get("id"), - true, - ); - }; + // Track this event + model.trigger("downloadComplete"); + MetacatUI.analytics?.trackEvent( + "download", + "Download DataONEObject", + model.get("id"), + ); + }, - xhr.onprogress = function (e) { - if (e.lengthComputable) { - var percent = (e.loaded / e.total) * 100; - model.set("downloadPercent", percent); + /** + * This method will fetch this object while sending the user's auth token + * in the request. The data can then be downloaded or displayed in the + * browser + * @returns {Promise} A promise that resolves when the data is fetched + * @since 0.0.0 + */ + fetchDataObjectWithCredentials() { + const url = this.get("url"); + const token = MetacatUI.appUserModel.get("token") || ""; + const method = "GET"; + + return new Promise((resolve, reject) => { + const headers = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; } - }; - - xhr.responseType = "blob"; - if (MetacatUI.appUserModel.get("loggedIn")) - xhr.setRequestHeader( - "Authorization", - "Bearer " + MetacatUI.appUserModel.get("token"), - ); + fetch(url, { method, headers }) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + resolve(response); + }) + .catch((error) => { + reject(error); + }); + }); + }, - xhr.send(); + /** + * Get the filename from the response headers or default to the model's + * title, id, or "download" + * @param {Response} response - The response object from the fetch request + * @returns {string} The filename to save the file as + * @since 0.0.0 + */ + getFileNameFromResponse(response) { + const model = this; + let filename = response.headers.get("Content-Disposition"); + + if (!filename) { + filename = + model.get("fileName") || + model.get("title") || + model.get("id") || + "download"; + } else { + filename = filename + .substring(filename.indexOf("filename=") + 9) + .replace(/"/g, ""); + } + filename = filename.trim().replace(/ /g, "_"); + return filename; }, getInfo: function (fields) { diff --git a/test/js/specs/unit/models/SolrResult.spec.js b/test/js/specs/unit/models/SolrResult.spec.js index b67f00a03..f3c08968b 100644 --- a/test/js/specs/unit/models/SolrResult.spec.js +++ b/test/js/specs/unit/models/SolrResult.spec.js @@ -1,20 +1,21 @@ -define(["../../../../../../../../src/js/models/SolrResult"], function ( - SolrResult, -) { +define(["models/SolrResult"], function (SolrResult) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; - let solrResult; + let solrResult, fetchStub; - describe("Search Test Suite", function () { + describe("SolrResult Test Suite", function () { /* Set up */ beforeEach(function () { solrResult = new SolrResult(); + // Create a stub for the fetch API + fetchStub = sinon.stub(window, "fetch"); }); /* Tear down */ - after(function () { + afterEach(function () { solrResult = undefined; + fetchStub.restore(); }); describe("The SolrResult model", function () { @@ -22,5 +23,98 @@ define(["../../../../../../../../src/js/models/SolrResult"], function ( solrResult.should.be.instanceof(SolrResult); }); }); + + describe("downloadWithCredentials", function () { + it("should download a file with valid credentials", async function () { + const mockBlob = new Blob(["test"], { type: "text/plain" }); + const mockResponse = new Response(mockBlob, { + status: 200, + headers: { + "Content-Disposition": 'attachment; filename="testfile.txt"', + }, + }); + + // Mock fetch response + fetchStub.resolves(mockResponse); + + // Spy on model.trigger to check if the events are triggered + const triggerSpy = sinon.spy(solrResult, "trigger"); + + // Execute the downloadWithCredentials method + await solrResult.downloadWithCredentials(); + + // Ensure that fetch was called once + sinon.assert.calledOnce(fetchStub); + + // Check if the downloadComplete event was triggered + sinon.assert.calledWith(triggerSpy, "downloadComplete"); + }); + }); + + describe("fetchDataObjectWithCredentials", function () { + it("should fetch the data object with valid credentials", async function () { + const mockResponse = new Response("{}", { status: 200 }); + + // Mock fetch response + fetchStub.resolves(mockResponse); + + // Execute the fetchDataObjectWithCredentials method + const response = await solrResult.fetchDataObjectWithCredentials(); + + // Ensure that fetch was called once + sinon.assert.calledOnce(fetchStub); + + // The response should be the mock response + response.status.should.equal(200); + }); + + it("should throw an error for a failed fetch", async function () { + // Mock a failed fetch response + fetchStub.rejects(new Error("Failed to fetch")); + + try { + await solrResult.fetchDataObjectWithCredentials(); + } catch (error) { + error.message.should.equal("Failed to fetch"); + } + + // Ensure that fetch was called once + sinon.assert.calledOnce(fetchStub); + }); + }); + + describe("getFileNameFromResponse", function () { + it("should extract filename from Content-Disposition header", function () { + const mockResponse = new Response(null, { + headers: { + "Content-Disposition": 'attachment; filename="testfile.txt"', + }, + }); + + // Execute getFileNameFromResponse + const filename = solrResult.getFileNameFromResponse(mockResponse); + + // Ensure the filename is correct + filename.should.equal("testfile.txt"); + }); + + it("should fall back to model attributes for filename", function () { + // Set model properties + sinon.stub(solrResult, "get").callsFake(function (attr) { + if (attr === "fileName") return "defaultFileName.txt"; + return null; + }); + + const mockResponse = new Response(null, { + headers: {}, + }); + + // Execute getFileNameFromResponse + const filename = solrResult.getFileNameFromResponse(mockResponse); + + // Ensure the fallback filename is correct + filename.should.equal("defaultFileName.txt"); + }); + }); }); });