feat(test): add tests for multiple functions

This commit is contained in:
Phan An 2024-01-15 23:26:50 +01:00
parent 179faefeed
commit 891cabaeb8
37 changed files with 311 additions and 195 deletions

View file

@ -38,7 +38,7 @@ class SongController extends Controller
$this->songRepository->getForListing(
sortColumn: $request->sort ?: 'songs.title',
sortDirection: $request->order ?: 'asc',
ownSongsOnly: (bool) $request->ownSongsOnly,
ownSongsOnly: $request->boolean('own_songs_only'),
scopedUser: $this->user
)
);

View file

@ -5,7 +5,7 @@ namespace App\Http\Requests\API;
/**
* @property-read string $order
* @property-read string $sort
* @property-read boolean|string|integer $ownSongsOnly
* @property-read boolean|string|integer $own_songs_only
*/
class SongListRequest extends Request
{

View file

@ -52,21 +52,22 @@ export default abstract class UnitTestCase {
protected afterEach (cb?: Closure) {
afterEach(() => {
isMobile.any = false
commonStore.state.song_length = 10
cleanup()
this.restoreAllMocks()
isMobile.any = false
this.disablePlusEdition()
cb && cb()
})
}
protected actingAs (user?: User) {
protected be (user?: User) {
userStore.state.current = user || factory<User>('user')
return this
}
protected actingAsAdmin () {
return this.actingAs(factory.states('admin')<User>('user'))
protected beAdmin () {
return this.be(factory.states('admin')<User>('user'))
}
protected mock<T, M extends MethodOf<Required<T>>> (obj: T, methodName: M, implementation?: any) {
@ -137,6 +138,26 @@ export default abstract class UnitTestCase {
return options
}
protected enablePlusEdition () {
commonStore.state.koel_plus = {
active: true,
short_key: '****-XXXX',
customer_name: 'John Doe',
customer_email: 'Koel Plus',
product_id: 'koel-plus',
}
}
protected disablePlusEdition () {
commonStore.state.koel_plus = {
active: false,
short_key: '',
customer_name: '',
customer_email: '',
product_id: '',
}
}
protected stub (testId = 'stub') {
return defineComponent({
template: `<br data-testid="${testId}"/>`

View file

@ -1,13 +1,20 @@
declare global {
interface Window {
BASE_URL: string;
}
}
import vueSnapshotSerializer from 'jest-serializer-vue'
import { expect, vi } from 'vitest'
import Axios from 'axios'
declare global {
interface Window {
BASE_URL: string;
createLemonSqueezy: () => void;
}
interface LemonSqueezy {
Url: {
Open: () => void;
}
}
}
expect.addSnapshotSerializer(vueSnapshotSerializer)
global.ResizeObserver = global.ResizeObserver ||
@ -17,6 +24,13 @@ global.ResizeObserver = global.ResizeObserver ||
unobserve: vi.fn()
}))
global.LemonSqueezy = {
Url: {
Open: vi.fn()
}
}
HTMLMediaElement.prototype.load = vi.fn()
HTMLMediaElement.prototype.play = vi.fn()
HTMLMediaElement.prototype.pause = vi.fn()
@ -34,5 +48,6 @@ HTMLDialogElement.prototype.close = vi.fn(function mock () {
})
window.BASE_URL = 'http://test/'
window.createLemonSqueezy = vi.fn()
Axios.defaults.adapter = vi.fn()

View file

@ -0,0 +1,22 @@
import { screen } from '@testing-library/vue'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { plusService } from '@/services'
import Form from './ActivateLicenseForm.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(Form )
}
protected test () {
it('activates license', async () => {
this.renderComponent()
const activateMock = this.mock(plusService, 'activateLicense').mockResolvedValueOnce('')
await this.type(screen.getByRole('textbox'), 'my-license-key')
await this.user.click(screen.getByText('Activate'))
expect(activateMock).toHaveBeenCalledWith('my-license-key')
})
}
}

View file

@ -0,0 +1,32 @@
import { screen } from '@testing-library/vue'
import { commonStore } from '@/stores'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import Modal from './KoelPlusModal.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.render(Modal)
}
protected test () {
it('shows button to purchase Koel Plus', async () => {
commonStore.state.koel_plus.product_id = '42'
this.renderComponent()
screen.getByTestId('buttons')
expect(screen.queryByTestId('activateForm')).toBeNull()
await this.user.click(screen.getByText('Purchase Koel Plus'))
expect(global.LemonSqueezy.Url.Open).toHaveBeenCalledWith(
'https://store.plus.koel.dev/checkout/buy/42?embed=1&media=0&desc=0'
)
})
it('shows form to activate Koel Plus', async () => {
commonStore.state.koel_plus.product_id = '42'
this.renderComponent()
await this.user.click(screen.getByText('I have a license key'))
screen.getByTestId('activateForm')
})
}
}

View file

@ -9,12 +9,12 @@
in the future!
</div>
<div class="buttons" v-show="!showingActivateLicenseForm">
<div v-show="!showingActivateLicenseForm" class="buttons" data-testid="buttons">
<Btn big red @click.prevent="openPurchaseOverlay">Purchase Koel Plus</Btn>
<Btn big green @click.prevent="showActivateLicenseForm">I have a license key</Btn>
</div>
<div class="activate-form" v-if="showingActivateLicenseForm">
<div v-if="showingActivateLicenseForm" class="activate-form" data-testid="activateForm">
<ActivateLicenseForm v-if="showingActivateLicenseForm" />
<Btn transparent class="cancel" @click.prevent="hideActivateLicenseForm">Cancel</Btn>
</div>
@ -52,7 +52,7 @@ const openPurchaseOverlay = () => {
const showActivateLicenseForm = () => (showingActivateLicenseForm.value = true)
const hideActivateLicenseForm = () => (showingActivateLicenseForm.value = false)
onMounted(() => window.createLemonSqueezy()) // @ts-ignore
onMounted(() => window.createLemonSqueezy?.())
</script>
<style scoped lang="scss">

View file

@ -77,7 +77,7 @@ new class extends UnitTestCase {
it('shows new version', () => {
commonStore.state.current_version = 'v1.0.0'
commonStore.state.latest_version = 'v1.0.1'
this.actingAsAdmin().renderComponent()[0].getByRole('button', { name: 'New version available!' })
this.beAdmin().renderComponent()[0].getByRole('button', { name: 'New version available!' })
})
})

View file

@ -19,14 +19,14 @@ const standardItems = [
const adminItems = [...standardItems, 'Users', 'Upload', 'Settings']
new class extends UnitTestCase {
protected test() {
protected test () {
it('shows the standard items', () => {
this.actingAs().render(Sidebar)
this.be().render(Sidebar)
standardItems.forEach(label => screen.getByText(label))
})
it('shows administrative items', () => {
this.actingAsAdmin().render(Sidebar)
this.beAdmin().render(Sidebar)
adminItems.forEach(label => screen.getByText(label))
})

View file

@ -4,7 +4,7 @@ import { eventBus } from '@/utils'
import YouTubeSidebarItem from './YouTubeSidebarItem.vue'
new class extends UnitTestCase {
protected test() {
protected test () {
it('renders', async () => {
const { html } = this.render(YouTubeSidebarItem)

View file

@ -27,7 +27,7 @@ new class extends UnitTestCase {
it('shows new version', () => {
commonStore.state.current_version = 'v1.0.0'
commonStore.state.latest_version = 'v1.0.1'
this.actingAsAdmin().renderComponent().getByTestId('new-version-about')
this.beAdmin().renderComponent().getByTestId('new-version-about')
})
it('shows demo notation', async () => {

View file

@ -35,6 +35,11 @@ new class extends UnitTestCase {
expect((await this.renderComponent()).queryByTestId('support-bar')).toBeNull()
})
it('does not show for Plus edition', async () => {
this.enablePlusEdition()
expect((await this.renderComponent()).queryByTestId('support-bar')).toBeNull()
})
it('hides', async () => {
await this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Hide' }))

View file

@ -36,7 +36,7 @@ const stopBugging = () => {
watch(preferenceStore.initialized, initialized => {
if (!initialized) return
if (preferenceStore.state.supportBarNoBugging || isMobile.any) return
if (!isPlus.value) return
if (isPlus.value) return
setUpShowBarTimeout()
}, { immediate: true })

View file

@ -20,7 +20,7 @@
</label>
</div>
<div class="form-row rules">
<div class="form-row rules" v-koel-overflow-fade>
<RuleGroup
v-for="(group, index) in collectedRuleGroups"
:key="group.id"

View file

@ -26,7 +26,7 @@
</label>
</div>
<div class="form-row rules">
<div class="form-row rules" v-koel-overflow-fade>
<RuleGroup
v-for="(group, index) in mutablePlaylist.rules"
:key="group.id"

View file

@ -10,6 +10,26 @@
<style lang="scss" scoped>
.smart-playlist-form {
width: 560px;
max-height: calc(100vh - 4rem);
}
:slotted(form) {
max-height: calc(100vh - 4rem);
display: flex;
flex-direction: column;
overflow: auto;
main {
flex-grow: 1;
overflow: scroll;
display: flex;
flex-direction: column;
.rules {
flex-grow: 1;
overflow: auto;
}
}
}
:slotted(label.folder) {

View file

@ -8,7 +8,7 @@ import ProfileForm from './ProfileForm.vue'
new class extends UnitTestCase {
private async renderComponent (user: User) {
return this.actingAs(user).render(ProfileForm)
return this.be(user).render(ProfileForm)
}
protected test () {

View file

@ -11,9 +11,9 @@ new class extends UnitTestCase {
commonStore.state.uses_spotify = useSpotify
if (isAdmin) {
this.actingAsAdmin()
this.beAdmin()
} else {
this.actingAs()
this.be()
}
expect(this.render(SpotifyIntegration).html()).toMatchSnapshot();

View file

@ -26,13 +26,19 @@ new class extends UnitTestCase {
}
})
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('title', 'asc', 1))
return rendered
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith({
sort: 'title',
order: 'asc',
page: 1,
own_songs_only: false
}))
return [rendered, fetchMock] as const
}
protected test () {
it('renders', async () => {
const { html } = await this.renderComponent()
const [{ html }] = await this.renderComponent()
await waitFor(() => expect(html()).toMatchSnapshot())
})
@ -42,7 +48,7 @@ new class extends UnitTestCase {
const goMock = this.mock(this.router, 'go')
await this.renderComponent()
await this.user.click(screen.getByTitle('Shuffle all songs'))
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
await waitFor(() => {
expect(queueMock).toHaveBeenCalled()
@ -50,5 +56,20 @@ new class extends UnitTestCase {
expect(goMock).toHaveBeenCalledWith('queue')
})
})
it('renders in Plus edition', async () => {
this.enablePlusEdition()
const [{ html }, fetchMock] = await this.renderComponent()
await waitFor(() => expect(html()).toMatchSnapshot())
await this.user.click(screen.getByLabelText('Own songs only'))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith({
sort: 'title',
order: 'asc',
page: 1,
own_songs_only: true
}))
})
}
}

View file

@ -132,7 +132,12 @@ const fetchSongs = async () => {
loading.value = true
try {
page.value = await songStore.paginate(sortField, sortOrder, page.value!, ownSongsOnly.value)
page.value = await songStore.paginate({
sort: sortField,
order: sortOrder,
page: page.value!,
own_songs_only: ownSongsOnly.value
})
} catch (error) {
toastError('Failed to load songs.')
logger.error(error)

View file

@ -31,7 +31,11 @@ new class extends UnitTestCase {
await waitFor(() => {
expect(fetchGenreMock).toHaveBeenCalledWith(genre!.name)
expect(paginateMock).toHaveBeenCalledWith(genre!.name, 'title', 'asc', 1)
expect(paginateMock).toHaveBeenCalledWith(genre!.name, {
sort: 'title',
order: 'asc',
page: 1
})
})
await this.tick(2)
@ -52,7 +56,7 @@ new class extends UnitTestCase {
await this.renderComponent(genre, songs)
await this.user.click(screen.getByTitle('Shuffle all songs'))
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
expect(playbackMock).toHaveBeenCalledWith(songs, true)
})
@ -65,7 +69,7 @@ new class extends UnitTestCase {
await this.renderComponent(genre, songs)
await this.user.click(screen.getByTitle('Shuffle all songs'))
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(genre, 500)

View file

@ -103,7 +103,11 @@ const fetch = async () => {
[genre.value, fetched] = await Promise.all([
genreStore.fetchOne(name.value!),
songStore.paginateForGenre(name.value!, sortField, sortOrder, page.value!)
songStore.paginateForGenre(name.value!, {
sort: sortField,
order: sortOrder,
page: page.value!,
})
])
page.value = fetched.nextPage

View file

@ -53,7 +53,7 @@ new class extends UnitTestCase {
this.renderComponent(songs)
const playMock = this.mock(playbackService, 'queueAndPlay')
await this.user.click(screen.getByTitle('Shuffle all songs'))
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
await waitFor(() => expect(playMock).toHaveBeenCalledWith(songs, true))
})
}

View file

@ -1,137 +1,74 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<section id="songsWrapper">
<header class="screen-header expanded" data-v-5691beb5="">
<aside class="thumbnail-wrapper" data-v-5691beb5="">
<div class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-55bfc268="" data-v-5691beb5-s=""><span data-testid="thumbnail" data-v-55bfc268=""></span></div>
</aside>
<main data-v-5691beb5="">
<div class="heading-wrapper" data-v-5691beb5="">
<h1 class="name" data-v-5691beb5=""> All Songs
<!--v-if-->
</h1><span class="meta text-secondary" data-v-5691beb5=""><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34 hr 17 min</span></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-d396e0d2="" data-v-5691beb5-s="">
<div class="wrapper" data-v-d396e0d2=""><span class="btn-group" uppercased="" data-v-e884c19a="" data-v-d396e0d2=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-e368fe26="" data-v-d396e0d2=""><br data-testid="icon" icon="[object Object]" fixed-width="" data-v-d396e0d2=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div class="menu-wrapper" data-v-d396e0d2="">
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
</ul>
</section><button type="button" transparent="" data-v-e368fe26="" data-v-42061e3e="">New Playlist…</button>
</div>
</div>
</div>
</main>
</header><br data-testid="song-list">
</section>
`;
exports[`renders 2`] = `
<section id="songsWrapper">
<header class="screen-header expanded" data-v-5691beb5="">
<aside class="thumbnail-wrapper" data-v-5691beb5="">
<div class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-55bfc268="" data-v-5691beb5-s=""><span data-testid="thumbnail" data-v-55bfc268=""></span></div>
</aside>
<main data-v-5691beb5="">
<div class="heading-wrapper" data-v-5691beb5="">
<h1 class="name" data-v-5691beb5=""> All Songs
<!--v-if-->
</h1><span class="meta text-secondary" data-v-5691beb5=""><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34 hr 17 min</span></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-d396e0d2="" data-v-5691beb5-s="">
<div class="wrapper" data-v-d396e0d2=""><span class="btn-group" uppercased="" data-v-e884c19a="" data-v-d396e0d2=""><button class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-e368fe26="" data-v-d396e0d2=""><br data-testid="icon" icon="[object Object]" fixed-width="" data-v-d396e0d2=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div class="menu-wrapper" data-v-d396e0d2="">
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
</ul>
</section><button transparent="" data-v-e368fe26="" data-v-42061e3e="">New Playlist…</button>
</div>
</div>
</div>
</main>
</header><br data-testid="song-list">
</section>
`;
exports[`renders 3`] = `
<section id="songsWrapper">
<header data-v-5691beb5="" class="screen-header expanded">
<section data-v-8ea4eaa5="" id="songsWrapper">
<header data-v-5691beb5="" data-v-8ea4eaa5="" class="screen-header expanded">
<aside data-v-5691beb5="" class="thumbnail-wrapper">
<div data-v-55bfc268="" data-v-5691beb5-s="" class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);"><span data-v-55bfc268="" data-testid="thumbnail"></span></div>
<div data-v-55bfc268="" data-v-8ea4eaa5="" data-v-5691beb5-s="" class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);"><span data-v-55bfc268="" data-testid="thumbnail"></span></div>
</aside>
<main data-v-5691beb5="">
<div data-v-5691beb5="" class="heading-wrapper">
<h1 data-v-5691beb5="" class="name"> All Songs
<!--v-if-->
</h1><span data-v-5691beb5="" class="meta text-secondary"><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34 hr 17 min</span></span>
</h1><span data-v-5691beb5="" class="meta text-secondary"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">420 songs</span><span data-v-8ea4eaa5="" data-v-5691beb5-s="">34 hr 17 min</span></span>
</div>
<div data-v-d396e0d2="" data-v-5691beb5-s="" class="song-list-controls" data-testid="song-list-controls">
<div data-v-d396e0d2="" class="wrapper"><span data-v-e884c19a="" data-v-d396e0d2="" class="btn-group" uppercased=""><button data-v-e368fe26="" data-v-d396e0d2="" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs"><br data-v-d396e0d2="" data-testid="icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div data-v-d396e0d2="" class="menu-wrapper">
<div data-v-42061e3e="" data-v-d396e0d2="" class="add-to" data-testid="add-to-menu" tabindex="0">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</ul>
</section><button data-v-e368fe26="" data-v-42061e3e="" transparent="">New Playlist…</button>
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls">
<div data-v-d396e0d2="" data-v-8ea4eaa5="" data-v-5691beb5-s="" class="song-list-controls" data-testid="song-list-controls">
<div data-v-d396e0d2="" class="wrapper"><span data-v-e884c19a="" data-v-d396e0d2="" class="btn-group" uppercased=""><button data-v-e368fe26="" data-v-d396e0d2="" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-v-d396e0d2="" data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div data-v-d396e0d2="" class="menu-wrapper">
<div data-v-42061e3e="" data-v-d396e0d2="" class="add-to" data-testid="add-to-menu" tabindex="0">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</ul>
</section><button data-v-e368fe26="" data-v-42061e3e="" transparent="">New Playlist…</button>
</div>
</div>
</div>
<!--v-if-->
</div>
</main>
</header><br data-testid="song-list">
</header><br data-v-8ea4eaa5="" data-testid="song-list">
</section>
`;
exports[`renders 4`] = `
<section id="songsWrapper">
<header data-v-5691beb5="" class="screen-header expanded">
exports[`renders in Plus edition 1`] = `
<section data-v-8ea4eaa5="" id="songsWrapper">
<header data-v-5691beb5="" data-v-8ea4eaa5="" class="screen-header expanded">
<aside data-v-5691beb5="" class="thumbnail-wrapper">
<div data-v-55bfc268="" data-v-5691beb5-s="" class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);"><span data-v-55bfc268="" data-testid="thumbnail"></span></div>
<div data-v-55bfc268="" data-v-8ea4eaa5="" data-v-5691beb5-s="" class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);"><span data-v-55bfc268="" data-testid="thumbnail"></span></div>
</aside>
<main data-v-5691beb5="">
<div data-v-5691beb5="" class="heading-wrapper">
<h1 data-v-5691beb5="" class="name"> All Songs
<!--v-if-->
</h1><span data-v-5691beb5="" class="meta text-secondary"><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34 hr 17 min</span></span>
</h1><span data-v-5691beb5="" class="meta text-secondary"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">420 songs</span><span data-v-8ea4eaa5="" data-v-5691beb5-s="">34 hr 17 min</span></span>
</div>
<div data-v-d396e0d2="" data-v-5691beb5-s="" class="song-list-controls" data-testid="song-list-controls">
<div data-v-d396e0d2="" class="wrapper"><span data-v-e884c19a="" data-v-d396e0d2="" class="btn-group" uppercased=""><button data-v-e368fe26="" data-v-d396e0d2="" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs"><br data-v-d396e0d2="" data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div data-v-d396e0d2="" class="menu-wrapper">
<div data-v-42061e3e="" data-v-d396e0d2="" class="add-to" data-testid="add-to-menu" tabindex="0">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</ul>
</section><button data-v-e368fe26="" data-v-42061e3e="" transparent="">New Playlist…</button>
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls">
<div data-v-d396e0d2="" data-v-8ea4eaa5="" data-v-5691beb5-s="" class="song-list-controls" data-testid="song-list-controls">
<div data-v-d396e0d2="" class="wrapper"><span data-v-e884c19a="" data-v-d396e0d2="" class="btn-group" uppercased=""><button data-v-e368fe26="" data-v-d396e0d2="" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-v-d396e0d2="" data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
</div>
<div data-v-d396e0d2="" class="menu-wrapper">
<div data-v-42061e3e="" data-v-d396e0d2="" class="add-to" data-testid="add-to-menu" tabindex="0">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</ul>
</section><button data-v-e368fe26="" data-v-42061e3e="" transparent="">New Playlist…</button>
</div>
</div>
</div><label data-v-8ea4eaa5="" data-v-5691beb5-s="" class="own-songs-toggle text-secondary"><span data-v-b5259680="" data-v-8ea4eaa5="" data-v-5691beb5-s="" class=""><input data-v-b5259680="" type="checkbox"></span><span data-v-8ea4eaa5="" data-v-5691beb5-s="">Own songs only</span></label>
</div>
</main>
</header><br data-testid="song-list">
</header><br data-v-8ea4eaa5="" data-testid="song-list">
</section>
`;

View file

@ -247,7 +247,7 @@ new class extends UnitTestCase {
})
it('allows edit songs if current user is admin', async () => {
await this.actingAsAdmin().renderComponent()
await this.beAdmin().renderComponent()
// mock after render to ensure that the component is mounted properly
const emitMock = this.mock(eventBus, 'emit')
@ -257,7 +257,7 @@ new class extends UnitTestCase {
})
it('does not allow edit songs if current user is not admin', async () => {
await this.actingAs().renderComponent()
await this.be().renderComponent()
expect(screen.queryByText('Edit…')).toBeNull()
})
@ -270,7 +270,7 @@ new class extends UnitTestCase {
const confirmMock = this.mock(DialogBoxStub.value, 'confirm', true)
const toasterMock = this.mock(MessageToasterStub.value, 'success')
const deleteMock = this.mock(songStore, 'deleteFromFilesystem')
await this.actingAsAdmin().renderComponent()
await this.beAdmin().renderComponent()
const emitMock = this.mock(eventBus, 'emit')
@ -285,12 +285,12 @@ new class extends UnitTestCase {
})
it('does not have an option to delete songs if current user is not admin', async () => {
await this.actingAs().renderComponent()
await this.be().renderComponent()
expect(screen.queryByText('Delete from Filesystem')).toBeNull()
})
it('creates playlist from selected songs', async () => {
await this.actingAs().renderComponent()
await this.be().renderComponent()
// mock after render to ensure that the component is mounted properly
const emitMock = this.mock(eventBus, 'emit')

View file

@ -4,7 +4,7 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import SongListFilter from './SongListFilter.vue'
new class extends UnitTestCase {
protected test() {
protected test () {
it('emit an event on input', async () => {
const { emitted } = this.render(SongListFilter)

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary">Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="Icon" icon="[object Object]"></button></span></div>`;
exports[`renders 1`] = `<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary"><!--v-if--> Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="Icon" icon="[object Object]"></button></span></div>`;

View file

@ -31,7 +31,7 @@ new class extends UnitTestCase {
const song = factory<Song>('song', { lyrics: null })
const mock = this.mock(eventBus, 'emit')
this.actingAsAdmin().renderComponent(song)
this.beAdmin().renderComponent(song)
await this.user.click(screen.getByRole('button', { name: 'Click here' }))
@ -39,7 +39,7 @@ new class extends UnitTestCase {
})
it('does not have a button to add lyrics if current user is not an admin', async () => {
this.actingAs().renderComponent(factory<Song>('song', { lyrics: null }))
this.be().renderComponent(factory<Song>('song', { lyrics: null }))
expect(screen.queryByRole('button', { name: 'Click here' })).toBeNull()
})
}

View file

@ -11,7 +11,7 @@ new class extends UnitTestCase {
avatar: 'https://example.com/avatar.jpg'
})
expect(this.actingAs(user).render(ProfileAvatar).html()).toMatchSnapshot()
expect(this.be(user).render(ProfileAvatar).html()).toMatchSnapshot()
})
}
}

View file

@ -7,7 +7,7 @@ import UserBadge from './UserBadge.vue'
new class extends UnitTestCase {
private renderComponent () {
return this.actingAs(factory<User>('user', {
return this.be(factory<User>('user', {
name: 'John Doe'
})).render(UserBadge)
}

View file

@ -20,7 +20,7 @@ new class extends UnitTestCase {
protected test () {
it('has different behaviors for current user', () => {
const user = factory<User>('user')
this.actingAs(user).renderComponent(user)
this.be(user).renderComponent(user)
screen.getByTitle('This is you!')
screen.getByText('Your Profile')
@ -39,7 +39,7 @@ new class extends UnitTestCase {
it('redirects to Profile screen if edit current user', async () => {
const mock = this.mock(this.router, 'go')
const user = factory<User>('user')
this.actingAs(user).renderComponent(user)
this.be(user).renderComponent(user)
await this.user.click(screen.getByRole('button', { name: 'Your Profile' }))
@ -49,7 +49,7 @@ new class extends UnitTestCase {
it('deletes user if confirmed', async () => {
this.mock(DialogBoxStub.value, 'confirm').mockResolvedValue(true)
const user = factory<User>('user')
this.actingAsAdmin().renderComponent(user)
this.beAdmin().renderComponent(user)
const destroyMock = this.mock(userStore, 'destroy')
await this.user.click(screen.getByRole('button', { name: 'Delete' }))
@ -60,7 +60,7 @@ new class extends UnitTestCase {
it('does not delete user if not confirmed', async () => {
this.mock(DialogBoxStub.value, 'confirm').mockResolvedValue(false)
const user = factory<User>('user')
this.actingAsAdmin().renderComponent(user)
this.beAdmin().renderComponent(user)
const destroyMock = this.mock(userStore, 'destroy')
await this.user.click(screen.getByRole('button', { name: 'Delete' }))
@ -71,7 +71,7 @@ new class extends UnitTestCase {
it('revokes invite for prospects', async () => {
this.mock(DialogBoxStub.value, 'confirm').mockResolvedValue(true)
const prospect = factory.states('prospect')<User>('user')
this.actingAsAdmin().renderComponent(prospect)
this.beAdmin().renderComponent(prospect)
const revokeMock = this.mock(invitationService, 'revoke')
await this.user.click(screen.getByRole('button', { name: 'Revoke' }))
@ -82,7 +82,7 @@ new class extends UnitTestCase {
it('does not revoke invite for prospects if not confirmed', async () => {
this.mock(DialogBoxStub.value, 'confirm').mockResolvedValue(false)
const prospect = factory.states('prospect')<User>('user')
this.actingAsAdmin().renderComponent(prospect)
this.beAdmin().renderComponent(prospect)
const revokeMock = this.mock(invitationService, 'revoke')
await this.user.click(screen.getByRole('button', { name: 'Revoke' }))

View file

@ -88,13 +88,8 @@ export default class Router {
return this.cache.get(location.hash)
}
public async triggerNotFound () {
await this.activateRoute(this.notFoundRoute)
}
public onRouteChanged (handler: RouteChangedHandler) {
this.routeChangedHandlers.push(handler)
}
public triggerNotFound = async () => await this.activateRoute(this.notFoundRoute)
public onRouteChanged = (handler: RouteChangedHandler) => this.routeChangedHandlers.push(handler)
public async activateRoute (route: Route, params: RouteParams = {}) {
this.$currentRoute.value = route

View file

@ -130,18 +130,6 @@ class PlaybackService {
})
}
public setCurrentSongAndPlaybackPosition (song: Song, playbackPosition: number) {
if (queueStore.current) {
queueStore.current.playback_state = 'Stopped'
}
song.playback_state = 'Paused'
this.player.media.src = songStore.getSourceUrl(song)
this.player.seek(playbackPosition)
this.player.pause()
}
// Record the UNIX timestamp the song starts playing, for scrobbling purpose
private recordStartTime (song: Song) {
song.play_start_time = Math.floor(Date.now() / 1000)

View file

@ -274,9 +274,14 @@ new class extends UnitTestCase {
const syncMock = this.mock(songStore, 'syncWithVault', reactive(songs))
expect(await songStore.paginate('title', 'desc', 2)).toBe(3)
expect(await songStore.paginate({
page: 2,
sort: 'title',
order: 'desc',
own_songs_only: true
})).toBe(3)
expect(getMock).toHaveBeenCalledWith('songs?page=2&sort=title&order=desc')
expect(getMock).toHaveBeenCalledWith('songs?page=2&sort=title&order=desc&own_songs_only=true')
expect(syncMock).toHaveBeenCalledWith(songs)
expect(songStore.state.songs).toEqual(reactive(songs))
})
@ -297,7 +302,11 @@ new class extends UnitTestCase {
const syncMock = this.mock(songStore, 'syncWithVault', reactiveSongs)
expect(await songStore.paginateForGenre('foo', 'title', 'desc', 2)).toEqual({
expect(await songStore.paginateForGenre('foo', {
page: 2,
sort: 'title',
order: 'desc'
})).toEqual({
songs: reactiveSongs,
nextPage: 3
})

View file

@ -29,6 +29,19 @@ export interface SongUpdateResult {
}
}
export interface SongListPaginateParams extends Record<string, any> {
sort: SongListSortField
order: SortOrder
page: number
own_songs_only: boolean
}
export interface GenreSongListPaginateParams extends Record<string, any> {
sort: SongListSortField
order: SortOrder
page: number
}
export const songStore = {
vault: new Map<string, UnwrapNestedRefs<Song>>(),
@ -184,13 +197,9 @@ export const songStore = {
return uniqBy(songs, 'id')
},
async paginateForGenre (genre: Genre | string, sortField: SongListSortField, sortOrder: SortOrder, page: number) {
async paginateForGenre (genre: Genre | string, params: GenreSongListPaginateParams) {
const name = typeof genre === 'string' ? genre : genre.name
const resource = await http.get<PaginatorResource>(
`genres/${name}/songs?page=${page}&sort=${sortField}&order=${sortOrder}`
)
const resource = await http.get<PaginatorResource>(`genres/${name}/songs?${new URLSearchParams(params).toString()}`)
const songs = this.syncWithVault(resource.data)
return {
@ -204,11 +213,8 @@ export const songStore = {
return this.syncWithVault(await http.get<Song[]>(`genres/${name}/songs/random?limit=${limit}`))
},
async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number, ownSongOnly: boolean) {
const resource = await http.get<PaginatorResource>(
`songs?page=${page}&sort=${sortField}&order=${sortOrder}&ownSongsOnly=${ownSongOnly ? 1 : 0}`
)
async paginate (params: SongListPaginateParams) {
const resource = await http.get<PaginatorResource>(`songs?${new URLSearchParams(params).toString()}`)
this.state.songs = unionBy(this.state.songs, this.syncWithVault(resource.data), 'id')
return resource.links.next ? ++resource.meta.current_page : null

View file

@ -60,6 +60,7 @@ interface Window {
readonly PUSHER_APP_KEY: string
readonly PUSHER_APP_CLUSTER: string
readonly MediaMetadata: Constructable<Record<string, any>>
createLemonSqueezy?: () => Closure
}
interface FileSystemDirectoryReader {

View file

@ -18,6 +18,37 @@ class SongTest extends TestCase
License::fakePlusLicense();
}
public function testWithOwnSongsOnlyOptionOn(): void
{
$user = create_user();
Song::factory(2)->public()->create();
$ownSongs = Song::factory(3)->for($user, 'owner')->create();
$this->getAs('api/songs?own_songs_only=true', $user)
->assertSuccessful()
->assertJsonCount(3, 'data')
->assertJsonFragment(['id' => $ownSongs[0]->id])
->assertJsonFragment(['id' => $ownSongs[1]->id])
->assertJsonFragment(['id' => $ownSongs[2]->id]);
}
public function testWithOwnSongsOnlyOptionOffOrMissing(): void
{
$user = create_user();
Song::factory(2)->public()->create();
Song::factory(3)->for($user, 'owner')->create();
$this->getAs('api/songs?own_songs_only=false', $user)
->assertSuccessful()
->assertJsonCount(5, 'data');
$this->getAs('api/songs', $user)
->assertSuccessful()
->assertJsonCount(5, 'data');
}
public function testShowSongPolicy(): void
{
$user = create_user();