diff --git a/front/babel.config.js b/front/babel.config.js new file mode 100644 index 000000000..d6d12ff72 --- /dev/null +++ b/front/babel.config.js @@ -0,0 +1,17 @@ +module.exports = { + presets: [ + '@babel/preset-env', + ], + plugins: [ + '@babel/plugin-transform-runtime', + function () { + return { + visitor: { + MetaProperty(path) { + path.replaceWithSourceString('process') + }, + }, + } + }, + ], +} diff --git a/front/package.json b/front/package.json index 25a732451..d5f3b05c2 100644 --- a/front/package.json +++ b/front/package.json @@ -9,7 +9,7 @@ "build": "vite build", "build:deployment": "vite build --base /front/", "serve": "vite preview", - "test:unit": "true", + "test:unit": "jest", "lint": "eslint --ext .js,.vue src", "fix-fomantic-css": "scripts/fix-fomantic-css.sh", "i18n-compile": "scripts/i18n-compile.sh", @@ -45,7 +45,14 @@ "vuex-router-sync": "5.0.0" }, "devDependencies": { + "@babel/core": "^7.17.5", + "@babel/plugin-transform-runtime": "^7.17.0", + "@babel/preset-env": "^7.16.11", + "@vue/test-utils": "^1.0.0-beta.22", "autoprefixer": "10.4.4", + "babel-core": "^7.0.0-bridge.0", + "babel-jest": "^27.5.1", + "chai": "^4.3.6", "easygettext": "2.17.0", "eslint": "8.11.0", "eslint-config-standard": "16.0.3", @@ -55,8 +62,12 @@ "eslint-plugin-promise": "6.0.0", "eslint-plugin-vue": "7.20.0", "glob-all": "3.3.0", + "jest-cli": "^27.5.1", + "moxios": "^0.4.0", + "sinon": "^13.0.1", "vite": "2.8.6", "vite-plugin-vue2": "1.9.3", + "vue-jest": "^3.0.7", "vue-template-compiler": "2.6.14" }, "resolutions": { @@ -109,5 +120,20 @@ "iOS >= 9", "Android >= 4", "not dead" - ] + ], + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "vue" + ], + "transform": { + ".*\\.(vue)$": "vue-jest", + "^.+\\.js$": "babel-jest" + }, + "moduleNameMapper": { + "^@/(.*)$": "/src/$1" + }, + "testEnvironment": "jsdom" + } } diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 1de735e30..5c7530948 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -2,7 +2,6 @@ import Vue from 'vue' import axios from 'axios' import logger from '@/logging' import lodash from 'lodash' -import router from '@/router' function getDefaultScopedTokens () { return { @@ -142,7 +141,9 @@ export default { // commit('token', response.data.token) dispatch('fetchProfile').then(() => { // Redirect to a specified route - return router.push(next) + import('@/router').then((router) => { + return router.default.push(next) + }) }) }, response => { logger.default.error('Error while logging in', response.data) diff --git a/front/tests/unit/.eslintrc b/front/tests/unit/.eslintrc new file mode 100644 index 000000000..959a4f4b5 --- /dev/null +++ b/front/tests/unit/.eslintrc @@ -0,0 +1,9 @@ +{ + "env": { + "mocha": true + }, + "globals": { + "expect": true, + "sinon": true + } +} diff --git a/front/tests/unit/specs/audio/volume.spec.js b/front/tests/unit/specs/audio/volume.spec.js new file mode 100644 index 000000000..178af5621 --- /dev/null +++ b/front/tests/unit/specs/audio/volume.spec.js @@ -0,0 +1,25 @@ +import { expect } from 'chai' + +import { toLinearVolumeScale, toLogarithmicVolumeScale } from '@/audio/volume' + +describe('store/auth', () => { + describe('toLinearVolumeScale', () => { + it('it should return real 0', () => { + expect(toLinearVolumeScale(0.0)).to.equal(0.0) + }) + + it('it should return full volume', () => { + expect(toLinearVolumeScale(1.0)).to.be.closeTo(1.0, 0.001) + }) + }) + + describe('toLogarithmicVolumeScale', () => { + it('it should return real 0', () => { + expect(toLogarithmicVolumeScale(0.0)).to.equal(0.0) + }) + + it('it should return full volume', () => { + expect(toLogarithmicVolumeScale(1.0)).to.be.closeTo(1.0, 0.001) + }) + }) +}) diff --git a/front/tests/unit/specs/components/common.spec.js b/front/tests/unit/specs/components/common.spec.js new file mode 100644 index 000000000..c0ae3a65e --- /dev/null +++ b/front/tests/unit/specs/components/common.spec.js @@ -0,0 +1,12 @@ +import {expect} from 'chai' + +import Username from '@/components/common/Username.vue' + +import { render } from '../../utils' + +describe('Username', () => { + it('displays username', () => { + const vm = render(Username, {username: 'Hello'}) + expect(vm.$el.textContent).to.equal('Hello') + }) +}) diff --git a/front/tests/unit/specs/components/forms.spec.js b/front/tests/unit/specs/components/forms.spec.js new file mode 100644 index 000000000..8667ff782 --- /dev/null +++ b/front/tests/unit/specs/components/forms.spec.js @@ -0,0 +1,42 @@ +import { expect } from 'chai' +import PasswordInput from '@/components/forms/PasswordInput.vue' +import { shallowMount } from '@vue/test-utils' +const sinon = require('sinon') + +describe('PasswordInput', () => { + const password = 'password' + let sandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + const wrapper = shallowMount(PasswordInput, { + mocks: { + $pgettext: () => 'dummy', + $store: { + commit: () => { } + }, + }, + propsData: { + fieldId: 'password', + value: password, + } + }) + wrapper.setProps({ value: password, copyButton: true }) + it('password input has passed value', () => { + const inputElement = wrapper.find('input') + expect(inputElement.element.value).to.equal(password) + }) + it('copy password function called', () => { + document.execCommand = jest.fn() + const spy = sandbox.spy(wrapper.vm, 'copyPassword') + sandbox.stub(PasswordInput.methods, '_copyStringToClipboard').callsFake() + const copyButton = wrapper.findAll('button').at(1) + copyButton.trigger('click') + sandbox.assert.calledOnce(spy) + }) +}) diff --git a/front/tests/unit/specs/filters/filters.spec.js b/front/tests/unit/specs/filters/filters.spec.js new file mode 100644 index 000000000..f4f3610dc --- /dev/null +++ b/front/tests/unit/specs/filters/filters.spec.js @@ -0,0 +1,52 @@ +import {expect} from 'chai' +import moment from 'moment' +import {truncate, ago, capitalize, year} from '@/filters' + +describe('filters', () => { + describe('truncate', () => { + it('leave strings as it if correct size', () => { + const input = 'Hello world' + let output = truncate(input, 100) + expect(output).to.equal(input) + }) + it('returns shorter string with character', () => { + const input = 'Hello world' + let output = truncate(input, 5) + expect(output).to.equal('Hello…') + }) + it('custom ellipsis', () => { + const input = 'Hello world' + let output = truncate(input, 5, ' pouet') + expect(output).to.equal('Hello pouet') + }) + }) + describe('ago', () => { + it('works', () => { + const input = new Date() + let output = ago(input) + let expected = moment(input).calendar(input, { + sameDay: 'LT', + nextDay: 'L', + nextWeek: 'L', + lastDay: 'L', + lastWeek: 'L', + sameElse: 'L' + }) + expect(output).to.equal(expected) + }) + }) + describe('year', () => { + it('works', () => { + const input = '2017-07-13' + let output = year(input) + expect(output).to.equal(2017) + }) + }) + describe('capitalize', () => { + it('works', () => { + const input = 'hello world' + let output = capitalize(input) + expect(output).to.equal('Hello world') + }) + }) +}) diff --git a/front/tests/unit/specs/search.spec.js b/front/tests/unit/specs/search.spec.js new file mode 100644 index 000000000..5cc551b20 --- /dev/null +++ b/front/tests/unit/specs/search.spec.js @@ -0,0 +1,65 @@ +import {expect} from 'chai' + +import {normalizeQuery, parseTokens, compileTokens} from '@/search' + +describe('search', () => { + it('normalizeQuery returns correct tokens', () => { + const input = 'this is a "search query" yeah' + let output = normalizeQuery(input) + expect(output).to.deep.equal(['this', 'is', 'a', 'search query', 'yeah']) + }) + it('parseTokens can extract fields and values from tokens', () => { + const input = ['unhandled', 'key:value', 'status:pending', 'title:"some title"', 'anotherunhandled'] + let output = parseTokens(input) + let expected = [ + { + 'field': null, + 'value': 'unhandled' + }, + { + 'field': 'key', + 'value': 'value' + }, + { + 'field': 'status', + 'value': 'pending', + }, + { + 'field': 'title', + 'value': 'some title' + }, + { + 'field': null, + 'value': 'anotherunhandled' + } + ] + expect(output).to.deep.equal(expected) + }) + it('compileTokens returns proper query string', () => { + let input = [ + { + 'field': null, + 'value': 'unhandled' + }, + { + 'field': 'key', + 'value': 'value' + }, + { + 'field': 'status', + 'value': 'pending', + }, + { + 'field': 'title', + 'value': 'some title' + }, + { + 'field': null, + 'value': 'anotherunhandled' + } + ] + const expected = 'unhandled key:value status:pending title:"some title" anotherunhandled' + let output = compileTokens(input) + expect(output).to.deep.equal(expected) + }) +}) diff --git a/front/tests/unit/specs/store/auth.spec.js b/front/tests/unit/specs/store/auth.spec.js new file mode 100644 index 000000000..023c253fe --- /dev/null +++ b/front/tests/unit/specs/store/auth.spec.js @@ -0,0 +1,174 @@ +var sinon = require('sinon') +import {expect} from 'chai' + +import moxios from 'moxios' +import store from '@/store/auth' + +import { testAction } from '../../utils' + +describe('store/auth', () => { + var sandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + moxios.install() + }) + afterEach(function () { + sandbox.restore() + moxios.uninstall() + }) + + describe('mutations', () => { + it('profile', () => { + const state = {} + store.mutations.profile(state, {}) + expect(state.profile).to.deep.equal({}) + }) + it('username', () => { + const state = {} + store.mutations.username(state, 'world') + expect(state.username).to.equal('world') + }) + it('authenticated true', () => { + const state = {} + store.mutations.authenticated(state, true) + expect(state.authenticated).to.equal(true) + }) + it('authenticated false', () => { + const state = { + username: 'dummy', + token: 'dummy', + profile: 'dummy', + availablePermissions: 'dummy' + } + store.mutations.authenticated(state, false) + expect(state.authenticated).to.equal(false) + expect(state.username).to.equal(null) + expect(state.token).to.equal(null) + expect(state.profile).to.equal(null) + expect(state.availablePermissions).to.deep.equal({}) + }) + it('token null', () => { + const state = {} + store.mutations.token(state, null) + expect(state.token).to.equal(null) + }) + it('token real', () => { + const state = {} + let token = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2p3dC1pZHAuZXhhbXBsZS5jb20iLCJzdWIiOiJtYWlsdG86bWlrZUBleGFtcGxlLmNvbSIsIm5iZiI6MTUxNTUzMzQyOSwiZXhwIjoxNTE1NTM3MDI5LCJpYXQiOjE1MTU1MzM0MjksImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9.' + store.mutations.token(state, token) + expect(state.token).to.equal(token) + }) + it('permissions', () => { + const state = { availablePermissions: {} } + store.mutations.permission(state, {key: 'admin', status: true}) + expect(state.availablePermissions).to.deep.equal({admin: true}) + }) + }) + describe('getters', () => { + it('header', () => { + const state = { oauth: {accessToken: 'helloworld' }} + expect(store.getters['header'](state)).to.equal('Bearer helloworld') + }) + }) + describe('actions', () => { + it('logout', () => { + testAction({ + action: store.actions.logout, + params: {state: {}}, + expectedMutations: [ + { type: 'auth/reset', payload: null, options: {root: true} }, + { type: 'favorites/reset', payload: null, options: {root: true} }, + { type: 'player/reset', payload: null, options: {root: true} }, + { type: 'playlists/reset', payload: null, options: {root: true} }, + { type: 'queue/reset', payload: null, options: {root: true} }, + { type: 'radios/reset', payload: null, options: {root: true} } + ] + }) + }) + it('check jwt null', () => { + testAction({ + action: store.actions.check, + params: {state: {}}, + expectedMutations: [ + { type: 'authenticated', payload: false }, + { type: 'authenticated', payload: true }, + ], + expectedActions: [ + { type: 'fetchProfile' }, + ] + }) + }) + it('login success', () => { + moxios.stubRequest('token/', { + status: 200, + response: { + token: 'test' + } + }) + const credentials = { + username: 'bob' + } + testAction({ + action: store.actions.login, + payload: {credentials: credentials}, + expectedMutations: [ + { type: 'token', payload: 'test' } + ], + expectedActions: [ + { type: 'fetchProfile' } + ] + }) + }) + it('login error', () => { + moxios.stubRequest('token/', { + status: 500, + response: { + token: 'test' + } + }) + const credentials = { + username: 'bob' + } + let spy = sandbox.spy() + testAction({ + action: store.actions.login, + payload: {credentials: credentials, onError: spy} + }, () => { + expect(spy.calledOnce).to.equal(true) + done() // eslint-disable-line no-undef + }) + }) + it('fetchProfile', () => { + const profile = { + username: 'bob', + permissions: { + admin: true + } + } + moxios.stubRequest('users/me/', { + status: 200, + response: profile + }) + testAction({ + action: store.actions.fetchProfile, + expectedMutations: [ + { type: 'authenticated', payload: true }, + { type: 'profile', payload: profile }, + { type: 'username', payload: profile.username }, + { type: 'permission', payload: {key: 'admin', status: true} } + ], + expectedActions: [ + { type: 'ui/initSettings', payload: { root: true } }, + { type: 'updateProfile', payload: profile }, + { type: 'ui/fetchUnreadNotifications', payload: null }, + { type: 'favorites/fetch', payload: null, options: {root: true} }, + { type: 'channels/fetchSubscriptions', payload: null, options: {root: true} }, + { type: 'libraries/fetchFollows', payload: null, options: {root: true} }, + { type: 'moderation/fetchContentFilters', payload: null, options: {root: true} }, + { type: 'playlists/fetchOwn', payload: null, options: {root: true} } + ] + }) + }) + }) +}) diff --git a/front/tests/unit/specs/store/favorites.spec.js b/front/tests/unit/specs/store/favorites.spec.js new file mode 100644 index 000000000..1c0f29a9d --- /dev/null +++ b/front/tests/unit/specs/store/favorites.spec.js @@ -0,0 +1,54 @@ +import {expect} from 'chai' + +import store from '@/store/favorites' + +import { testAction } from '../../utils' + +describe('store/favorites', () => { + describe('mutations', () => { + it('track true', () => { + const state = { tracks: [] } + store.mutations.track(state, {id: 1, value: true}) + expect(state.tracks).to.deep.equal([1]) + expect(state.count).to.deep.equal(1) + }) + it('track false', () => { + const state = { tracks: [1] } + store.mutations.track(state, {id: 1, value: false}) + expect(state.tracks).to.deep.equal([]) + expect(state.count).to.deep.equal(0) + }) + }) + describe('getters', () => { + it('isFavorite true', () => { + const state = { tracks: [1] } + expect(store.getters['isFavorite'](state)(1)).to.equal(true) + }) + it('isFavorite false', () => { + const state = { tracks: [] } + expect(store.getters['isFavorite'](state)(1)).to.equal(false) + }) + }) + describe('actions', () => { + it('toggle true', () => { + testAction({ + action: store.actions.toggle, + payload: 1, + params: {getters: {isFavorite: () => false}}, + expectedActions: [ + { type: 'set', payload: {id: 1, value: true} } + ] + }) + }) + it('toggle true', () => { + testAction({ + action: store.actions.toggle, + payload: 1, + params: {getters: {isFavorite: () => true}}, + expectedActions: [ + { type: 'set', payload: {id: 1, value: false} } + ] + }) + }) + }) +}) diff --git a/front/tests/unit/specs/store/instance.spec.js b/front/tests/unit/specs/store/instance.spec.js new file mode 100644 index 000000000..5ae771c75 --- /dev/null +++ b/front/tests/unit/specs/store/instance.spec.js @@ -0,0 +1,81 @@ +import {expect} from 'chai' +var sinon = require('sinon') +import axios from 'axios' +import moxios from 'moxios' +import store from '@/store/instance' +import { testAction } from '../../utils' + +describe('store/instance', () => { + var sandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + moxios.install() + }) + afterEach(function () { + sandbox.restore() + moxios.uninstall() + axios.defaults.baseURL = null + }) + + describe('mutations', () => { + it('settings', () => { + const state = {settings: {users: {upload_quota: {value: 1}}}} + let settings = {users: {registration_enabled: {value: true}}} + store.mutations.settings(state, settings) + expect(state.settings).to.deep.equal({ + users: {upload_quota: {value: 1}, registration_enabled: {value: true}} + }) + }) + it('instanceUrl', () => { + const state = {instanceUrl: null, knownInstances: ['http://test2/', 'http://test/']} + store.mutations.instanceUrl(state, 'http://test') + expect(state).to.deep.equal({ + instanceUrl: 'http://test/', // trailing slash added + knownInstances: ['http://test/', 'http://test2/'] + }) + }) + }) + describe('actions', () => { + it('fetchSettings', () => { + moxios.stubRequest('instance/settings/', { + status: 200, + response: [ + { + section: 'users', + name: 'upload_quota', + value: 1 + }, + { + section: 'users', + name: 'registration_enabled', + value: false + } + ] + }) + testAction({ + action: store.actions.fetchSettings, + payload: null, + expectedMutations: [ + { + type: 'settings', + payload: { + users: { + upload_quota: { + section: 'users', + name: 'upload_quota', + value: 1 + }, + registration_enabled: { + section: 'users', + name: 'registration_enabled', + value: false + } + } + } + } + ] + }) + }) + }) +}) diff --git a/front/tests/unit/specs/store/player.spec.js b/front/tests/unit/specs/store/player.spec.js new file mode 100644 index 000000000..b40642842 --- /dev/null +++ b/front/tests/unit/specs/store/player.spec.js @@ -0,0 +1,214 @@ +import {expect} from 'chai' + +import store from '@/store/player' + +import { testAction } from '../../utils' + +describe('store/player', () => { + describe('mutations', () => { + it('set volume', () => { + const state = { volume: 0 } + store.mutations.volume(state, 0.9) + expect(state.volume).to.equal(0.9) + }) + it('set volume max 1', () => { + const state = { volume: 0 } + store.mutations.volume(state, 2) + expect(state.volume).to.equal(1) + }) + it('set volume min to 0', () => { + const state = { volume: 0.5 } + store.mutations.volume(state, -2) + expect(state.volume).to.equal(0) + }) + it('increment volume', () => { + const state = { volume: 0 } + store.mutations.incrementVolume(state, 0.1) + expect(state.volume).to.equal(0.1) + }) + it('increment volume max 1', () => { + const state = { volume: 0 } + store.mutations.incrementVolume(state, 2) + expect(state.volume).to.equal(1) + }) + it('increment volume min to 0', () => { + const state = { volume: 0.5 } + store.mutations.incrementVolume(state, -2) + expect(state.volume).to.equal(0) + }) + it('set duration', () => { + const state = { duration: 42 } + store.mutations.duration(state, 14) + expect(state.duration).to.equal(14) + }) + it('set errored', () => { + const state = { errored: false } + store.mutations.errored(state, true) + expect(state.errored).to.equal(true) + }) + it('set looping', () => { + const state = { looping: 1 } + store.mutations.looping(state, 2) + expect(state.looping).to.equal(2) + }) + it('set playing', () => { + const state = { playing: false } + store.mutations.playing(state, true) + expect(state.playing).to.equal(true) + }) + it('set current time', () => { + const state = { currentTime: 1 } + store.mutations.currentTime(state, 2) + expect(state.currentTime).to.equal(2) + }) + it('toggle looping from 0', () => { + const state = { looping: 0 } + store.mutations.toggleLooping(state) + expect(state.looping).to.equal(1) + }) + it('toggle looping from 1', () => { + const state = { looping: 1 } + store.mutations.toggleLooping(state) + expect(state.looping).to.equal(2) + }) + it('toggle looping from 2', () => { + const state = { looping: 2 } + store.mutations.toggleLooping(state) + expect(state.looping).to.equal(0) + }) + it('increment error count', () => { + const state = { errorCount: 0 } + store.mutations.incrementErrorCount(state) + expect(state.errorCount).to.equal(1) + }) + it('reset error count', () => { + const state = { errorCount: 10 } + store.mutations.resetErrorCount(state) + expect(state.errorCount).to.equal(0) + }) + }) + describe('getters', () => { + it('durationFormatted', () => { + const state = { duration: 12.51 } + expect(store.getters['durationFormatted'](state)).to.equal('0:13') + }) + it('currentTimeFormatted', () => { + const state = { currentTime: 12.51 } + expect(store.getters['currentTimeFormatted'](state)).to.equal('0:13') + }) + it('progress', () => { + const state = { currentTime: 4, duration: 10 } + expect(store.getters['progress'](state)).to.equal(40) + }) + }) + describe('actions', () => { + it('incrementVolume', () => { + testAction({ + action: store.actions.incrementVolume, + payload: 0.2, + params: {state: {volume: 0.7}}, + expectedMutations: [ + { type: 'volume', payload: 0.7 + 0.2 } + ] + }) + }) + it('toggle playback false', () => { + testAction({ + action: store.actions.togglePlayback, + params: {state: {playing: false}}, + expectedMutations: [ + { type: 'playing', payload: true } + ] + }) + }) + it('toggle playback true', () => { + testAction({ + action: store.actions.togglePlayback, + params: {state: {playing: true}}, + expectedMutations: [ + { type: 'playing', payload: false } + ] + }) + }) + it('resume playback', () => { + testAction({ + action: store.actions.resumePlayback, + params: {state: {}}, + expectedMutations: [ + { type: 'playing', payload: true } + ] + }) + }) + it('pause playback', () => { + testAction({ + action: store.actions.pausePlayback, + expectedMutations: [ + { type: 'playing', payload: false } + ] + }) + }) + it('trackEnded', () => { + testAction({ + action: store.actions.trackEnded, + payload: {test: 'track'}, + params: {rootState: {queue: {currentIndex: 0, tracks: [1, 2]}}}, + expectedActions: [ + { type: 'queue/next', payload: null, options: {root: true} } + ] + }) + }) + it('trackEnded calls populateQueue if last', () => { + testAction({ + action: store.actions.trackEnded, + payload: {test: 'track'}, + params: {rootState: {queue: {currentIndex: 1, tracks: [1, 2]}}}, + expectedActions: [ + { type: 'radios/populateQueue', payload: null, options: {root: true} }, + { type: 'queue/next', payload: null, options: {root: true} } + ] + }) + }) + it('trackErrored', () => { + testAction({ + action: store.actions.trackErrored, + payload: {test: 'track'}, + params: {state: {errorCount: 0, maxConsecutiveErrors: 5}}, + expectedMutations: [ + { type: 'errored', payload: true }, + { type: 'incrementErrorCount' } + ], + expectedActions: [ + { type: 'queue/next', payload: null, options: {root: true} } + ] + }) + }) + it('updateProgress', () => { + testAction({ + action: store.actions.updateProgress, + payload: 1, + expectedMutations: [ + { type: 'currentTime', payload: 1 } + ] + }) + }) + it('mute', () => { + testAction({ + action: store.actions.mute, + params: {state: { volume: 0.7, tempVolume: 0}}, + expectedMutations: [ + { type: 'tempVolume', payload: 0.7 }, + { type: 'volume', payload: 0 }, + ] + }) + }) + it('unmute', () => { + testAction({ + action: store.actions.unmute, + params: {state: { volume: 0, tempVolume: 0.8}}, + expectedMutations: [ + { type: 'volume', payload: 0.8 }, + ] + }) + }) + }) +}) diff --git a/front/tests/unit/specs/store/playlists.spec.js b/front/tests/unit/specs/store/playlists.spec.js new file mode 100644 index 000000000..0fe0c0ae2 --- /dev/null +++ b/front/tests/unit/specs/store/playlists.spec.js @@ -0,0 +1,37 @@ +import {expect} from 'chai' +var sinon = require('sinon') +import moxios from 'moxios' +import store from '@/store/playlists' + +import { testAction } from '../../utils' + +describe('store/playlists', () => { + var sandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + moxios.install() + }) + afterEach(function () { + sandbox.restore() + moxios.uninstall() + }) + + describe('mutations', () => { + it('set playlists', () => { + const state = { playlists: [] } + store.mutations.playlists(state, [{id: 1, name: 'test'}]) + expect(state.playlists).to.deep.equal([{id: 1, name: 'test'}]) + }) + }) + describe('actions', () => { + it('fetchOwn does nothing with no user', () => { + testAction({ + action: store.actions.fetchOwn, + payload: null, + params: {state: { playlists: [] }, rootState: {auth: {profile: {}}}}, + expectedMutations: [] + }) + }) + }) +}) diff --git a/front/tests/unit/specs/store/queue.spec.js b/front/tests/unit/specs/store/queue.spec.js new file mode 100644 index 000000000..d7d98a17a --- /dev/null +++ b/front/tests/unit/specs/store/queue.spec.js @@ -0,0 +1,306 @@ +var sinon = require('sinon') +import {expect} from 'chai' + +import _ from 'lodash' + +import store from '@/store/queue' +import { testAction } from '../../utils' + +describe('store/queue', () => { + var sandbox + + beforeEach(function () { + // Create a sandbox for the test + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + // Restore all the things made through the sandbox + sandbox.restore() + }) + describe('mutations', () => { + it('currentIndex', () => { + const state = {} + store.mutations.currentIndex(state, 2) + expect(state.currentIndex).to.equal(2) + }) + it('ended', () => { + const state = {} + store.mutations.ended(state, false) + expect(state.ended).to.equal(false) + }) + it('tracks', () => { + const state = {} + store.mutations.tracks(state, [1, 2]) + expect(state.tracks).to.deep.equal([1, 2]) + }) + it('splice', () => { + const state = {tracks: [1, 2, 3]} + store.mutations.splice(state, {start: 1, size: 2}) + expect(state.tracks).to.deep.equal([1]) + }) + it('insert', () => { + const state = {tracks: [1, 3]} + store.mutations.insert(state, {track: 2, index: 1}) + expect(state.tracks).to.deep.equal([1, 2, 3]) + }) + it('reorder before', () => { + const state = {currentIndex: 3} + store.mutations.reorder(state, {oldIndex: 2, newIndex: 1}) + expect(state.currentIndex).to.equal(3) + }) + it('reorder from after to before', () => { + const state = {currentIndex: 3} + store.mutations.reorder(state, {oldIndex: 4, newIndex: 1}) + expect(state.currentIndex).to.equal(4) + }) + it('reorder after', () => { + const state = {currentIndex: 3} + store.mutations.reorder(state, {oldIndex: 4, newIndex: 5}) + expect(state.currentIndex).to.equal(3) + }) + it('reorder before to after', () => { + const state = {currentIndex: 3} + store.mutations.reorder(state, {oldIndex: 1, newIndex: 5}) + expect(state.currentIndex).to.equal(2) + }) + it('reorder current', () => { + const state = {currentIndex: 3} + store.mutations.reorder(state, {oldIndex: 3, newIndex: 1}) + expect(state.currentIndex).to.equal(1) + }) + }) + describe('getters', () => { + it('currentTrack', () => { + const state = { tracks: [1, 2, 3], currentIndex: 2 } + expect(store.getters['currentTrack'](state)).to.equal(3) + }) + it('hasNext true', () => { + const state = { tracks: [1, 2, 3], currentIndex: 1 } + expect(store.getters['hasNext'](state)).to.equal(true) + }) + it('hasNext false', () => { + const state = { tracks: [1, 2, 3], currentIndex: 2 } + expect(store.getters['hasNext'](state)).to.equal(false) + }) + }) + describe('actions', () => { + it('append at end', () => { + testAction({ + action: store.actions.append, + payload: {track: 4}, + params: {state: {tracks: [1, 2, 3]}}, + expectedMutations: [ + { type: 'insert', payload: {track: 4, index: 3} } + ] + }) + }) + it('append at index', () => { + testAction({ + action: store.actions.append, + payload: {track: 2, index: 1}, + params: {state: {tracks: [1, 3]}}, + expectedMutations: [ + { type: 'insert', payload: {track: 2, index: 1} } + ] + }) + }) + it('appendMany', () => { + const tracks = [{title: 1}, {title: 2}] + testAction({ + action: store.actions.appendMany, + payload: {tracks: tracks}, + params: {state: {tracks: []}}, + expectedActions: [ + { type: 'append', payload: {track: tracks[0], index: 0} }, + { type: 'append', payload: {track: tracks[1], index: 1} }, + ] + }) + }) + it('appendMany at index', () => { + const tracks = [{title: 1}, {title: 2}] + testAction({ + action: store.actions.appendMany, + payload: {tracks: tracks, index: 1}, + params: {state: {tracks: [1, 2]}}, + expectedActions: [ + { type: 'append', payload: {track: tracks[0], index: 1} }, + { type: 'append', payload: {track: tracks[1], index: 2} }, + ] + }) + }) + it('cleanTrack after current', () => { + testAction({ + action: store.actions.cleanTrack, + payload: 3, + params: {state: {currentIndex: 2, tracks: [1, 2, 3, 4, 5]}}, + expectedMutations: [ + { type: 'splice', payload: {start: 3, size: 1} } + ] + }) + }) + it('cleanTrack before current', () => { + testAction({ + action: store.actions.cleanTrack, + payload: 1, + params: {state: {currentIndex: 2, tracks: []}}, + expectedMutations: [ + { type: 'splice', payload: {start: 1, size: 1} }, + { type: 'currentIndex', payload: 1 } + ] + }) + }) + it('cleanTrack current', () => { + testAction({ + action: store.actions.cleanTrack, + payload: 2, + params: {state: {currentIndex: 2, tracks: []}}, + expectedMutations: [ + { type: 'splice', payload: {start: 2, size: 1} }, + { type: 'currentIndex', payload: 2 } + ], + expectedActions: [ + { type: 'player/stop', payload: null, options: {root: true} } + ] + }) + }) + it('cleanTrack current is last', () => { + testAction({ + action: store.actions.cleanTrack, + payload: 5, + params: { state: { currentIndex: 5, tracks: [1, 2, 3, 4, 5] } }, + expectedMutations: [ + { type: 'splice', payload: { start: 5, size: 1 } }, + { type: 'currentIndex', payload: 4 } + ], + expectedActions: [ + { type: 'player/stop', payload: null, options: { root: true } } + ] + }) + }) + it('previous when at beginning', () => { + testAction({ + action: store.actions.previous, + params: {state: {currentIndex: 0}}, + expectedActions: [ + { type: 'currentIndex', payload: 0 } + ] + }) + }) + it('previous after less than 3 seconds of playback', () => { + testAction({ + action: store.actions.previous, + params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 1}}}, + expectedActions: [ + { type: 'currentIndex', payload: 0 } + ] + }) + }) + it('previous after more than 3 seconds of playback', () => { + testAction({ + action: store.actions.previous, + params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 3}}}, + expectedActions: [ + { type: 'currentIndex', payload: 1 } + ] + }) + }) + it('next on last track when looping on queue', () => { + testAction({ + action: store.actions.next, + params: {state: {tracks: [1, 2], currentIndex: 1}, rootState: {player: {looping: 2}}}, + expectedActions: [ + { type: 'currentIndex', payload: 0 } + ] + }) + }) + it('next track when last track', () => { + testAction({ + action: store.actions.next, + params: {state: {tracks: [1, 2], currentIndex: 1}, rootState: {player: {looping: 0}}}, + expectedMutations: [ + { type: 'ended', payload: true } + ] + }) + }) + it('next track when not last track', () => { + testAction({ + action: store.actions.next, + params: {state: {tracks: [1, 2], currentIndex: 0}, rootState: {player: {looping: 0}}}, + expectedActions: [ + { type: 'currentIndex', payload: 1 } + ] + }) + }) + it('currentIndex', () => { + testAction({ + action: store.actions.currentIndex, + payload: 1, + params: {state: {tracks: [1, 2], currentIndex: 0}, rootState: {radios: {running: false}}}, + expectedMutations: [ + { type: 'ended', payload: false }, + { type: 'player/currentTime', payload: 0, options: {root: true} }, + { type: 'currentIndex', payload: 1 } + ] + }) + }) + it('currentIndex with radio and many tracks remaining', () => { + testAction({ + action: store.actions.currentIndex, + payload: 1, + params: {state: {tracks: [1, 2, 3, 4], currentIndex: 0}, rootState: {radios: {running: true}}}, + expectedMutations: [ + { type: 'ended', payload: false }, + { type: 'player/currentTime', payload: 0, options: {root: true} }, + { type: 'currentIndex', payload: 1 } + ] + }) + }) + it('currentIndex with radio and less than two tracks remaining', () => { + testAction({ + action: store.actions.currentIndex, + payload: 1, + params: {state: {tracks: [1, 2, 3], currentIndex: 0}, rootState: {radios: {running: true}}}, + expectedMutations: [ + { type: 'ended', payload: false }, + { type: 'player/currentTime', payload: 0, options: {root: true} }, + { type: 'currentIndex', payload: 1 } + ], + expectedActions: [ + { type: 'radios/populateQueue', payload: null, options: {root: true} } + ] + }) + }) + it('clean', () => { + testAction({ + action: store.actions.clean, + expectedMutations: [ + { type: 'tracks', payload: [] }, + { type: 'ended', payload: true } + ], + expectedActions: [ + { type: 'radios/stop', payload: null, options: {root: true} }, + { type: 'player/stop', payload: null, options: {root: true} }, + { type: 'currentIndex', payload: -1 } + ] + }) + }) + it('shuffle', () => { + let _shuffle = sandbox.stub(_, 'shuffle') + let tracks = ['a', 'b', 'c', 'd', 'e'] + let shuffledTracks = ['a', 'b', 'e', 'd', 'c'] + _shuffle.returns(shuffledTracks) + testAction({ + action: store.actions.shuffle, + params: {state: {currentIndex: 1, tracks: tracks}}, + expectedMutations: [ + { type: 'tracks', payload: [] } + ], + expectedActions: [ + { type: 'appendMany', payload: {tracks: shuffledTracks} }, + { type: 'currentIndex', payload: {tracks: shuffledTracks} } + ] + }) + }) + }) +}) diff --git a/front/tests/unit/specs/store/radios.spec.js b/front/tests/unit/specs/store/radios.spec.js new file mode 100644 index 000000000..a4d348d0f --- /dev/null +++ b/front/tests/unit/specs/store/radios.spec.js @@ -0,0 +1,104 @@ +var sinon = require('sinon') +import {expect} from 'chai' + +import moxios from 'moxios' +import store from '@/store/radios' +import { testAction } from '../../utils' + +describe('store/radios', () => { + var sandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + moxios.install() + }) + afterEach(function () { + sandbox.restore() + moxios.uninstall() + }) + + describe('mutations', () => { + it('current', () => { + const state = {} + store.mutations.current(state, 1) + expect(state.current).to.equal(1) + }) + it('running', () => { + const state = {} + store.mutations.running(state, false) + expect(state.running).to.equal(false) + }) + }) + describe('actions', () => { + it('start', () => { + moxios.stubRequest('radios/sessions/', { + status: 200, + response: {id: 2} + }) + testAction({ + action: store.actions.start, + payload: {type: 'favorites', objectId: 0, customRadioId: null}, + expectedMutations: [ + { + type: 'current', + payload: { + type: 'favorites', + objectId: 0, + customRadioId: null, + session: 2 + } + }, + { type: 'running', payload: true } + ], + expectedActions: [ + { type: 'populateQueue', payload: true } + ] + }) + }) + it('stop', () => { + return testAction({ + action: store.actions.stop, + params: {state: {}}, + expectedMutations: [ + { type: 'current', payload: null }, + { type: 'running', payload: false } + ] + }) + }) + it('populateQueue', () => { + moxios.stubRequest('radios/tracks/', { + status: 201, + response: {track: {id: 1}} + }) + return testAction({ + action: store.actions.populateQueue, + params: { + state: {running: true, current: {session: 1}}, + rootState: {player: {errorCount: 0, maxConsecutiveErrors: 5}} + + }, + expectedActions: [ + { type: 'queue/append', payload: {track: {id: 1}}, options: {root: true} } + ] + }) + }) + it('populateQueue does nothing when not running', () => { + testAction({ + action: store.actions.populateQueue, + params: {state: {running: false}}, + expectedActions: [] + }) + }) + it('populateQueue does nothing when too much errors', () => { + return testAction({ + action: store.actions.populateQueue, + payload: {test: 'track'}, + params: { + rootState: {player: {errorCount: 5, maxConsecutiveErrors: 5}}, + state: {running: true} + }, + expectedActions: [] + }) + }) + }) +}) diff --git a/front/tests/unit/specs/utils.spec.js b/front/tests/unit/specs/utils.spec.js new file mode 100644 index 000000000..9fc9c36b7 --- /dev/null +++ b/front/tests/unit/specs/utils.spec.js @@ -0,0 +1,32 @@ +import {expect} from 'chai' + +import {parseAPIErrors} from '@/utils' + +describe('utils', () => { + describe('parseAPIErrors', () => { + it('handles flat structure', () => { + const input = {"old_password": ["Invalid password"]} + let expected = ["Invalid password"] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + it('handles flat structure with multiple errors per field', () => { + const input = {"old_password": ["Invalid password", "Too short"]} + let expected = ["Invalid password", "Too short"] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + it('translate field name', () => { + const input = {"old_password": ["This field is required"]} + let expected = ["Old Password: This field is required"] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + it('handle nested fields', () => { + const input = {"summary": {"text": ["Ensure this field has no more than 5000 characters."]}} + let expected = ["Summary - Text: Ensure this field has no more than 5000 characters."] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + }) +}) diff --git a/front/tests/unit/specs/views/admin/library.spec.js b/front/tests/unit/specs/views/admin/library.spec.js new file mode 100644 index 000000000..dbe65f2dd --- /dev/null +++ b/front/tests/unit/specs/views/admin/library.spec.js @@ -0,0 +1,64 @@ +const sinon = require('sinon') +import { expect } from 'chai' +import { shallowMount, createLocalVue, RouterLinkStub } from '@vue/test-utils' +import AlbumDetail from '@/views/admin/library/AlbumDetail.vue' +import GetTextPlugin from 'vue-gettext' + +import HumanDate from '@/components/common/HumanDate.vue' +import DangerousButton from '@/components/common/DangerousButton.vue' + +describe('views/admin/library', () => { + + let wrapper + let sandbox + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + afterEach(() => { + sandbox.restore() + }) + describe('Album details', () => { + + it('displays default cover', async () => { + const album = { cover: null, artist: { id: 1 }, title: "dummy", id: 1, creation_date: "2020-01-01" } + const localVue = createLocalVue() + localVue.directive('title', (() => null)) + localVue.directive('dropdown', (() => null)) + localVue.use(GetTextPlugin, { translations: {} }) + localVue.filter('truncate', () => null) + localVue.filter('humanSize', () => null) + localVue.filter('ago', () => null) + localVue.filter('moment', () => null) + // overrides axios calls + sandbox.stub(AlbumDetail.methods, "fetchData").callsFake(() => null) + sandbox.stub(AlbumDetail.methods, "fetchStats").callsFake(() => null) + + wrapper = shallowMount(AlbumDetail, { + localVue, + data() { + return { + isLoading: false, + isLoadingStats: false, + object: album, + stats: [], + } + }, + mocks: { + $store: { + state: { auth: { profile: null }, ui: { lastDate: null } } + }, + }, + stubs: { + 'human-date': HumanDate, + 'dangerous-button': DangerousButton, + 'router-link': RouterLinkStub + }, + propsData: { + id: 1 + }, + computed: { labels: () => { return { statsWarning: null } } } + }) + expect(wrapper.find('img').attributes('src')).to.include("default-cover") + }) + }) +}) \ No newline at end of file diff --git a/front/tests/unit/utils.js b/front/tests/unit/utils.js new file mode 100644 index 000000000..642b3b509 --- /dev/null +++ b/front/tests/unit/utils.js @@ -0,0 +1,77 @@ +// helper for testing action with expected mutations +import Vue from 'vue' +import {expect} from 'chai' + + +export const render = (Component, propsData) => { + const Constructor = Vue.extend(Component) + return new Constructor({ propsData: propsData }).$mount() +} + +export const testAction = ({action, payload, params, expectedMutations, expectedActions}, done) => { + let mutationsCount = 0 + let actionsCount = 0 + + if (!expectedMutations) { + expectedMutations = [] + } + if (!expectedActions) { + expectedActions = [] + } + const isOver = () => { + return mutationsCount >= expectedMutations.length && actionsCount >= expectedActions.length + } + // mock commit + const commit = (type, payload) => { + const mutation = expectedMutations[mutationsCount] + + expect(mutation.type).to.equal(type) + if (payload) { + expect(mutation.payload).to.deep.equal(payload) + } + + mutationsCount++ + if (isOver()) { + return + } + } + // mock dispatch + const dispatch = (type, payload, options) => { + const a = expectedActions[actionsCount] + if (!a) { + throw Error(`Unexecpted action ${type}`) + } + expect(a.type).to.equal(type) + if (payload) { + expect(a.payload).to.deep.equal(payload) + } + if (a.options) { + expect(options).to.deep.equal(a.options) + } + actionsCount++ + if (isOver()) { + return + } + } + + let end = function () { + // check if no mutations should have been dispatched + if (expectedMutations.length === 0) { + expect(mutationsCount).to.equal(0) + } + if (expectedActions.length === 0) { + expect(actionsCount).to.equal(0) + } + if (isOver()) { + return + } + } + // call the action with mocked store and arguments + let promise = action({ commit, dispatch, ...params }, payload) + if (promise) { + promise.then(end) + return promise + } else { + return end() + } +}