feat(test): add songStore tests

This commit is contained in:
Phan An 2022-07-24 13:47:18 +02:00
parent 5a6ddb226e
commit 9789933991
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
12 changed files with 343 additions and 107 deletions

View file

@ -1,7 +1,8 @@
import factory from 'factoria'
import artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory'
import songFactory, { states as songStates } from '@/__tests__/factory/songFactory'
import albumFactory, { states as albumStates } from './albumFactory'
import albumFactory, { states as albumStates } from '@/__tests__/factory/albumFactory'
import interactionFactory from '@/__tests__/factory/interactionFactory'
import smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory'
import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory'
import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory'
@ -18,6 +19,7 @@ export default factory
.define('album-track', faker => albumTrackFactory(faker))
.define('album-info', faker => albumInfoFactory(faker))
.define('song', faker => songFactory(faker), songStates)
.define('interaction', faker => interactionFactory(faker))
.define('video', faker => youTubeVideoFactory(faker))
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))

View file

@ -0,0 +1,10 @@
import crypto from 'crypto-random-string'
import { Faker } from '@faker-js/faker'
export default (faker: Faker): Interaction => ({
type: 'interactions',
id: faker.datatype.number({ min: 1 }),
song_id: crypto(32),
liked: faker.datatype.boolean(),
play_count: faker.datatype.number({ min: 1 })
})

View file

@ -13,3 +13,5 @@ global.ResizeObserver = global.ResizeObserver ||
window.HTMLMediaElement.prototype.load = vi.fn()
window.HTMLMediaElement.prototype.play = vi.fn()
window.HTMLMediaElement.prototype.pause = vi.fn()
window.BASE_URL = 'https://koel.test/'

View file

