feat(test): add client test for collaborative playlists and more

This commit is contained in:
Phan An 2024-01-26 15:24:46 +01:00
parent 259e96bdd3
commit 00eebaf225
24 changed files with 623 additions and 226 deletions

View file

@ -23,7 +23,7 @@ class PlaylistCollaboratorResource extends JsonResource
public function toArray($request): array
{
return [
'type' => 'playlist_collaborators',
'type' => 'playlist-collaborators',
'id' => $this->collaborator->id,
'name' => $this->collaborator->name,
'avatar' => $this->collaborator->avatar,

View file

@ -24,7 +24,7 @@ class PlaylistFolderResource extends JsonResource
public function toArray($request): array
{
return [
'type' => 'playlist_folders',
'type' => 'playlist-folders',
'id' => $this->folder->id,
'name' => $this->folder->name,
'user_id' => $this->folder->user_id,

View file

@ -13,6 +13,7 @@ import albumInfoFactory from '@/__tests__/factory/albumInfoFactory'
import artistInfoFactory from '@/__tests__/factory/artistInfoFactory'
import youTubeVideoFactory from '@/__tests__/factory/youTubeVideoFactory'
import genreFactory from '@/__tests__/factory/genreFactory'
import playlistCollaboratorFactory from '@/__tests__/factory/playlistCollaboratorFactory'
export default factory
.define('artist', faker => artistFactory(faker), artistStates)
@ -29,3 +30,4 @@ export default factory
.define('playlist', faker => playlistFactory(faker), playlistStates)
.define('playlist-folder', faker => playlistFolderFactory(faker))
.define('user', faker => userFactory(faker), userStates)
.define('playlist-collaborator', faker => playlistCollaboratorFactory(faker))

View file

@ -0,0 +1,8 @@
import { Faker } from '@faker-js/faker'
export default (faker: Faker): PlaylistCollaborator => ({
type: 'playlist-collaborators',
id: faker.datatype.number(),
name: faker.name.findName(),
avatar: 'https://gravatar.com/foo'
})

View file

@ -20,7 +20,7 @@ const modalNameToComponentMap = {
'edit-song-form': defineAsyncComponent(() => import('@/components/song/EditSongForm.vue')),
'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')),
'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')),
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/CollaborationModal.vue')),
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')),
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')),
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
'equalizer': defineAsyncComponent(() => import('@/components/ui/Equalizer.vue'))

View file

@ -1,209 +0,0 @@
<template>
<div class="collaboration-modal" tabindex="0" @keydown.esc="close">
<header>
<h1>Playlist Collaboration</h1>
</header>
<main>
<p class="intro text-secondary">
Collaborative playlists allow multiple users to contribute. <br>
Please note that songs added to a collaborative playlist are made accessible to all users,
and you cannot mark a song as private if its still part of a collaborative playlist.
</p>
<section class="collaborators">
<h2>
<span>Current Collaborators</span>
<span v-if="canManageCollaborators">
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn>
<span v-if="justCreatedInviteLink" class="text-secondary copied">
<Icon :icon="faCheckCircle" class="text-green" />
Invite link copied to clipboard!
</span>
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
</span>
</h2>
<ul v-koel-overflow-fade>
<li v-for="user in collaborators" :key="user.id">
<span class="avatar">
<UserAvatar :user="user" width="32" />
</span>
<span class="name">
{{ user.name }}
<Icon
v-if="user.id === currentUser.id"
:icon="faCircleCheck"
class="you text-highlight"
title="This is you!"
/>
</span>
<span class="role text-secondary">
<span v-if="user.id === playlist.user_id" class="owner">Owner</span>
<span v-else class="contributor">Contributor</span>
</span>
<span v-if="canManageCollaborators" class="actions">
<Btn v-if="user.id !== playlist.user_id" small red @click.prevent="removeCollaborator(user)">
Remove
</Btn>
</span>
</li>
</ul>
</section>
</main>
<footer>
<Btn @click.prevent="close">Close</Btn>
</footer>
</div>
</template>
<script setup lang="ts">
import { faCheckCircle, faCircleCheck, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { sortBy } from 'lodash'
import { computed, onMounted, ref, Ref } from 'vue'
import { copyText, eventBus, logger } from '@/utils'
import { playlistCollaborationService } from '@/services'
import { useAuthorization, useModal, useDialogBox } from '@/composables'
import UserAvatar from '@/components/user/UserAvatar.vue'
import Btn from '@/components/ui/Btn.vue'
const playlist = useModal().getFromContext<Playlist>('playlist')
const { currentUser } = useAuthorization()
const { showConfirmDialog } = useDialogBox()
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
const creatingInviteLink = ref(false)
const justCreatedInviteLink = ref(false)
const shouldShowInviteButton = computed(() => !creatingInviteLink.value && !justCreatedInviteLink.value)
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const fetchCollaborators = async () => {
collaborators.value = sortBy(
await playlistCollaborationService.getCollaborators(playlist),
({ id }) => {
if (id === currentUser.value.id) return 0
if (id === playlist.user_id) return 1
return 2
}
)
}
const inviteCollaborators = async () => {
creatingInviteLink.value = true
try {
await copyText(await playlistCollaborationService.createInviteLink(playlist))
justCreatedInviteLink.value = true
setTimeout(() => (justCreatedInviteLink.value = false), 5_000)
} finally {
creatingInviteLink.value = false
}
}
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
const deadSure = await showConfirmDialog(
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`
)
if (!deadSure) return
try {
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id)
await playlistCollaborationService.removeCollaborator(playlist, collaborator)
eventBus.emit('PLAYLIST_COLLABORATOR_REMOVED', playlist)
} catch (e) {
logger.error(e)
}
}
onMounted(async () => await fetchCollaborators())
</script>
<style lang="scss" scoped>
.collaboration-modal {
max-width: 640px;
}
h2 {
display: flex;
span:first-child {
flex: 1;
}
.copied {
font-size: .95rem;
}
}
.collaborators {
h2 {
font-size: 1.2rem;
margin: 1.5rem 0;
}
ul {
display: flex;
width: 100%;
flex-direction: column;
margin: 1rem 0;
gap: .5rem;
max-height: calc(100vh - 8rem);
overflow-y: auto;
li {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
gap: 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-bg-secondary);
padding: .5rem .8rem;
border-radius: 5px;
transition: border-color .2s ease-in-out;
&:hover {
border-color: rgba(255, 255, 255, .15);
}
.you {
margin-left: .5rem;
}
span {
display: inline-block;
min-width: 0;
line-height: 1;
}
.name {
flex: 1;
}
.role {
text-align: right;
flex: 0 0 96px;
text-transform: uppercase;
span {
padding: 3px 4px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, .2);
}
}
.actions {
flex: 0 0 72px;
text-align: right;
}
}
}
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<span>
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn>
<span v-if="justCreatedInviteLink" class="text-secondary copied">
<Icon :icon="faCheckCircle" class="text-green" />
Link copied to clipboard!
</span>
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
</span>
</template>
<script setup lang="ts">
import { faCheckCircle, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRefs } from 'vue'
import { copyText } from '@/utils'
import { playlistCollaborationService } from '@/services'
import Btn from '@/components/ui/Btn.vue'
const props = defineProps<{ playlist: Playlist }>()
const { playlist } = toRefs(props)
const creatingInviteLink = ref(false)
const justCreatedInviteLink = ref(false)
const shouldShowInviteButton = computed(() => !creatingInviteLink.value && !justCreatedInviteLink.value)
const inviteCollaborators = async () => {
creatingInviteLink.value = true
try {
await copyText(await playlistCollaborationService.createInviteLink(playlist.value))
justCreatedInviteLink.value = true
setTimeout(() => (justCreatedInviteLink.value = false), 5_000)
} finally {
creatingInviteLink.value = false
}
}
</script>
<style scoped lang="scss">
.copied {
font-size: .95rem;
}
svg {
margin-right: .25rem;
}
</style>

View file

@ -0,0 +1,29 @@
import { expect, it } from 'vitest'
import { screen, waitFor } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { playlistCollaborationService } from '@/services'
import Component from './InvitePlaylistCollaborators.vue'
new class extends UnitTestCase {
protected test () {
it('works', async () => {
this.mock(playlistCollaborationService, 'createInviteLink').mockResolvedValue('http://localhost:3000/invite/1234')
const playlist = factory<Playlist>('playlist')
this.render(Component, {
props: {
playlist
}
})
this.render(Component)
await this.user.click(screen.getByText('Invite'))
await waitFor(async () => {
expect(navigator.clipboard.readText()).equal('http://localhost:3000/invite/1234')
screen.getByText('Link copied to clipboard!')
})
})
}
}

View file

@ -0,0 +1,26 @@
import { it, expect } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { ref } from 'vue'
import { ModalContextKey } from '@/symbols'
import Modal from './PlaylistCollaborationModal.vue'
new class extends UnitTestCase {
protected test () {
it ('renders the modal', async () => {
const { html } = this.render(Modal, {
global: {
provide: {
[<symbol>ModalContextKey]: [ref({ playlist: factory<Playlist>('playlist') })]
},
stubs: {
InviteCollaborators: this.stub('InviteCollaborators'),
CollaboratorList: this.stub('CollaboratorList')
}
}
})
expect(html()).toMatchSnapshot()
})
}
}

View file

@ -0,0 +1,70 @@
<template>
<div class="collaboration-modal" tabindex="0" @keydown.esc="close">
<header>
<h1>Playlist Collaboration</h1>
</header>
<main>
<p class="intro text-secondary">
Collaborative playlists allow multiple users to contribute. <br>
Please note that songs added to a collaborative playlist are made accessible to all users,
and you cannot mark a song as private if its still part of a collaborative playlist.
</p>
<section class="collaborators">
<h2>
<span>Current Collaborators</span>
<InviteCollaborators v-if="canManageCollaborators" :playlist="playlist" />
</h2>
<div v-koel-overflow-fade class="collaborators-wrapper">
<CollaboratorList :playlist="playlist" />
</div>
</section>
</main>
<footer>
<Btn @click.prevent="close">Close</Btn>
</footer>
</div>
</template>
<script setup lang="ts">
import { computed, ref, Ref } from 'vue'
import { useAuthorization, useModal, useDialogBox } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import InviteCollaborators from '@/components/playlist/InvitePlaylistCollaborators.vue'
import CollaboratorList from '@/components/playlist/PlaylistCollaboratorList.vue'
const playlist = useModal().getFromContext<Playlist>('playlist')
const { currentUser } = useAuthorization()
const { showConfirmDialog } = useDialogBox()
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
</script>
<style lang="scss" scoped>
.collaboration-modal {
max-width: 640px;
}
h2 {
display: flex;
font-size: 1.2rem;
margin: 1.5rem 0;
span:first-child {
flex: 1;
}
}
.collaborators-wrapper {
max-height: calc(100vh - 8rem);
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,40 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { playlistCollaborationService } from '@/services'
import Component from './PlaylistCollaboratorList.vue'
new class extends UnitTestCase {
private async renderComponent (playlist: Playlist) {
const rendered = this.render(Component, {
props: {
playlist
},
global: {
stubs: {
ListItem: this.stub('ListItem')
}
}
})
await this.tick(2)
return rendered
}
protected test () {
it('renders', async () => {
const playlist = factory<Playlist>('playlist', {
is_collaborative: true
})
const fetchMock = this.mock(playlistCollaborationService, 'fetchCollaborators').mockResolvedValue(
factory<PlaylistCollaborator>('playlist-collaborator', 5)
)
const { html } = await this.be().renderComponent(playlist)
expect(fetchMock).toHaveBeenCalledWith(playlist)
expect(html()).toMatchSnapshot()
})
}
}

View file

@ -0,0 +1,81 @@
<template>
<ListSkeleton v-if="loading" />
<ul v-else>
<ListItem
is="li"
v-for="collaborator in collaborators"
:role="currentUserIsOwner ? 'owner' : 'contributor'"
:manageable="currentUserIsOwner"
:removable="currentUserIsOwner && collaborator.id !== playlist.user_id"
:collaborator="collaborator"
@remove="removeCollaborator(collaborator)"
/>
</ul>
</template>
<script setup lang="ts">
import { sortBy } from 'lodash'
import { computed, onMounted, ref, Ref, toRefs } from 'vue'
import { useAuthorization, useDialogBox } from '@/composables'
import { playlistCollaborationService } from '@/services'
import { eventBus, logger } from '@/utils'
import ListSkeleton from '@/components/ui/skeletons/PlaylistCollaboratorListSkeleton.vue'
import ListItem from '@/components/playlist/PlaylistCollaboratorListItem.vue'
const props = defineProps<{ playlist: Playlist }>()
const { playlist } = toRefs(props)
const { currentUser } = useAuthorization()
const { showConfirmDialog } = useDialogBox()
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
const loading = ref(false)
const currentUserIsOwner = computed(() => currentUser.value?.id === playlist.value.user_id)
const fetchCollaborators = async () => {
loading.value = true
try {
collaborators.value = sortBy(
await playlistCollaborationService.fetchCollaborators(playlist.value),
({ id }) => {
if (id === currentUser.value.id) return 0
if (id === playlist.value.user_id) return 1
return 2
}
)
} finally {
loading.value = false
}
}
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
const deadSure = await showConfirmDialog(
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`
)
if (!deadSure) return
try {
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id)
await playlistCollaborationService.removeCollaborator(playlist.value, collaborator)
eventBus.emit('PLAYLIST_COLLABORATOR_REMOVED', playlist.value)
} catch (e) {
logger.error(e)
}
}
onMounted(async () => await fetchCollaborators())
</script>
<style scoped lang="scss">
ul {
display: flex;
width: 100%;
flex-direction: column;
margin: 1rem 0;
gap: .5rem;
}
</style>

