feat(test): add and fix playlistStore tests

This commit is contained in:
Phan An 2022-07-23 11:29:48 +02:00
parent dcc99b9a03
commit d42e1e84a3
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
7 changed files with 159 additions and 53 deletions

View file

@ -3,6 +3,7 @@ import artistFactory, { states as artistStates } from '@/__tests__/factory/artis
import songFactory, { states as songStates } from '@/__tests__/factory/songFactory' import songFactory, { states as songStates } from '@/__tests__/factory/songFactory'
import albumFactory, { states as albumStates } from './albumFactory' import albumFactory, { states as albumStates } from './albumFactory'
import smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory' import smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory'
import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory'
import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory' import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory'
import userFactory, { states as userStates } from '@/__tests__/factory/userFactory' import userFactory, { states as userStates } from '@/__tests__/factory/userFactory'
import albumTrackFactory from '@/__tests__/factory/albumTrackFactory' import albumTrackFactory from '@/__tests__/factory/albumTrackFactory'
@ -10,7 +11,7 @@ import albumInfoFactory from '@/__tests__/factory/albumInfoFactory'
import artistInfoFactory from '@/__tests__/factory/artistInfoFactory' import artistInfoFactory from '@/__tests__/factory/artistInfoFactory'
import youTubeVideoFactory from '@/__tests__/factory/youTubeVideoFactory' import youTubeVideoFactory from '@/__tests__/factory/youTubeVideoFactory'
factory export default factory
.define('artist', faker => artistFactory(faker), artistStates) .define('artist', faker => artistFactory(faker), artistStates)
.define('artist-info', faker => artistInfoFactory(faker)) .define('artist-info', faker => artistInfoFactory(faker))
.define('album', faker => albumFactory(faker), albumStates) .define('album', faker => albumFactory(faker), albumStates)
@ -19,7 +20,6 @@ factory
.define('song', faker => songFactory(faker), songStates) .define('song', faker => songFactory(faker), songStates)
.define('video', faker => youTubeVideoFactory(faker)) .define('video', faker => youTubeVideoFactory(faker))
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker)) .define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))
.define('playlist', faker => playlistFactory(faker), playlistStates) .define('playlist', faker => playlistFactory(faker), playlistStates)
.define('user', faker => userFactory(faker), userStates) .define('user', faker => userFactory(faker), userStates)
export default factory

View file

@ -0,0 +1,7 @@
import { Faker } from '@faker-js/faker'
import factory from 'factoria'
export default (faker: Faker): SmartPlaylistRuleGroup => ({
id: faker.datatype.number(),
rules: factory<SmartPlaylistRule[]>('smart-playlist-rule', 3)
})

View file

@ -1,6 +1,6 @@
<template> <template>
<input <input
v-model="mutatedPlaylist.name" v-model="name"
v-koel-focus v-koel-focus
data-testid="inline-playlist-name-input" data-testid="inline-playlist-name-input"
name="name" name="name"
@ -15,44 +15,42 @@
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref, toRefs } from 'vue' import { reactive, ref, toRefs } from 'vue'
import { playlistStore } from '@/stores' import { playlistStore } from '@/stores'
import { alerts } from '@/utils' import { alerts, logger } from '@/utils'
const props = defineProps<{ playlist: Playlist }>() const props = defineProps<{ playlist: Playlist }>()
const { playlist } = toRefs(props) const { playlist } = toRefs(props)
const updating = ref(false) let updating = false
const mutatedPlaylist = reactive<Playlist>(Object.assign({}, playlist.value)) const mutablePlaylist = reactive<Playlist>(Object.assign({}, playlist.value))
const name = ref(mutablePlaylist.name)
const emit = defineEmits(['updated', 'cancelled']) const emit = defineEmits(['updated', 'cancelled'])
const update = async () => { const update = async () => {
mutatedPlaylist.name = mutatedPlaylist.name.trim() if (!name.value || name.value === playlist.value.name) {
if (!mutatedPlaylist.name) {
cancel()
return
}
if (mutatedPlaylist.name === playlist.value.name) {
cancel() cancel()
return return
} }
// prevent duplicate updating from Enter and Blur // prevent duplicate updating from Enter and Blur
if (updating.value) { if (updating) {
return return
} }
updating.value = true updating = true
await playlistStore.update(mutatedPlaylist) try {
alerts.success(`Playlist "${mutatedPlaylist.name}" updated.`) await playlistStore.update(mutablePlaylist, { name: name.value })
emit('updated', mutatedPlaylist) alerts.success(`Playlist "${name}" updated.`)
emit('updated', name)
} catch (error) {
alerts.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
updating = false
}
} }
const cancel = () => { const cancel = () => emit('cancelled')
mutatedPlaylist.name = playlist.value.name
emit('cancelled')
}
</script> </script>