@ -13,7 +13,7 @@ new class extends UnitTestCase {
commonStore.state.song_count = 420
commonStore.state.song_length = 123_456
songStore.state.songs = factory<Song[]>('song', 20)
const fetchMock = this.mock(songStore, 'fetch').mockResolvedValue(2)
const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2)
const rendered = this.render(AllSongsScreen, {
global: {

View file

@ -84,7 +84,7 @@ const fetchSongs = async () => {
if (!moreSongsAvailable.value || loading) return
loading = true
page.value = await songStore.fetch(sortField, sortOrder, page.value!)
page.value = await songStore.paginate(sortField, sortOrder, page.value!)
loading = false
}

View file

@ -173,22 +173,12 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { isEqual } from 'lodash'
import { alerts, defaultCover, pluralize, requireInjection } from '@/utils'
import { songStore } from '@/stores'
import { songStore, SongUpdateData } from '@/stores'
import { EditSongFormInitialTabKey, SongsKey } from '@/symbols'
import Btn from '@/components/ui/Btn.vue'
import SoundBar from '@/components/ui/SoundBar.vue'
type EditFormData = {
title?: string
artist_name?: string
album_name?: string
album_artist_name?: string
track?: number | null
disc?: number | null
lyrics?: string
}
const [initialTab] = requireInjection(EditSongFormInitialTabKey)
const [songs] = requireInjection(SongsKey)
@ -200,7 +190,7 @@ const mutatedSongs = computed(() => songs.value)
/**
* In order not to mess up the original songs, we manually assign and manipulate their attributes.
*/
const formData = reactive<EditFormData>({
const formData = reactive<SongUpdateData>({
title: '',
album_name: '',
artist_name: '',
@ -223,7 +213,7 @@ const coverUrl = computed(() => allSongsAreInSameAlbum.value
: defaultCover
)
const allSongsShareSameValue = (key: keyof EditFormData) => {
const allSongsShareSameValue = (key: keyof SongUpdateData) => {
if (editingOnlyOneSong.value) return true
return new Set(mutatedSongs.value.map(song => song[key])).size === 1
}

View file

@ -1,13 +1,13 @@
import { reactive } from 'vue'
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, merge, orderBy, take, unionBy } from 'lodash'
import { Cache, httpService } from '@/services'
import { arrayify } from '@/utils'
import { arrayify, logger } from '@/utils'
import { songStore } from '@/stores'
const UNKNOWN_ALBUM_ID = 1
export const albumStore = {
vault: new Map<number, Album>(),
vault: new Map<number, UnwrapNestedRefs<Album>>(),
state: reactive({
albums: []
@ -64,8 +64,13 @@ export const albumStore = {
let album = this.byId(id)
if (!album) {
album = await Cache.resolve<Album>(['album', id], async () => await httpService.get<Album>(`albums/${id}`))
this.syncWithVault(album)
try {
album = this.syncWithVault(
await Cache.resolve<Album>(['album', id], async () => await httpService.get<Album>(`albums/${id}`))
)[0]
} catch (e) {
logger.error(e)
}
}
return album

View file

@ -1,13 +1,13 @@
import { reactive } from 'vue'
import { reactive, UnwrapNestedRefs } from 'vue'
import { differenceBy, orderBy, take, unionBy } from 'lodash'
import { Cache, httpService } from '@/services'
import { arrayify } from '@/utils'
import { arrayify, logger } from '@/utils'
const UNKNOWN_ARTIST_ID = 1
const VARIOUS_ARTISTS_ID = 2
export const artistStore = {
vault: new Map<number, Artist>(),
vault: new Map<number, UnwrapNestedRefs<Artist>>(),
state: reactive({
artists: []
@ -57,8 +57,13 @@ export const artistStore = {
let artist = this.byId(id)
if (!artist) {
artist = await Cache.resolve<Artist>(['artist', id], async () => await httpService.get<Artist>(`artists/${id}`))
this.syncWithVault(artist)
try {
artist = this.syncWithVault(
await Cache.resolve<Artist>(['artist', id], async () => await httpService.get<Artist>(`artists/${id}`))
)[0]
} catch (e) {
logger.error(e)
}
}
return artist

View file

@ -1,27 +1,280 @@
import isMobile from 'ismobilejs'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest'
import { songStore } from '@/stores/songStore'
import factory from '@/__tests__/factory'
import { authService, httpService } from '@/services'
import {
albumStore,
artistStore,
commonStore,
overviewStore,
preferenceStore,
songStore,
SongUpdateResult
} from '@/stores'
import { eventBus } from '@/utils'
import { reactive } from 'vue'
new class extends UnitTestCase {
protected afterEach () {
super.afterEach(() => {
isMobile.any = false
preferenceStore.transcodeOnMobile = false
})
}
protected test () {
it('gets a song by ID', () => {
expect(songStore.byId('e6d3977f3ffa147801ca5d1fdf6fa55e')!.title).toBe('Like a rolling stone')
const song = reactive(factory<Song>('song', { id: 'foo' }))
songStore.vault.set('foo', reactive(song))
songStore.vault.set('bar', reactive(factory<Song>('song', { id: 'bar' })))
expect(songStore.byId('foo')).toBe(song)
})
it('gets multiple songs by IDs', () => {
const songs = songStore.byIds(['e6d3977f3ffa147801ca5d1fdf6fa55e', 'aa16bbef6a9710eb9a0f41ecc534fad5'])
expect(songs[0].title).toBe('Like a rolling stone')
expect(songs[1].title).toBe('Knockin\' on heaven\'s door')
it('gets songs by IDs', () => {
const foo = reactive(factory<Song>('song', { id: 'foo' }))
const bar = reactive(factory<Song>('song', { id: 'bar' }))
songStore.vault.set('foo', foo)
songStore.vault.set('bar', bar)
songStore.vault.set('baz', reactive(factory<Song>('song', { id: 'baz' })))
expect(songStore.byIds(['foo', 'bar'])).toEqual([foo, bar])
})
it('sets interaction status', () => {
const song = songStore.byId('cb7edeac1f097143e65b1b2cde102482')!
expect(song.liked).toBe(true)
expect(song.play_count).toBe(3)
it('gets formatted length', () => {
expect(songStore.getFormattedLength(factory<Song>('song', { length: 123 }))).toBe('02:03')
expect(songStore.getFormattedLength([
factory<Song>('song', { length: 122 }),
factory<Song>('song', { length: 123 })
])).toBe('04:05')
})
it('guesses a song', () => {
throw 'Unimplemented'
it('gets songs by album', () => {
const songs = reactive(factory<Song>('song', 2, { album_id: 3 }))
songStore.vault.set(songs[0].id, songs[0])
songStore.vault.set(songs[1].id, songs[1])
const album = factory<Album>('album', { id: 3 })
expect(songStore.byAlbum(album)).toEqual(songs)
})
it('resolves a song', async () => {
const song = factory<Song>('song')
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(song)
expect(await songStore.resolve(song.id)).toEqual(song)
expect(getMock).toHaveBeenCalledWith(`songs/${song.id}`)
// next call shouldn't make another request
expect(await songStore.resolve(song.id)).toEqual(song)
expect(getMock).toHaveBeenCalledOnce()
})
it('matches a song', () => {
const song = factory<Song>('song', { title: 'An amazing song' })
const songs = [song, ...factory<Song[]>('song', 3)]
expect(songStore.match('An amazing song', songs)).toEqual(song)
expect(songStore.match('An Amazing Song', songs)).toEqual(song)
})
it('registers a play', async () => {
const song = factory<Song>('song', { play_count: 42 })
const postMock = this.mock(httpService, 'post').mockResolvedValueOnce(factory<Interaction>('interaction', {
song_id: song.id,
play_count: 50
}))
await songStore.registerPlay(song)
expect(postMock).toHaveBeenCalledWith('interaction/play', { song: song.id })
expect(song.play_count).toBe(50)
})
it('scrobbles', async () => {
const song = factory<Song>('song')
song.play_start_time = 123456789
const postMock = this.mock(httpService, 'post')
await songStore.scrobble(song)
expect(postMock).toHaveBeenCalledWith(`songs/${song.id}/scrobble`, { timestamp: 123456789 })
})
it('updates songs', async () => {
const songs = factory<Song[]>('song', 3)
const result: SongUpdateResult = {
songs: factory<Song[]>('song', 3),
albums: factory<Album[]>('album', 2),
artists: factory<Artist[]>('artist', 2),
removed: {
albums: [{
id: 10,
artist_id: 3,
name: 'Removed Album',
cover: 'https://example.com/removed-album.jpg',
created_at: '2020-01-01'
}],
artists: [{
id: 42,
name: 'Removed Artist',
image: 'https://example.com/removed-artist.jpg',
created_at: '2020-01-01'
}]
}
}
const syncSongsMock = this.mock(songStore, 'syncWithVault')
const syncAlbumsMock = this.mock(albumStore, 'syncWithVault')
const syncArtistsMock = this.mock(artistStore, 'syncWithVault')
const removeAlbumsMock = this.mock(albumStore, 'removeByIds')
const removeArtistsMock = this.mock(artistStore, 'removeByIds')
const emitMock = this.mock(eventBus, 'emit')
const refreshMock = this.mock(overviewStore, 'refresh')
const putMock = this.mock(httpService, 'put').mockResolvedValueOnce(result)
await songStore.update(songs, {
album_name: 'Updated Album',
artist_name: 'Updated Artist'
})
expect(putMock).toHaveBeenCalledWith('songs', {
data: {
album_name: 'Updated Album',
artist_name: 'Updated Artist'
},
songs: songs.map(song => song.id)
})
expect(syncSongsMock).toHaveBeenCalledWith(result.songs)
expect(syncAlbumsMock).toHaveBeenCalledWith(result.albums)
expect(syncArtistsMock).toHaveBeenCalledWith(result.artists)
expect(removeAlbumsMock).toHaveBeenCalledWith([10])
expect(removeArtistsMock).toHaveBeenCalledWith([42])
expect(emitMock).toHaveBeenCalledWith('SONGS_UPDATED')
expect(refreshMock).toHaveBeenCalled()
})
it('gets source URL', () => {
commonStore.state.cdn_url = 'http://localhost/'
const song = factory<Song>('song', { id: 'foo' })
this.mock(authService, 'getToken', 'hadouken')
expect(songStore.getSourceUrl(song)).toBe('http://localhost/play/foo?api_token=hadouken')
isMobile.any = true
preferenceStore.transcodeOnMobile = true
expect(songStore.getSourceUrl(song)).toBe('http://localhost/play/foo/1/128?api_token=hadouken')
})
it('gets shareable URL', () => {
const song = factory<Song>('song', { id: 'foo' })
expect(songStore.getShareableUrl(song)).toBe('https://koel.test/#!/song/foo')
})
it('syncs with the vault', () => {
const song = factory<Song>('song', {
playback_state: null
})
const trackPlayCountMock = this.mock(songStore, 'trackPlayCount')
expect(songStore.syncWithVault(song)).toEqual([reactive(song)])
expect(songStore.vault.has(song.id)).toBe(true)
expect(trackPlayCountMock).toHaveBeenCalledOnce()
expect(songStore.syncWithVault(song)).toEqual([reactive(song)])
expect(songStore.vault.has(song.id)).toBe(true)
// second call shouldn't set up play count tracking again
expect(trackPlayCountMock).toHaveBeenCalledOnce()
})
it('tracks play count', async () => {
const refreshMock = this.mock(overviewStore, 'refresh')
const artist = reactive(factory<Artist>('artist', { id: 42, play_count: 100 }))
const album = reactive(factory<Album>('album', { id: 10, play_count: 120 }))
const albumArtist = reactive(factory<Artist>('artist', { id: 43, play_count: 130 }))
artistStore.vault.set(42, artist)
artistStore.vault.set(43, albumArtist)
albumStore.vault.set(10, album)
const song = reactive(factory<Song>('song', {
album_id: 10,
artist_id: 42,
album_artist_id: 43,
play_count: 98
}))
songStore.trackPlayCount(song)
song.play_count = 100
await this.tick()
expect(artist.play_count).toBe(102)
expect(album.play_count).toBe(122)
expect(albumArtist.play_count).toBe(132)
expect(refreshMock).toHaveBeenCalled()
})
it('fetches for album', async () => {
const songs = factory<Song[]>('song', 3)
const album = factory<Album>('album', { id: 42 })
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault')
await songStore.fetchForAlbum(album)
expect(getMock).toHaveBeenCalledWith('albums/42/songs')
expect(syncMock).toHaveBeenCalledWith(songs)
})
it('fetches for artist', async () => {
const songs = factory<Song[]>('song', 3)
const artist = factory<Artist>('artist', { id: 42 })
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault')
await songStore.fetchForArtist(artist)
expect(getMock).toHaveBeenCalledWith('artists/42/songs')
expect(syncMock).toHaveBeenCalledWith(songs)
})
it('fetches for playlist', async () => {
const songs = factory<Song[]>('song', 3)
const playlist = factory<Playlist>('playlist', { id: 42 })
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault')
await songStore.fetchForPlaylist(playlist)
expect(getMock).toHaveBeenCalledWith('playlists/42/songs')
expect(syncMock).toHaveBeenCalledWith(songs)
})
it('paginates', async () => {
const songs = factory<Song[]>('song', 3)
const getMock = this.mock(httpService, 'get').mockResolvedValueOnce({
data: songs,
links: {
next: 'http://localhost/api/v1/songs?page=3'
},
meta: {
current_page: 2
}
})
const syncMock = this.mock(songStore, 'syncWithVault', reactive(songs))
expect(await songStore.paginate('title', 'desc', 2)).toBe(3)
expect(getMock).toHaveBeenCalledWith('songs?page=2&sort=title&order=desc')
expect(syncMock).toHaveBeenCalledWith(songs)
expect(songStore.state.songs).toEqual(reactive(songs))
})
}
}

View file

@ -1,30 +1,22 @@
import isMobile from 'ismobilejs'
import slugify from 'slugify'
import { orderBy, take, union, unionBy } from 'lodash'
import { reactive, watch } from 'vue'
import { arrayify, eventBus, secondsToHis, use } from '@/utils'
import { merge, orderBy, sumBy, take, unionBy } from 'lodash'
import { reactive, UnwrapNestedRefs, watch } from 'vue'
import { arrayify, eventBus, logger, secondsToHis, use } from '@/utils'
import { authService, Cache, httpService } from '@/services'
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore } from '@/stores'
interface BroadcastSongData {
song: {
id: string
title: string
liked: boolean
playbackState: PlaybackState
album: {
id: number
name: string
cover: string
}
artist: {
id: number
name: string
}
}
export type SongUpdateData = {
title?: string
artist_name?: string
album_name?: string
album_artist_name?: string
track?: number | null
disc?: number | null
lyrics?: string
}
interface SongUpdateResult {
export interface SongUpdateResult {
songs: Song[]
artists: Artist[]
albums: Album[]
@ -35,27 +27,13 @@ interface SongUpdateResult {
}
export const songStore = {
vault: new Map<string, Song>(),
vault: new Map<string, UnwrapNestedRefs<Song>>(),
state: reactive({
songs: [] as Song[]
}),
/**
* Get the total duration of some songs.
*
* @param songs
* @param {Boolean} formatted Whether to convert the duration into H:i:s format
*/
getLength: (songs: Song[], formatted: boolean = false) => {
const duration = songs.reduce((length, song) => length + song.length, 0)
return formatted ? secondsToHis(duration) : duration
},
getFormattedLength (songs: Song[]) {
return String(this.getLength(songs, true))
},
getFormattedLength: (songs: Song | Song[]) => secondsToHis(sumBy(arrayify(songs), 'length')),
byId (id: string) {
return this.vault.get(id)
@ -72,15 +50,17 @@ export const songStore = {
},
async resolve (id: string) {
if (this.byId(id)) {
return this.byId(id)
let song = this.byId(id)
if (!song) {
try {
song = this.syncWithVault(await httpService.get<Song>(`songs/${id}`))[0]
} catch (e) {
logger.error(e)
}
}
try {
return this.syncWithVault(await httpService.get<Song>(`songs/${id}`))[0]
} catch (e) {
return null
}
return song
},
/**
@ -109,9 +89,11 @@ export const songStore = {
song.play_count = interaction.play_count
},
scrobble: async (song: Song) => await httpService.post(`${song.id}/scrobble`, { timestamp: song.play_start_time }),
scrobble: async (song: Song) => await httpService.post(`songs/${song.id}/scrobble`, {
timestamp: song.play_start_time
}),
async update (songsToUpdate: Song[], data: any) {
async update (songsToUpdate: Song[], data: SongUpdateData) {
const { songs, artists, albums, removed } = await httpService.put<SongUpdateResult>('songs', {
data,
songs: songsToUpdate.map(song => song.id)
@ -138,42 +120,24 @@ export const songStore = {
getShareableUrl: (song: Song) => `${window.BASE_URL}#!/song/${song.id}`,
generateDataToBroadcast: (song: Song): BroadcastSongData => ({
song: {
id: song.id,
title: song.title,
liked: song.liked,
playbackState: song.playback_state || 'Stopped',
album: {
id: song.album_id,
name: song.album_name,
cover: song.album_cover
},
artist: {
id: song.artist_id,
name: song.artist_name
}
}
}),
syncWithVault (songs: Song | Song[]) {
return arrayify(songs).map(song => {
let local = this.byId(song.id)
if (local) {
Object.assign(local, song)
merge(local, song)
} else {
song.playback_state = 'Stopped'
local = reactive(song)
this.trackPlayCount(local!)
local.playback_state = 'Stopped'
this.trackPlayCount(local)
this.vault.set(local.id, local)
}
this.vault.set(song.id, local)
return local
})
},
trackPlayCount: (song: Song) => {
trackPlayCount: (song: UnwrapNestedRefs<Song>) => {
watch(() => song.play_count, (newCount, oldCount) => {
const album = albumStore.byId(song.album_id)
album && (album.play_count += (newCount - oldCount))
@ -211,7 +175,7 @@ export const songStore = {
)
},
async fetch (sortField: SongListSortField, sortOrder: SortOrder, page: number) {
async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number) {
const resource = await httpService.get<PaginatorResource>(
`songs?page=${page}&sort=${sortField}&order=${sortOrder}`
)

View file

@ -268,6 +268,7 @@ interface Settings {
}
interface Interaction {
type: 'interactions'
readonly id: number
readonly song_id: string
liked: boolean

View file

@ -55,7 +55,11 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::put('settings', [SettingController::class, 'update']);
/**
* @deprecated Use songs/{song}/scrobble instead
*/
Route::post('{song}/scrobble', [ScrobbleController::class, 'store']);
Route::post('songs/{song}/scrobble', [ScrobbleController::class, 'store']);
Route::put('songs', [SongController::class, 'update']);
Route::post('upload', UploadController::class);