View file

@ -0,0 +1,91 @@
import { expect, it } from 'vitest'
import { screen } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import Component from './PlaylistCollaboratorListItem.vue'
new class extends UnitTestCase {
private renderComponent (props: {
collaborator: PlaylistCollaborator,
removable: boolean,
manageable: boolean,
role: 'owner' | 'contributor'
}) {
return this.render(Component, {
props,
global: {
stubs: {
UserAvatar: this.stub('UserAvatar')
}
}
})
}
protected test () {
it('does not show a badge when current user is not the collaborator', async () => {
const currentUser = factory<User>('user')
this.be(currentUser).renderComponent({
collaborator: factory<PlaylistCollaborator>('playlist-collaborator', { id: currentUser.id + 1 }),
removable: true,
manageable: true,
role: 'owner'
})
expect(screen.queryByTitle('This is you!')).toBeNull()
})
it('shows a badge when current user is the collaborator', async () => {
const currentUser = factory<User>('user')
this.be(currentUser).renderComponent({
collaborator: factory<PlaylistCollaborator>('playlist-collaborator',
{
id: currentUser.id,
name: currentUser.name,
avatar: currentUser.avatar
}
),
removable: true,
manageable: true,
role: 'owner'
})
screen.getByTitle('This is you!')
})
it('shows the role', async () => {
const collaborator = factory<PlaylistCollaborator>('playlist-collaborator')
this.be().renderComponent({
collaborator,
removable: true,
manageable: true,
role: 'owner'
})
screen.getByText('Owner')
this.be().renderComponent({
collaborator,
removable: true,
manageable: true,
role: 'contributor'
})
screen.getByText('Contributor')
})
it('emits the remove event when the remove button is clicked', async () => {
const collaborator = factory<PlaylistCollaborator>('playlist-collaborator')
const { emitted } = this.be().renderComponent({
collaborator,
removable: true,
manageable: true,
role: 'owner'
})
await this.user.click(screen.getByRole('button', { name: 'Remove' }))
expect(emitted('remove')).toBeTruthy()
})
}
}