View file

@ -117,8 +117,8 @@ const openContextMenu = async (event: MouseEvent) => {
const cancelEditing = () => (editing.value = false) const cancelEditing = () => (editing.value = false)
const onPlaylistNameUpdated = (mutatedPlaylist: Playlist) => { const onPlaylistNameUpdated = (name: string) => {
playlist.value.name = mutatedPlaylist.name playlist.value.name = name
editing.value = false editing.value = false
} }

View file

@ -11,13 +11,13 @@
<div class="form-row"> <div class="form-row">
<label> <label>
Name Name
<input v-model="mutatedPlaylist.name" v-koel-focus name="name" required type="text"> <input v-model="mutablePlaylist.name" v-koel-focus name="name" required type="text">
</label> </label>
</div> </div>
<div class="form-row rules"> <div class="form-row rules">
<RuleGroup <RuleGroup
v-for="(group, index) in mutatedPlaylist.rules" v-for="(group, index) in mutablePlaylist.rules"
:key="group.id" :key="group.id"
:group="group" :group="group"
:isFirstGroup="index === 0" :isFirstGroup="index === 0"
@ -43,18 +43,18 @@ import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { computed, reactive, watch } from 'vue' import { computed, reactive, watch } from 'vue'
import { cloneDeep, isEqual } from 'lodash' import { cloneDeep, isEqual } from 'lodash'
import { playlistStore } from '@/stores' import { playlistStore } from '@/stores'
import { alerts, eventBus, requireInjection } from '@/utils' import { alerts, eventBus, logger, requireInjection } from '@/utils'
import { useSmartPlaylistForm } from '@/components/playlist/smart-playlist/useSmartPlaylistForm' import { useSmartPlaylistForm } from '@/components/playlist/smart-playlist/useSmartPlaylistForm'
import { PlaylistKey } from '@/symbols' import { PlaylistKey } from '@/symbols'
const [playlist] = requireInjection(PlaylistKey) const [playlist] = requireInjection(PlaylistKey)
let mutatedPlaylist: Playlist let mutablePlaylist: Playlist
watch(playlist, () => (mutatedPlaylist = reactive(cloneDeep(playlist.value))), { immediate: true }) watch(playlist, () => (mutablePlaylist = reactive(cloneDeep(playlist.value))), { immediate: true })
const isPristine = computed(() => { const isPristine = computed(() => {
return isEqual(mutatedPlaylist.rules, playlist.value.rules) && mutatedPlaylist.name.trim() === playlist.value.name return isEqual(mutablePlaylist.rules, playlist.value.rules) && mutablePlaylist.name.trim() === playlist.value.name
}) })
const { const {
@ -66,7 +66,7 @@ const {
loading, loading,
addGroup, addGroup,
onGroupChanged onGroupChanged
} = useSmartPlaylistForm(mutatedPlaylist.rules) } = useSmartPlaylistForm(mutablePlaylist.rules)
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const close = () => emit('close') const close = () => emit('close')
@ -82,12 +82,22 @@ const maybeClose = () => {
const submit = async () => { const submit = async () => {
loading.value = true loading.value = true
mutatedPlaylist.rules = collectedRuleGroups.value mutablePlaylist.rules = collectedRuleGroups.value
await playlistStore.update(mutatedPlaylist)
Object.assign(playlist.value, mutatedPlaylist) try {
loading.value = false await playlistStore.update(playlist.value, {
alerts.success(`Playlist "${playlist.value.name}" updated.`) name: mutablePlaylist.name,
eventBus.emit('SMART_PLAYLIST_UPDATED', playlist.value) rules: mutablePlaylist.rules
close() })
alerts.success(`Playlist "${playlist.value.name}" updated.`)
eventBus.emit('SMART_PLAYLIST_UPDATED', playlist.value)
close()
} catch (error) {
alerts.error('Something went wrong. Please try again.')
logger.error(error)
} finally {
loading.value = false
}
} }
</script> </script>

View file

@ -2,6 +2,7 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { playlistStore } from '@/stores' import { playlistStore } from '@/stores'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { Cache, httpService } from '@/services'
const ruleGroups: SmartPlaylistRuleGroup[] = [ const ruleGroups: SmartPlaylistRuleGroup[] = [
{ {
@ -70,12 +71,102 @@ new class extends UnitTestCase {
it('sets up a smart playlist with properly unserialized rules', () => { it('sets up a smart playlist with properly unserialized rules', () => {
const playlist = factory<Playlist>('playlist', { const playlist = factory<Playlist>('playlist', {
is_smart: true, is_smart: true,
rules: serializedRuleGroups as unknown as SmartPlaylistRuleGroup[] rules: serializedRuleGroups as SmartPlaylistRuleGroup[]
}) })
playlistStore.setupSmartPlaylist(playlist) playlistStore.setupSmartPlaylist(playlist)
expect(playlist.rules).toEqual(ruleGroups) expect(playlist.rules).toEqual(ruleGroups)
}) })
it('stores a playlist', async () => {
const songs = factory<Song[]>('song', 3)
const playlist = factory<Playlist>('playlist')
const postMock = this.mock(httpService, 'post').mockResolvedValue(playlist)
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', null)
await playlistStore.store('New Playlist', songs, [])
expect(postMock).toHaveBeenCalledWith('playlist', {
name: 'New Playlist',
songs: songs.map(song => song.id),
rules: null
})
expect(serializeMock).toHaveBeenCalledWith([])
expect(playlistStore.state.playlists).toHaveLength(1)
expect(playlistStore.state.playlists[0]).toEqual(playlist)
})
it('deletes a playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
const deleteMock = this.mock(httpService, 'delete')
playlistStore.state.playlists = [factory<Playlist>('playlist'), playlist]
await playlistStore.delete(playlist)
expect(deleteMock).toHaveBeenCalledWith('playlists/12')
expect(playlistStore.state.playlists).toHaveLength(1)
expect(playlistStore.byId(playlist.id)).toBeUndefined()
})
it('adds songs to a playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
const songs = factory<Song[]>('song', 3)
const postMock = this.mock(httpService, 'post').mockResolvedValue(playlist)
const invalidateMock = this.mock(Cache, 'invalidate')
await playlistStore.addSongs(playlist, songs)
expect(postMock).toHaveBeenCalledWith('playlists/12/songs', { songs: songs.map(song => song.id) })
expect(invalidateMock).toHaveBeenCalledWith(['playlist.songs', 12])
})
it('removes songs from a playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
const songs = factory<Song[]>('song', 3)
const deleteMock = this.mock(httpService, 'delete').mockResolvedValue(playlist)
const invalidateMock = this.mock(Cache, 'invalidate')
await playlistStore.removeSongs(playlist, songs)
expect(deleteMock).toHaveBeenCalledWith('playlists/12/songs', { songs: songs.map(song => song.id) })
expect(invalidateMock).toHaveBeenCalledWith(['playlist.songs', 12])
})
it('does not modify a smart playlist content', async () => {
const playlist = factory.states('smart')<Playlist>('playlist')
const postMock = this.mock(httpService, 'post')
await playlistStore.addSongs(playlist, factory<Song[]>('song', 3))
expect(postMock).not.toHaveBeenCalled()
await playlistStore.removeSongs(playlist, factory<Song[]>('song', 3))
expect(postMock).not.toHaveBeenCalled()
})
it('updates a standard playlist', async () => {
const playlist = factory<Playlist>('playlist', { id: 12 })
const putMock = this.mock(httpService, 'put').mockResolvedValue(playlist)
await playlistStore.update(playlist, { name: 'Foo' })
expect(putMock).toHaveBeenCalledWith('playlists/12', { name: 'Foo', rules: null })
expect(playlist.name).toBe('Foo')
})
it('updates a smart playlist', async () => {
const playlist = factory.states('smart')<Playlist>('playlist', { id: 12 })
const rules = factory<SmartPlaylistRuleGroup[]>('smart-playlist-rule-group', 2)
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', ['Whatever'])
const putMock = this.mock(httpService, 'put').mockResolvedValue(playlist)
const invalidateMock = this.mock(Cache, 'invalidate')
await playlistStore.update(playlist, { name: 'Foo', rules })
expect(serializeMock).toHaveBeenCalledWith(rules)
expect(putMock).toHaveBeenCalledWith('playlists/12', { name: 'Foo', rules: ['Whatever'] })
expect(invalidateMock).toHaveBeenCalledWith(['playlist.songs', 12])
})
} }
} }

View file

@ -1,6 +1,5 @@
import { differenceBy, orderBy } from 'lodash' import { differenceBy, orderBy } from 'lodash'
import { reactive } from 'vue' import { reactive } from 'vue'
import { logger } from '@/utils' import { logger } from '@/utils'
import { Cache, httpService } from '@/services' import { Cache, httpService } from '@/services'
import models from '@/config/smart-playlist/models' import models from '@/config/smart-playlist/models'
@ -13,11 +12,7 @@ export const playlistStore = {
init (playlists: Playlist[]) { init (playlists: Playlist[]) {
this.state.playlists = this.sort(playlists) this.state.playlists = this.sort(playlists)
this.state.playlists.forEach(playlist => this.setupPlaylist(playlist)) this.state.playlists.forEach(playlist => playlist.is_smart && this.setupSmartPlaylist(playlist))
},
setupPlaylist (playlist: Playlist) {
playlist.is_smart && this.setupSmartPlaylist(playlist)
}, },
/** /**
@ -43,10 +38,12 @@ export const playlistStore = {
}, },
async store (name: string, songs: Song[] = [], rules: SmartPlaylistRuleGroup[] = []) { async store (name: string, songs: Song[] = [], rules: SmartPlaylistRuleGroup[] = []) {
const songIds = songs.map(song => song.id) const playlist = await httpService.post<Playlist>('playlist', {
const serializedRules = this.serializeSmartPlaylistRulesForStorage(rules) name,
songs: songs.map(song => song.id),
rules: this.serializeSmartPlaylistRulesForStorage(rules)
})
const playlist = await httpService.post<Playlist>('playlist', { name, songs: songIds, rules: serializedRules })
this.state.playlists.push(playlist) this.state.playlists.push(playlist)
this.state.playlists = this.sort(this.state.playlists) this.state.playlists = this.sort(this.state.playlists)
@ -80,11 +77,14 @@ export const playlistStore = {
return playlist return playlist
}, },
async update (playlist: Playlist) { async update (playlist: Playlist, data: Partial<Pick<Playlist, 'name' | 'rules'>>) {
const serializedRules = this.serializeSmartPlaylistRulesForStorage(playlist.rules) await httpService.put(`playlists/${playlist.id}`, {
await httpService.put(`playlists/${playlist.id}`, { name: playlist.name, rules: serializedRules }) name: data.name,
rules: this.serializeSmartPlaylistRulesForStorage(data.rules || [])
})
playlist.is_smart && Cache.invalidate(['playlist.songs', playlist.id]) playlist.is_smart && Cache.invalidate(['playlist.songs', playlist.id])
Object.assign(playlist, data)
return playlist return playlist
}, },