diff --git a/.eslintrc.js b/.eslintrc.js index 949fe01483..663d66a9f9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,4 +2,12 @@ module.exports = { extends: [ '@nextcloud' ], + overrides: [ + { + 'files': ['**/*.spec.js'], + 'rules': { + 'node/no-unpublished-import': 0 + } + } + ] } diff --git a/jest.config.js b/jest.config.js index c8a15b8efb..faa2f2657a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,7 +26,10 @@ module.exports = { testMatch: ['/src/**/*.(spec|test).(ts|js)'], resetMocks: false, setupFiles: ['jest-localstorage-mock'], - setupFilesAfterEnv: ['/src/test-setup.js'], + setupFilesAfterEnv: [ + '/src/test-setup.js', + 'jest-mock-console/dist/setupTestFramework.js', + ], transform: { // process `*.js` files with `babel-jest` '.*\\.(js)$': 'babel-jest', diff --git a/package-lock.json b/package-lock.json index 52c2d0f999..129af354fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4880,7 +4880,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true + "dev": true, + "optional": true }, "find-cache-dir": { "version": "3.3.1", @@ -5167,6 +5168,7 @@ "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.2.0.tgz", "integrity": "sha512-TitGhqSQ61RJljMmhIGvfWzJ2zk9m1Qug049Ugml6QP3t0e95o0XJjk29roNEiPKJQBEi8Ord5hFuSuELzSp8Q==", "dev": true, + "optional": true, "requires": { "chalk": "^4.1.0", "hash-sum": "^2.0.0", @@ -5178,6 +5180,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", "dev": true, + "optional": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5187,13 +5190,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "dev": true, + "optional": true }, "loader-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", "dev": true, + "optional": true, "requires": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -5205,6 +5210,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "optional": true, "requires": { "has-flag": "^4.0.0" } @@ -13607,6 +13613,12 @@ "synchronous-promise": "^2.0.15" } }, + "jest-mock-console": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-mock-console/-/jest-mock-console-1.1.0.tgz", + "integrity": "sha512-9/6a0nXw+Iqq8U0LPpR5GrzpkGYFasD4TPNItKuNP36fUN6/rqcUtcNvzFiq2AssfR0PtSTIzrIHdKOipUxe3Q==", + "dev": true + }, "jest-pnp-resolver": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", diff --git a/package.json b/package.json index e1bfe49dbb..642fc570f5 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "author": "Joas Schilling ", "scripts": { "build": "NODE_ENV=production webpack --progress --config webpack.prod.js", - "test:unit": "vue-cli-service test:unit", - "test:watch": "vue-cli-service test:unit --watchAll", + "test:unit": "TZ=UTC vue-cli-service test:unit", + "test:watch": "TZ=UTC vue-cli-service test:unit --watchAll", "lint": "eslint --ext .js,.vue src", "dev": "NODE_ENV=development webpack --config webpack.dev.js", "lint:fix": "eslint --ext .js,.vue src --fix", @@ -88,6 +88,7 @@ "eslint-plugin-vue": "^5.2.3", "jest-localstorage-mock": "^2.4.10", "jest-mock-axios": "^4.4.0", + "jest-mock-console": "^1.1.0", "node-sass": "^5.0.0", "regenerator-runtime": "^0.13.7", "sass-loader": "^10.1.1", diff --git a/src/store/fileUploadStore.spec.js b/src/store/fileUploadStore.spec.js new file mode 100644 index 0000000000..076fa20edb --- /dev/null +++ b/src/store/fileUploadStore.spec.js @@ -0,0 +1,361 @@ +import mockConsole from 'jest-mock-console' +import Vuex from 'vuex' +import { cloneDeep } from 'lodash' +import { createLocalVue } from '@vue/test-utils' + +import client from '../services/DavClient' +import { findUniquePath, getFileExtension } from '../utils/fileUpload' +import { shareFile } from '../services/filesSharingServices' +import { setAttachmentFolder } from '../services/settingsService' +import { showError } from '@nextcloud/dialogs' + +import fileUploadStore from './fileUploadStore' + +jest.mock('../services/DavClient') +jest.mock('./helper') +jest.mock('../utils/fileUpload', () => ({ + findUniquePath: jest.fn(), + getFileExtension: jest.fn(), +})) +jest.mock('../services/filesSharingServices', () => ({ + shareFile: jest.fn(), +})) +jest.mock('../services/settingsService', () => ({ + setAttachmentFolder: jest.fn(), +})) +jest.mock('@nextcloud/dialogs', () => ({ + showError: jest.fn(), +})) + +describe('fileUploadStore', () => { + let localVue = null + let storeConfig = null + let store = null + let mockedActions = null + + beforeEach(() => { + let temporaryMessageCount = 0 + + localVue = createLocalVue() + localVue.use(Vuex) + + mockedActions = { + createTemporaryMessage: jest.fn() + .mockImplementation((context, { file, index, uploadId, localUrl, token }) => { + temporaryMessageCount += 1 + return { + id: temporaryMessageCount, + referenceId: 'reference-id-' + temporaryMessageCount, + token, + messageParameters: { + file: { + uploadId, + index, + token, + localUrl, + file: file, + }, + }, + } + }), + addTemporaryMessage: jest.fn(), + markTemporaryMessageAsFailed: jest.fn(), + } + + global.URL.createObjectURL = jest.fn().mockImplementation((file) => 'local-url:' + file.name) + global.OC.MimeType = { + getIconUrl: jest.fn().mockImplementation((type) => 'icon-url:' + type), + } + + storeConfig = cloneDeep(fileUploadStore) + storeConfig.actions = Object.assign(storeConfig.actions, mockedActions) + storeConfig.getters.getUserId = jest.fn().mockReturnValue(() => 'current-user') + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('uploading', () => { + let restoreConsole + + beforeEach(() => { + storeConfig.getters.getAttachmentFolder = jest.fn().mockReturnValue(() => '/Talk') + store = new Vuex.Store(storeConfig) + restoreConsole = mockConsole(['error', 'debug']) + }) + + afterEach(() => { + restoreConsole() + }) + + test('initialises upload for given files', async() => { + const files = [ + { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + }, + { + name: 'jpgimage.jpg', + type: 'image/jpeg', + size: 456, + lastModified: Date.UTC(2021, 3, 26, 15, 30, 0), + }, + { + name: 'textfile.txt', + type: 'text/plain', + size: 111, + lastModified: Date.UTC(2021, 3, 25, 15, 30, 0), + }, + ] + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files, + }) + + const uploads = store.getters.getInitialisedUploads('upload-id1') + expect(Object.keys(uploads).length).toBe(3) + + for (let i = 0; i < files.length; i++) { + expect(mockedActions.createTemporaryMessage.mock.calls[i][1].text).toBe('{file}') + expect(mockedActions.createTemporaryMessage.mock.calls[i][1].uploadId).toBe('upload-id1') + expect(mockedActions.createTemporaryMessage.mock.calls[i][1].index).toBeDefined() + expect(mockedActions.createTemporaryMessage.mock.calls[i][1].file).toBe(files[i]) + expect(mockedActions.createTemporaryMessage.mock.calls[i][1].token).toBe('XXTOKENXX') + } + + expect(mockedActions.createTemporaryMessage.mock.calls[0][1].localUrl).toBe('local-url:pngimage.png') + expect(mockedActions.createTemporaryMessage.mock.calls[1][1].localUrl).toBe('local-url:jpgimage.jpg') + expect(mockedActions.createTemporaryMessage.mock.calls[2][1].localUrl).toBe('icon-url:text/plain') + }) + + test('performs upload by uploading then sharing', async() => { + const files = [ + { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + }, + { + name: 'textfile.txt', + type: 'text/plain', + size: 111, + lastModified: Date.UTC(2021, 3, 25, 15, 30, 0), + }, + ] + + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files, + }) + + expect(store.getters.currentUploadId).toBe('upload-id1') + + findUniquePath + .mockResolvedValueOnce('/Talk/' + files[0].name + 'uniq') + .mockResolvedValueOnce('/Talk/' + files[1].name + 'uniq') + client.putFileContents.mockResolvedValue() + shareFile.mockResolvedValue() + + await store.dispatch('uploadFiles', 'upload-id1') + + expect(client.putFileContents).toHaveBeenCalledTimes(2) + expect(shareFile).toHaveBeenCalledTimes(2) + + for (let i = 0; i < files.length; i++) { + expect(findUniquePath).toHaveBeenCalledWith(client, '/files/current-user', '/Talk/' + files[i].name) + expect(client.putFileContents.mock.calls[i][0]).toBe('/files/current-user/Talk/' + files[i].name + 'uniq') + expect(client.putFileContents.mock.calls[i][1]).toBe(files[i]) + + expect(shareFile.mock.calls[i][0]).toBe('//Talk/' + files[i].name + 'uniq') + expect(shareFile.mock.calls[i][1]).toBe('XXTOKENXX') + expect(shareFile.mock.calls[i][2]).toBe('reference-id-' + (i + 1)) + } + + expect(mockedActions.addTemporaryMessage).toHaveBeenCalledTimes(2) + expect(store.getters.currentUploadId).not.toBeDefined() + }) + + test('marks temporary message as failed in case of upload error', async() => { + const files = [ + { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + }, + ] + + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files, + }) + + findUniquePath + .mockResolvedValueOnce('/Talk/' + files[0].name + 'uniq') + client.putFileContents.mockRejectedValueOnce({ + response: { + status: 403, + }, + }) + + await store.dispatch('uploadFiles', 'upload-id1') + + expect(client.putFileContents).toHaveBeenCalledTimes(1) + expect(shareFile).not.toHaveBeenCalled() + + expect(mockedActions.addTemporaryMessage).toHaveBeenCalledTimes(1) + expect(mockedActions.markTemporaryMessageAsFailed).toHaveBeenCalledTimes(1) + expect(mockedActions.markTemporaryMessageAsFailed.mock.calls[0][1].message.referenceId).toBe('reference-id-1') + expect(mockedActions.markTemporaryMessageAsFailed.mock.calls[0][1].reason).toBe('failed-upload') + expect(showError).toHaveBeenCalled() + expect(console.error).toHaveBeenCalled() + }) + + test('marks temporary message as failed in case of sharing error', async() => { + const files = [ + { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + }, + ] + + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files, + }) + + findUniquePath + .mockResolvedValueOnce('/Talk/' + files[0].name + 'uniq') + client.putFileContents.mockResolvedValue() + shareFile.mockRejectedValueOnce({ + response: { + status: 403, + }, + }) + + await store.dispatch('uploadFiles', 'upload-id1') + + expect(client.putFileContents).toHaveBeenCalledTimes(1) + expect(shareFile).toHaveBeenCalledTimes(1) + + expect(mockedActions.addTemporaryMessage).toHaveBeenCalledTimes(1) + expect(mockedActions.markTemporaryMessageAsFailed).toHaveBeenCalledTimes(1) + expect(mockedActions.markTemporaryMessageAsFailed.mock.calls[0][1].message.referenceId).toBe('reference-id-1') + expect(mockedActions.markTemporaryMessageAsFailed.mock.calls[0][1].reason).toBe('failed-share') + expect(showError).toHaveBeenCalled() + expect(console.error).toHaveBeenCalled() + }) + + test('removes file from selection', async() => { + const files = [ + { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + }, + { + name: 'textfile.txt', + type: 'text/plain', + size: 111, + lastModified: Date.UTC(2021, 3, 25, 15, 30, 0), + }, + ] + + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files, + }) + + // temporary message mock uses incremental id + await store.dispatch('removeFileFromSelection', 2) + + const uploads = store.getters.getInitialisedUploads('upload-id1') + expect(Object.keys(uploads).length).toBe(1) + + expect(Object.values(uploads)[0].file).toBe(files[0]) + }) + + test('discard an entire upload', async() => { + const files = [ + { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + }, + { + name: 'textfile.txt', + type: 'text/plain', + size: 111, + lastModified: Date.UTC(2021, 3, 25, 15, 30, 0), + }, + ] + + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files, + }) + + await store.dispatch('discardUpload', 'upload-id1') + + const uploads = store.getters.getInitialisedUploads('upload-id1') + expect(uploads).toStrictEqual({}) + + expect(store.getters.currentUploadId).not.toBeDefined() + }) + + test('autorenames files using timestamps when requested', async() => { + const files = [ + { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + }, + { + name: 'textfile.txt', + type: 'text/plain', + size: 111, + lastModified: Date.UTC(2021, 3, 25, 15, 30, 0), + }, + ] + + getFileExtension + .mockReturnValueOnce('.png') + .mockReturnValueOnce('.txt') + + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files, + rename: true, + }) + + expect(files[0].newName).toBe('20210427_153000.png') + expect(files[1].newName).toBe('20210425_153000.txt') + }) + }) + + test('set attachment folder', async() => { + store = new Vuex.Store(storeConfig) + + setAttachmentFolder.mockResolvedValue() + await store.dispatch('setAttachmentFolder', '/Talk-another') + + expect(setAttachmentFolder).toHaveBeenCalledWith('/Talk-another') + expect(store.getters.getAttachmentFolder()).toBe('/Talk-another') + }) +})