View file

@ -0,0 +1,94 @@
<template>
<li>
<span class="avatar">
<UserAvatar :user="collaborator" width="32" />
</span>
<span class="name">
{{ collaborator.name }}
<Icon
v-if="collaborator.id === currentUser.id"
:icon="faCircleCheck"
class="you text-highlight"
title="This is you!"
/>
</span>
<span class="role text-secondary">
<span v-if="role === 'owner'" class="owner">Owner</span>
<span v-else class="contributor">Contributor</span>
</span>
<span v-if="manageable" class="actions">
<Btn v-if="removable" small red @click.prevent="emit('remove')">Remove</Btn>
</span>
</li>
</template>
<script setup lang="ts">
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { toRefs } from 'vue'
import Btn from '@/components/ui/Btn.vue'
import UserAvatar from '@/components/user/UserAvatar.vue'
import { useAuthorization } from '@/composables'
const props = defineProps<{
collaborator: PlaylistCollaborator,
removable: boolean,
manageable: boolean,
role: 'owner' | 'contributor'
}>()
const { collaborator, removable, role } = toRefs(props)
const { currentUser } = useAuthorization()
const emit = defineEmits<{ (e: 'remove'): void }>()
</script>
<style scoped lang="scss">
li {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
gap: 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-bg-secondary);
padding: .5rem .8rem;
border-radius: 5px;
transition: border-color .2s ease-in-out;
&:hover {
border-color: rgba(255, 255, 255, .15);
}
.you {
margin-left: .5rem;
}
span {
display: inline-block;
min-width: 0;
line-height: 1;
}
.name {
flex: 1;
}
.role {
text-align: right;
flex: 0 0 104px;
text-transform: uppercase;
span {
padding: 3px 4px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, .2);
}
}
.actions {
flex: 0 0 72px;
text-align: right;
}
}
</style>

