diff --git a/jest.config.js b/jest.config.js index 1804b678c..44c10b5cf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,11 @@ const config = { testEnvironment: "jsdom", testEnvironmentOptions: { - url: "https://design-patterns.service.juice.gov.uk/", }, - setupFilesAfterEnv: ["./jest.setup.js"], + setupFilesAfterEnv: ["./jest.setup.js", "jest-sinon"], + // See: https://github.com/sinonjs/sinon/issues/2522#issuecomment-1612555284 + moduleNameMapper: { + sinon: "/node_modules/sinon/pkg/sinon.js", + }, }; module.exports = config; diff --git a/package-lock.json b/package-lock.json index 6166697bd..15f106ace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "jest": "^29.2.2", "jest-axe": "^9.0.0", "jest-environment-jsdom": "^29.7.0", + "jest-sinon": "^1.1.0", "jquery": "^3.7.1", "js-beautify": "^1.13.13", "lodash": "^4.17.21", @@ -68,7 +69,8 @@ "mock-match-media": "^0.4.3", "npm-run-all2": "^6.0.0", "postcss": "^8.4.31", - "semantic-release": "^23.0.0" + "semantic-release": "^23.0.0", + "sinon": "^19.0.2" } }, "node_modules/@11ty/dependency-tree": { @@ -5168,6 +5170,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", @@ -10137,6 +10165,15 @@ "node": ">= 0.8.0" } }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -18372,6 +18409,19 @@ "node": ">=8" } }, + "node_modules/jest-sinon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-sinon/-/jest-sinon-1.1.0.tgz", + "integrity": "sha512-l0BvXxHffvPNjQsf+LbkMK2kGcKYuOmZUP1KNDXX/ueUfRuBXRJLohQILcRvhEyH+ANiL941pHHEmAqe12MCSg==", + "dev": true, + "dependencies": { + "jest-matcher-utils": ">=24.0.0" + }, + "peerDependencies": { + "jest": ">=24.0.0", + "sinon": ">=7.0.0" + } + }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -19201,6 +19251,12 @@ "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==", "devOptional": true }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "node_modules/keyv": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.0.0.tgz", @@ -19684,6 +19740,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.isfunction": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", @@ -20859,6 +20921,37 @@ "devOptional": true, "license": "MIT" }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -28059,6 +28152,54 @@ "node": ">=4" } }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", diff --git a/package.json b/package.json index d47dd3c89..f02a1bb34 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "jest": "^29.2.2", "jest-axe": "^9.0.0", "jest-environment-jsdom": "^29.7.0", + "jest-sinon": "^1.1.0", "jquery": "^3.7.1", "js-beautify": "^1.13.13", "lodash": "^4.17.21", @@ -98,7 +99,8 @@ "mock-match-media": "^0.4.3", "npm-run-all2": "^6.0.0", "postcss": "^8.4.31", - "semantic-release": "^23.0.0" + "semantic-release": "^23.0.0", + "sinon": "^19.0.2" }, "config": { "commitizen": { diff --git a/src/moj/components/multi-file-upload/multi-file-upload.js b/src/moj/components/multi-file-upload/multi-file-upload.js index eebc08bf0..54387fccd 100644 --- a/src/moj/components/multi-file-upload/multi-file-upload.js +++ b/src/moj/components/multi-file-upload/multi-file-upload.js @@ -78,7 +78,7 @@ if(MOJFrontend.dragAndDropSupported() && MOJFrontend.formDataSupported() && MOJF this.uploadFiles(e.currentTarget.files); this.fileInput.replaceWith($(e.currentTarget).val('').clone(true)); this.setupFileInput(); - this.fileInput.focus(); + this.fileInput.get(0).focus(); }; MOJFrontend.MultiFileUpload.prototype.onFileFocus = function(e) { diff --git a/src/moj/components/multi-file-upload/multi-file-upload.spec.js b/src/moj/components/multi-file-upload/multi-file-upload.spec.js new file mode 100644 index 000000000..2a95b5c61 --- /dev/null +++ b/src/moj/components/multi-file-upload/multi-file-upload.spec.js @@ -0,0 +1,525 @@ +const sinon = require("sinon"); +const { + queryByRole, + getByLabelText, + fireEvent, +} = require("@testing-library/dom"); +const { userEvent } = require("@testing-library/user-event"); +const { configureAxe } = require("jest-axe"); + +require("../../helpers.js"); +require("./multi-file-upload.js"); + +const user = userEvent.setup(); +const axe = configureAxe({ + rules: { + // disable landmark rules when testing isolated components. + region: { enabled: false }, + }, +}); + +const createComponent = (options = {}) => { + const html = ` +
+
+
+
+

Files added

+
+
+
+
+
+ + +
+
+
+
+
`; + + document.body.insertAdjacentHTML("afterbegin", html); + const component = document.querySelector(".moj-multi-file-upload"); + return { + component, + options: { container: component, ...options }, + }; +}; + +describe("Multi-file upload", () => { + let component; + let options; + let server; + let uploadFileEntryHook; + let uploadFileExitHook; + let uploadFileErrorHook; + let fileDeleteHook; + + beforeEach(() => { + server = sinon.fakeServerWithClock.create({ + respondImmediately: true, + }); + + uploadFileEntryHook = sinon.spy(); + uploadFileExitHook = sinon.spy(); + uploadFileErrorHook = sinon.spy(); + fileDeleteHook = sinon.spy(); + + ({ component, options } = createComponent({ + uploadFileEntryHook: uploadFileEntryHook, + uploadFileExitHook: uploadFileExitHook, + uploadFileErrorHook: uploadFileErrorHook, + fileDeleteHook: fileDeleteHook, + uploadUrl: "/upload", + deleteUrl: "/delete", + })); + + new MOJFrontend.MultiFileUpload(options); + }); + + afterEach(() => { + document.body.innerHTML = ""; + server.restore(); + sinon.restore(); + }); + + test("initialises with enhanced class", () => { + expect(component).toHaveClass("moj-multi-file-upload--enhanced"); + }); + + test("creates dropzone with correct text", () => { + const dropzone = component.querySelector( + ".moj-multi-file-upload__dropzone", + ); + expect(dropzone).toBeInTheDocument(); + expect(dropzone).toHaveTextContent("Drag and drop files here or"); + expect(dropzone.querySelector("label")).toHaveTextContent("Choose files"); + }); + + test("creates status box for announcements", () => { + const statusBox = queryByRole(component, "status"); + expect(statusBox).toBeInTheDocument(); + expect(statusBox).toHaveClass("govuk-visually-hidden"); + }); + + describe("File upload handling", () => { + let file; + let input; + const successResponse = { + success: { + messageHtml: "File uploaded successfully", + messageText: "File uploaded successfully", + }, + file: { + filename: "test", + originalname: "test.txt", + }, + }; + + beforeEach(() => { + file = new File(["test content"], "test.txt", { type: "text/plain" }); + input = component.querySelector(".moj-multi-file-upload__input"); + input = getByLabelText(component, "Upload a file"); + + // Configure server response for file upload + server.respondWith("POST", "/upload", [ + 200, + { "Content-Type": "application/json" }, + JSON.stringify(successResponse), + ]); + }); + + test("handles file input change", async () => { + const changeEvent = new Event("change", { bubbles: true }); + + //input.files is not writable, so we do this to add the files to the input + Object.defineProperty(input, "files", { + value: { files: [file] }, + }); + + fireEvent(input, changeEvent); + + const feedbackContainer = component.querySelector( + ".moj-multi-file__uploaded-files", + ); + expect(feedbackContainer).not.toHaveClass("moj-hidden"); + const newInput = getByLabelText(component, "Upload a file"); + expect(newInput).toHaveValue(""); + expect(newInput).toHaveFocus(); + }); + + test("displays upload progress", async () => { + // Create a spy on XMLHttpRequest to simulate upload progress + const xhr = sinon.useFakeXMLHttpRequest(); + let request; + xhr.onCreate = (req) => { + request = req; + }; + + await user.upload(input, file); + + request.uploadProgress({ + lengthComputable: true, + loaded: 50, + total: 100, + }); + + const fileRows = component.querySelectorAll( + ".moj-multi-file-upload__row", + ); + const progressElement = component.querySelector( + ".moj-multi-file-upload__progress", + ); + const nameElement = component.querySelector( + ".moj-multi-file-upload__filename", + ); + + expect(fileRows.length).toBe(1); + expect(progressElement).toHaveTextContent("50%"); + expect(nameElement).toHaveTextContent(file.name); + + xhr.restore(); + }); + + test("handles successful upload", async () => { + await user.upload(input, file); + + expect(uploadFileEntryHook).toHaveBeenCalledOnce(); + expect(uploadFileExitHook).toHaveBeenCalledOnce(); + expect(uploadFileExitHook).toHaveBeenCalledAfter(uploadFileEntryHook); + + const successMessage = component.querySelector( + ".moj-multi-file-upload__success", + ); + const deleteButton = component.querySelector( + ".moj-multi-file-upload__delete", + ); + + expect(successMessage).toHaveTextContent("File uploaded successfully"); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toHaveAccessibleName(`Delete test.txt`); + expect(deleteButton).toHaveAttribute("value", "test"); + }); + + // this fails as the component still attempts to access response.file (line 149) + test.skip("handles 200 status with error in response json", async () => { + server.respondWith("POST", "/upload", [ + 200, + { "Content-Type": "application/json" }, + JSON.stringify({ + error: { + message: "Upload failed", + }, + }), + ]); + + await user.upload(input, file); + + const errorMessage = component.querySelector( + ".moj-multi-file-upload__error", + ); + expect(errorMessage).toHaveTextContent("Upload failed"); + }); + + test("handles non 200 response status ", async () => { + server.respondWith("POST", "/upload", [ + 500, + { "Content-Type": "text/plain" }, + "", + ]); + + await user.upload(input, file); + + expect(uploadFileErrorHook).toHaveBeenCalledOnce(); + }); + }); + + describe("File deletion", () => { + beforeEach(async () => { + const file = new File(["test content"], "test.txt", { + type: "text/plain", + }); + const input = component.querySelector(".moj-multi-file-upload__input"); + + server.respondWith("POST", "/upload", [ + 200, + { "Content-Type": "application/json" }, + JSON.stringify({ + success: { + messageHtml: "File uploaded successfully", + }, + file: { + filename: "123", + originalname: "test.txt", + }, + }), + ]); + + await user.upload(input, file); + }); + + test("handles file deletion", async () => { + server.respondWith("POST", "/delete", [ + 200, + { "Content-Type": "application/json" }, + JSON.stringify({ success: true }), + ]); + + const deleteButton = component.querySelector( + ".moj-multi-file-upload__delete", + ); + await user.click(deleteButton); + + expect(fileDeleteHook).toHaveBeenCalledOnce(); + expect(server.requests[server.requests.length - 1].url).toBe("/delete"); + expect(server.requests[server.requests.length - 1].method).toBe("POST"); + + const fileRow = component.querySelector(".moj-multi-file-upload__row"); + expect(fileRow).not.toBeInTheDocument(); + }); + + test("hides feedback container when all files are deleted", async () => { + server.respondWith("POST", "/delete", [ + 200, + { "Content-Type": "application/json" }, + JSON.stringify({ success: true }), + ]); + + const deleteButton = component.querySelector( + ".moj-multi-file-upload__delete", + ); + await user.click(deleteButton); + + const feedbackContainer = component.querySelector( + ".moj-multi-file__uploaded-files", + ); + expect(feedbackContainer).toHaveClass("moj-hidden"); + }); + }); + + describe("Drag and drop", () => { + test("handles dragover event", () => { + const dropzone = component.querySelector( + ".moj-multi-file-upload__dropzone", + ); + const dragOverEvent = new Event("dragover"); + dropzone.dispatchEvent(dragOverEvent); + + expect(dropzone).toHaveClass("moj-multi-file-upload--dragover"); + }); + + test("handles dragleave event", () => { + const dropzone = component.querySelector( + ".moj-multi-file-upload__dropzone", + ); + dropzone.classList.add("moj-multi-file-upload--dragover"); + + const dragLeaveEvent = new Event("dragleave"); + dropzone.dispatchEvent(dragLeaveEvent); + + expect(dropzone).not.toHaveClass("moj-multi-file-upload--dragover"); + }); + + test("handles file drop", () => { + server.respondWith("POST", "/upload", [ + 200, + { "Content-Type": "application/json" }, + JSON.stringify({ + success: { + messageHtml: "File uploaded successfully", + }, + file: { + filename: "test", + originalname: "test.txt", + }, + }), + ]); + + const dropzone = component.querySelector( + ".moj-multi-file-upload__dropzone", + ); + const file = new File(["test content"], "test.txt", { + type: "text/plain", + }); + + const dropEvent = new Event("drop"); + dropEvent.preventDefault = () => {}; + Object.defineProperty(dropEvent, "dataTransfer", { + value: { + files: [file], + }, + }); + + dropzone.dispatchEvent(dropEvent); + + expect(server.requests.length).toBe(1); + expect(server.requests[0].url).toBe("/upload"); + expect(server.requests[0].method).toBe("POST"); + + const feedbackContainer = component.querySelector( + ".moj-multi-file__uploaded-files", + ); + const successMessage = component.querySelector( + ".moj-multi-file-upload__success", + ); + const deleteButton = component.querySelector( + ".moj-multi-file-upload__delete", + ); + + // test callbacks + expect(uploadFileEntryHook).toHaveBeenCalledOnce(); + expect(uploadFileExitHook).toHaveBeenCalledOnce(); + expect(uploadFileExitHook).toHaveBeenCalledAfter(uploadFileEntryHook); + + // test file present in UI + expect(feedbackContainer).not.toHaveClass("moj-hidden"); + expect(successMessage).toHaveTextContent("File uploaded successfully"); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toHaveAccessibleName(`Delete test.txt`); + expect(deleteButton).toHaveAttribute("value", "test"); + }); + }); + + describe("Uploading multiple files", () => { + let files; + let input; + const successResponse = { + success: { + messageHtml: "File uploaded successfully", + messageText: "File uploaded successfully", + }, + file: { + filename: "test", + originalname: "test.txt", + }, + }; + + beforeEach(() => { + files = [ + new File(["test content"], "test-1.txt", { type: "text/plain" }), + new File(["test content"], "test-2.txt", { type: "text/plain" }), + ]; + input = component.querySelector(".moj-multi-file-upload__input"); + input = getByLabelText(component, "Upload a file"); + + // Configure server response for file upload + server.respondWith("POST", "/upload", [ + 200, + { "Content-Type": "application/json" }, + JSON.stringify(successResponse), + ]); + }); + + test("handles multiple files", async () => { + await user.upload(input, files); + + const feedbackContainer = component.querySelector( + ".moj-multi-file__uploaded-files", + ); + const fileRows = component.querySelectorAll( + ".moj-multi-file-upload__row", + ); + const successMessages = component.querySelectorAll( + ".moj-multi-file-upload__success", + ); + const deleteButtons = component.querySelectorAll( + ".moj-multi-file-upload__delete", + ); + + expect(uploadFileEntryHook).toHaveBeenCalledTwice(); + expect(uploadFileExitHook).toHaveBeenCalledTwice(); + + expect(feedbackContainer).not.toHaveClass("moj-hidden"); + expect(fileRows.length).toBe(2); + + expect(successMessages[0]).toHaveTextContent( + "File uploaded successfully", + ); + expect(deleteButtons[0]).toHaveAccessibleName(`Delete test.txt`); + expect(deleteButtons[0]).toHaveAttribute("value", "test"); + expect(successMessages[1]).toHaveTextContent( + "File uploaded successfully", + ); + expect(deleteButtons[1]).toHaveAccessibleName(`Delete test.txt`); + expect(deleteButtons[1]).toHaveAttribute("value", "test"); + }); + + test("handles multiple file drop", () => { + const dropzone = component.querySelector( + ".moj-multi-file-upload__dropzone", + ); + + const dropEvent = new Event("drop"); + dropEvent.preventDefault = () => {}; + Object.defineProperty(dropEvent, "dataTransfer", { + value: { + files: files, + }, + }); + + dropzone.dispatchEvent(dropEvent); + + expect(server.requests.length).toBe(2); + expect(server.requests[0].url).toBe("/upload"); + expect(server.requests[0].method).toBe("POST"); + + const feedbackContainer = component.querySelector( + ".moj-multi-file__uploaded-files", + ); + + const fileRows = component.querySelectorAll( + ".moj-multi-file-upload__row", + ); + const successMessages = component.querySelectorAll( + ".moj-multi-file-upload__success", + ); + const deleteButtons = component.querySelectorAll( + ".moj-multi-file-upload__delete", + ); + + expect(uploadFileEntryHook).toHaveBeenCalledTwice(); + expect(uploadFileExitHook).toHaveBeenCalledTwice(); + + expect(feedbackContainer).not.toHaveClass("moj-hidden"); + expect(fileRows.length).toBe(2); + + expect(successMessages[0]).toHaveTextContent( + "File uploaded successfully", + ); + expect(deleteButtons[0]).toHaveAccessibleName(`Delete test.txt`); + expect(deleteButtons[0]).toHaveAttribute("value", "test"); + expect(successMessages[1]).toHaveTextContent( + "File uploaded successfully", + ); + expect(deleteButtons[1]).toHaveAccessibleName(`Delete test.txt`); + expect(deleteButtons[1]).toHaveAttribute("value", "test"); + }); + }); + + describe("Accessibility", () => { + let file; + let input; + + beforeEach(() => { + file = new File(["test content"], "test.txt", { + type: "text/plain", + }); + input = component.querySelector(".moj-multi-file-upload__input"); + }); + + test("status messages are announced to screen readers", async () => { + await user.upload(input, file); + + const statusBox = queryByRole(component, "status"); + expect(statusBox).toHaveTextContent("Uploading files, please wait"); + }); + + test("component has no wcag violations", async () => { + expect(await axe(document.body)).toHaveNoViolations(); + await user.upload(input, file); + expect(await axe(document.body)).toHaveNoViolations(); + }); + }); +});