View file

@ -5,7 +5,7 @@ import { eventBus } from '@/utils'
import factory from '@/__tests__/factory'
import { screen, waitFor } from '@testing-library/vue'
import { songStore, userStore } from '@/stores'
import { playbackService, playlistCollaborationService } from '@/services'
import { playbackService } from '@/services'
import { queueStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs'
import PlaylistContextMenu from './PlaylistContextMenu.vue'
@ -153,20 +153,15 @@ new class extends UnitTestCase {
expect(screen.queryByText('Delete')).toBeNull()
})
it('invites collaborators', async () => {
it('opens collaboration form', async () => {
this.enablePlusEdition()
const playlist = factory<Playlist>('playlist')
await this.renderComponent(playlist)
const emitMock = this.mock(eventBus, 'emit')
const createInviteLinkMock = this.mock(playlistCollaborationService, 'createInviteLink')
.mockResolvedValue('https://koel.app/invite/123')
await this.user.click(screen.getByText('Collaborate…'))
await this.user.click(screen.getByText('Invite Collaborators'))
await waitFor(async () => {
expect(createInviteLinkMock).toHaveBeenCalledWith(playlist)
expect(await navigator.clipboard.readText()).equal('https://koel.app/invite/123')
})
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_PLAYLIST_COLLABORATION', playlist)
})
}
}

View file

@ -0,0 +1,19 @@
// Vitest Snapshot v1
exports[`renders the modal 1`] = `
<div data-v-886145d2="" class="collaboration-modal" tabindex="0">
<header data-v-886145d2="">
<h1 data-v-886145d2="">Playlist Collaboration</h1>
</header>
<main data-v-886145d2="">
<p data-v-886145d2="" class="intro text-secondary"> Collaborative playlists allow multiple users to contribute. <br data-v-886145d2=""> Please note that songs added to a collaborative playlist are made accessible to all users, and you cannot mark a song as private if its still part of a collaborative playlist. </p>
<section data-v-886145d2="" class="collaborators">
<h2 data-v-886145d2=""><span data-v-886145d2="">Current Collaborators</span>
<!--v-if-->
</h2>
<div data-v-886145d2=""><br data-v-886145d2="" data-testid="CollaboratorList" playlist="[object Object]"></div>
</section>
</main>
<footer data-v-886145d2=""><button data-v-e368fe26="" data-v-886145d2="">Close</button></footer>
</div>
`;

View file

@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<ul data-v-fa3dce2e=""><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"><br data-v-fa3dce2e="" data-testid="ListItem" is="li" role="contributor" manageable="false" removable="false" collaborator="[object Object]"></ul>`;

View file

@ -76,7 +76,7 @@ import { usePlaylistManagement, useRouter, useSongList, useAuthorization, useSon
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
import CollaboratorsBadge from '@/components/playlist/CollaboratorsBadge.vue'
import CollaboratorsBadge from '@/components/playlist/PlaylistCollaboratorsBadge.vue'
const { currentUser } = useAuthorization()
const { triggerNotFound, getRouteParam, onScreenActivated } = useRouter()
@ -124,7 +124,7 @@ const fetchDetails = async (refresh = false) => {
try {
[songs.value, collaborators.value] = await Promise.all([
songStore.fetchForPlaylist(playlist.value!, refresh),
playlistCollaborationService.getCollaborators(playlist.value!),
playlistCollaborationService.fetchCollaborators(playlist.value!),
])
sort()

View file

@ -0,0 +1,22 @@
<template>
<div class="wrapper skeleton">
<div v-for="i in 3" :key="i" class="pulse"/>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.wrapper {
display: flex;
flex-direction: column;
width: 100%;
gap: .5rem;
> div {
border-radius: 5px;
height: 49px;
}
}
</style>

View file

@ -0,0 +1,60 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory'
import { cache, http } from '@/services'
import { playlistCollaborationService as service } from './playlistCollaborationService'
new class extends UnitTestCase {
protected test () {
it('creates invite link', async () => {
const playlist = factory<Playlist>('playlist', { is_smart: false })
const postMock = this.mock(http, 'post').mockResolvedValue({ token: 'abc123' })
const link = await service.createInviteLink(playlist)
expect(postMock).toHaveBeenCalledWith(`playlists/${playlist.id}/collaborators/invite`)
expect(link).toBe('http://localhost:3000/#/playlist/collaborate/abc123')
})
it('throws if trying to create invite link for smart playlist', async () => {
const playlist = factory<Playlist>('playlist', { is_smart: true })
await expect(service.createInviteLink(playlist)).rejects.toThrow('Smart playlists are not collaborative.')
})
it('accepts invite', async () => {
const postMock = this.mock(http, 'post').mockResolvedValue({})
await service.acceptInvite('abc123')
expect(postMock).toHaveBeenCalledWith(`playlists/collaborators/accept`, { token: 'abc123' })
})
it('fetches collaborators', async () => {
const playlist = factory<Playlist>('playlist')
const collaborators = factory<PlaylistCollaborator[]>('playlist-collaborator', 2)
const getMock = this.mock(http, 'get').mockResolvedValue(collaborators)
const received = await service.fetchCollaborators(playlist)
expect(getMock).toHaveBeenCalledWith(`playlists/${playlist.id}/collaborators`)
expect(received).toBe(collaborators)
})
it('removes collaborator', async () => {
const playlist = factory<Playlist>('playlist')
const collaborator = factory<PlaylistCollaborator>('playlist-collaborator')
const deleteMock = this.mock(http, 'delete').mockResolvedValue({})
const removeCacheMock = this.mock(cache, 'remove')
await service.removeCollaborator(playlist, collaborator)
expect(deleteMock).toHaveBeenCalledWith(`playlists/${playlist.id}/collaborators`, {
collaborator:
collaborator.id
})
expect(removeCacheMock).toHaveBeenCalledWith(['playlist.songs', playlist.id])
})
}
}

View file

@ -14,7 +14,7 @@ export const playlistCollaborationService = {
return http.post<Playlist>(`playlists/collaborators/accept`, { token })
},
async getCollaborators (playlist: Playlist) {
async fetchCollaborators (playlist: Playlist) {
return http.get<PlaylistCollaborator[]>(`playlists/${playlist.id}/collaborators`)
},

View file

@ -0,0 +1,16 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { http } from '@/services'
import { plusService as service } from './plusService'
new class extends UnitTestCase {
protected test () {
it('activates license', async () => {
const postMock = this.mock(http, 'post').mockResolvedValue({})
await service.activateLicense('abc123')
expect(postMock).toHaveBeenCalledWith('licenses/activate', { key: 'abc123' })
})
}
}

View file

@ -223,7 +223,9 @@ interface PlaylistFolder {
// we don't need to keep track of the playlists here, as they can be computed using their folder_id value
}
type PlaylistCollaborator = Pick<User, 'id' | 'name' | 'avatar'>
type PlaylistCollaborator = Pick<User, 'id' | 'name' | 'avatar'> & {
type: 'playlist-collaborators'
}
interface Playlist {
type: 'playlists'