+
+
+
-
+
+
-
+
+
+
+
+
+ {{ $t('timeline.no_statuses') }}
+
+
+
+
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
index ee6fa8f20..185a127c4 100644
--- a/src/components/timeline_menu/timeline_menu.js
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -1,5 +1,4 @@
-import { mapState as mapPiniaState } from 'pinia'
-import { mapState } from 'vuex'
+import { mapState } from 'pinia'
import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
@@ -13,6 +12,7 @@ import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useListsStore } from 'src/stores/lists'
+import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
@@ -62,16 +62,30 @@ const TimelineMenu = {
(route === 'bookmark-folder' || route === 'bookmarks')
)
},
- ...mapPiniaState(useInstanceCapabilitiesStore, [
+ timelineName() {
+ const route = this.$route.name
+ if (route === 'tag-timeline') {
+ return '#' + this.$route.params.tag
+ }
+ if (route === 'lists-timeline') {
+ return useListsStore().findListTitle(this.$route.params.id)
+ }
+ if (route === 'bookmark-folder') {
+ return useBookmarkFoldersStore().findBookmarkFolderName(
+ this.$route.params.id,
+ )
+ }
+ const i18nkey = timelineNames(this.bookmarkFolders)[this.$route.name]
+ return i18nkey ? this.$t(i18nkey) : route
+ },
+ ...mapState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable',
'pleromaBookmarkFoldersAvailable',
'bookmarkFolders',
'localBubble',
]),
- ...mapPiniaState(useInstanceStore, ['privateMode', 'federating']),
- ...mapState({
- currentUser: (state) => state.users.currentUser,
- }),
+ ...mapState(useInstanceStore, ['privateMode', 'federating']),
+ ...mapState(useUsersStore, ['currentUser']),
timelinesList() {
return filterNavigation(
Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
@@ -105,22 +119,6 @@ const TimelineMenu = {
event.stopPropagation()
}
},
- timelineName() {
- const route = this.$route.name
- if (route === 'tag-timeline') {
- return '#' + this.$route.params.tag
- }
- if (route === 'lists-timeline') {
- return useListsStore().findListTitle(this.$route.params.id)
- }
- if (route === 'bookmark-folder') {
- return useBookmarkFoldersStore().findBookmarkFolderName(
- this.$route.params.id,
- )
- }
- const i18nkey = timelineNames(this.bookmarkFolders)[this.$route.name]
- return i18nkey ? this.$t(i18nkey) : route
- },
},
}
diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue
index 717c8a8e8..6a56e8741 100644
--- a/src/components/timeline_menu/timeline_menu.vue
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -30,7 +30,7 @@
- {
+ .then(({ data: user, ...rest }) => {
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
- this.$store.commit('addNewUsers', [user])
- this.$store.commit('setCurrentUser', user)
+ useUsersStore().addNewUsers({ data: user, ...rest })
this.resetState()
})
.catch((error) => {
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index e6c6485f7..525a22833 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -23,7 +23,7 @@
class="user-info-avatar -link"
@click="zoomAvatar"
>
-
+
- {{ $t('timeline.no_statuses') }}
-
-
{{ $t('timeline.no_more_statuses') }}
-
{{ timelineName() }}
+{{ timelineName }}
@@ -51,7 +51,7 @@
-
+
diff --git a/src/components/user_list_menu/user_list_menu.js b/src/components/user_list_menu/user_list_menu.js
index b36175276..d04cdf692 100644
--- a/src/components/user_list_menu/user_list_menu.js
+++ b/src/components/user_list_menu/user_list_menu.js
@@ -3,6 +3,7 @@ import { mapState } from 'pinia'
import Popover from 'src/components/popover/popover.vue'
import { useListsStore } from 'src/stores/lists.js'
+import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
@@ -18,7 +19,7 @@ const UserListMenu = {
Popover,
},
created() {
- this.$store.dispatch('fetchUserInLists', this.user.id)
+ useUsersStore().fetchUserInLists(this.user.id)
},
computed: {
...mapState(useListsStore, {
@@ -49,7 +50,7 @@ const UserListMenu = {
if (!response.ok) {
return
}
- this.$store.dispatch('fetchUserInLists', this.user.id)
+ useUsersStore().fetchUserInLists(this.user.id)
})
} else {
useListsStore()
@@ -58,7 +59,7 @@ const UserListMenu = {
if (!response.ok) {
return
}
- this.$store.dispatch('fetchUserInLists', this.user.id)
+ useUsersStore().fetchUserInLists(this.user.id)
})
}
},
diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js
index 84107d6b1..f5a403be2 100644
--- a/src/components/user_list_popover/user_list_popover.js
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -4,6 +4,7 @@ import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -14,15 +15,22 @@ library.add(faCircleNotch)
const UserListPopover = {
name: 'UserListPopover',
- props: ['users'],
+ props: {
+ userIds: Set,
+ },
components: {
UnicodeDomainIndicator,
Popover,
UserAvatar,
},
computed: {
+ users() {
+ return [...this.userIds]
+ .map((id) => useUsersStore().findUser(id))
+ .filter(Boolean)
+ },
usersCapped() {
- return this.users.slice(0, 16)
+ return [...this.users].slice(0, 16)
},
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
index ddeb6f6f6..b5677bcab 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -9,7 +9,7 @@
',
)
- const wrapper = shallowMount(RichContent, {
- global,
- props: {
- attentions,
- handleLinks: false,
- greentext: false,
- emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
- html,
- },
- })
+ const wrapper = shallowMount(
+ RichContent,
+ mountOpts({
+ props: {
+ attentions,
+ handleLinks: false,
+ greentext: false,
+ emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
+ html,
+ },
+ }),
+ )
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
@@ -217,16 +224,18 @@ describe('RichContent', () => {
it("Doesn't add nonexistent emoji to post", () => {
const html = p('Lol :lol:')
- const wrapper = shallowMount(RichContent, {
- global,
- props: {
- attentions,
- handleLinks: false,
- greentext: false,
- emoji: [],
- html,
- },
- })
+ const wrapper = shallowMount(
+ RichContent,
+ mountOpts({
+ props: {
+ attentions,
+ handleLinks: false,
+ greentext: false,
+ emoji: [],
+ html,
+ },
+ }),
+ )
expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(html))
})
@@ -245,16 +254,18 @@ describe('RichContent', () => {
'>quote',
].join('\n')
- const wrapper = shallowMount(RichContent, {
- global,
- props: {
- attentions,
- handleLinks: true,
- greentext: true,
- emoji: [],
- html,
- },
- })
+ const wrapper = shallowMount(
+ RichContent,
+ mountOpts({
+ props: {
+ attentions,
+ handleLinks: true,
+ greentext: true,
+ emoji: [],
+ html,
+ },
+ }),
+ )
expect(wrapper.html()).to.eql(compwrap(expected))
})
@@ -268,16 +279,18 @@ describe('RichContent', () => {
].join('
') const expected = ['Bruh', 'Bruh', mentionsLine(3), 'Bruh'].join('
') - const wrapper = shallowMount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = shallowMount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected)) }) @@ -304,16 +317,18 @@ describe('RichContent', () => { ' ', ].join('') - const wrapper = shallowMount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = shallowMount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected)) }) @@ -351,16 +366,18 @@ describe('RichContent', () => { p('Testing'), ].join('') - const wrapper = mount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = mount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect( wrapper @@ -424,16 +441,18 @@ describe('RichContent', () => { 'Testing', ].join('') - const wrapper = mount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = mount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect( wrapper @@ -473,16 +492,18 @@ describe('RichContent', () => { '', ].join('') - const wrapper = shallowMount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = shallowMount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected)) }) @@ -519,14 +540,16 @@ describe('RichContent', () => { const ptest = (handleLinks, vhtml) => { const t0 = performance.now() - const wrapper = mount(TestComponent, { - global, - props: { - attentions, - handleLinks, - vhtml, - }, - }) + const wrapper = mount( + TestComponent, + mountOpts({ + props: { + attentions, + handleLinks, + vhtml, + }, + }), + ) const t1 = performance.now() diff --git a/test/unit/specs/components/user_profile.spec.js b/test/unit/specs/components/user_profile.spec.js deleted file mode 100644 index 36d323575..000000000 --- a/test/unit/specs/components/user_profile.spec.js +++ /dev/null @@ -1,212 +0,0 @@ -import { mount } from '@vue/test-utils' -import { createStore } from 'vuex' - -import UserProfile from 'src/components/user_profile/user_profile.vue' - -import { getters } from 'src/modules/users.js' - -const mutations = { - clearTimeline: () => { - /* no-op */ - }, -} - -const actions = { - fetchUser: () => { - /* no-op */ - }, - fetchUserByScreenName: () => { - /* no-op */ - }, -} - -const testGetters = { - findUser: (state) => getters.findUser(state.users), - findUserByName: (state) => getters.findUserByName(state.users), - relationship: (state) => getters.relationship(state.users), - mergedConfig: () => ({ - colors: '', - highlight: {}, - customTheme: { - colors: [], - }, - }), -} - -const localUser = { - id: 100, - is_local: true, - screen_name: 'testUser', - screen_name_ui: 'testUser', -} - -const extUser = { - id: 100, - is_local: false, - screen_name: 'testUser@test.instance', - screen_name_ui: 'testUser@test.instance', -} - -const externalProfileStore = createStore({ - mutations, - actions, - getters: testGetters, - state: { - interface: { - browserSupport: '', - }, - instance: { - hideUserStats: true, - }, - statuses: { - timelines: { - user: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - media: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - }, - }, - users: { - currentUser: { - credentials: '', - }, - usersObject: { 100: extUser }, - usersByNameObject: {}, - users: [extUser], - relationships: {}, - }, - }, -}) - -const localProfileStore = createStore({ - mutations, - actions, - getters: testGetters, - state: { - interface: { - browserSupport: '', - }, - config: { - colors: '', - highlight: {}, - customTheme: { - colors: [], - }, - }, - instance: { - hideUserStats: true, - }, - statuses: { - timelines: { - user: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - media: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - }, - }, - users: { - currentUser: { - credentials: '', - }, - usersObject: { 100: localUser }, - usersByNameObject: { testuser: localUser }, - users: [localUser], - relationships: {}, - }, - }, -}) - -// https://github.com/vuejs/test-utils/issues/1382 -describe.skip('UserProfile', () => { - it('renders external profile', () => { - const wrapper = mount(UserProfile, { - global: { - plugins: [externalProfileStore], - mocks: { - $route: { - params: { id: 100 }, - name: 'external-user-profile', - }, - $t: (msg) => msg, - }, - }, - }) - - expect(wrapper.find('.user-screen-name').text()).to.eql( - '@testUser@test.instance', - ) - }) - - it('renders local profile', () => { - const wrapper = mount(UserProfile, { - global: { - plugins: [localProfileStore], - mocks: { - $route: { - params: { name: 'testUser' }, - name: 'user-profile', - }, - $t: (msg) => msg, - }, - }, - }) - - expect(wrapper.find('.user-screen-name').text()).to.eql('@testUser') - }) -}) diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js deleted file mode 100644 index 04bd07ffe..000000000 --- a/test/unit/specs/modules/statuses.spec.js +++ /dev/null @@ -1,447 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' - -import { - defaultState, - mutations, - prepareStatus, -} from '../../../../src/modules/statuses.js' - -createTestingPinia() - -const makeMockStatus = ({ id, text, type = 'status' }) => { - return { - id, - user: { id: '0' }, - name: 'status', - text: text || `Text number ${id}`, - fave_num: 0, - uri: '', - type, - attentions: [], - } -} - -describe('Statuses module', () => { - describe('prepareStatus', () => { - it('sets deleted flag to false', () => { - const aStatus = makeMockStatus({ id: '1', text: 'Hello oniichan' }) - expect(prepareStatus(aStatus).deleted).to.eq(false) - }) - }) - - describe('addNewStatuses', () => { - it('adds the status to allStatuses and to the given timeline', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { - statuses: [status], - timeline: 'public', - }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([status]) - expect(state.timelines.public.visibleStatuses).to.eql([]) - expect(state.timelines.public.newStatusCount).to.equal(1) - }) - - it('counts the status as new if it has not been seen on this timeline', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { - statuses: [status], - timeline: 'public', - }) - mutations.addNewStatuses(state, { - statuses: [status], - timeline: 'friends', - }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([status]) - expect(state.timelines.public.visibleStatuses).to.eql([]) - expect(state.timelines.public.newStatusCount).to.equal(1) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.friends.statuses).to.eql([status]) - expect(state.timelines.friends.visibleStatuses).to.eql([]) - expect(state.timelines.friends.newStatusCount).to.equal(1) - }) - - it('add the statuses to allStatuses if no timeline is given', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { statuses: [status] }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([]) - expect(state.timelines.public.visibleStatuses).to.eql([]) - expect(state.timelines.public.newStatusCount).to.equal(0) - }) - - it('adds the status to allStatuses and to the given timeline, directly visible', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([status]) - expect(state.timelines.public.visibleStatuses).to.eql([status]) - expect(state.timelines.public.newStatusCount).to.equal(0) - }) - - it('does not update the maxId when the noIdUpdate flag is set', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const secondStatus = makeMockStatus({ id: '2' }) - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.maxId).to.equal('1') - - mutations.addNewStatuses(state, { - statuses: [secondStatus], - showImmediately: true, - timeline: 'public', - noIdUpdate: true, - }) - expect(state.timelines.public.statuses).to.eql([secondStatus, status]) - expect(state.timelines.public.visibleStatuses).to.eql([ - secondStatus, - status, - ]) - expect(state.timelines.public.maxId).to.equal('1') - }) - - it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => { - const state = defaultState() - const nonVisibleStatus = makeMockStatus({ id: '1' }) - const status = makeMockStatus({ id: '3' }) - const statusTwo = makeMockStatus({ id: '2' }) - const statusThree = makeMockStatus({ id: '4' }) - - mutations.addNewStatuses(state, { - statuses: [nonVisibleStatus], - showImmediately: false, - timeline: 'public', - }) - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addNewStatuses(state, { - statuses: [statusTwo], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.minVisibleId).to.equal('2') - - mutations.addNewStatuses(state, { - statuses: [statusThree], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.statuses).to.eql([ - statusThree, - status, - statusTwo, - nonVisibleStatus, - ]) - expect(state.timelines.public.visibleStatuses).to.eql([ - statusThree, - status, - statusTwo, - ]) - }) - - it('splits retweets from their status and links them', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const retweet = makeMockStatus({ id: '2', type: 'retweet' }) - const modStatus = makeMockStatus({ id: '1', text: 'something else' }) - - retweet.retweeted_status = status - - // It adds both statuses, but only the retweet to visible. - mutations.addNewStatuses(state, { - statuses: [retweet], - timeline: 'public', - showImmediately: true, - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.statuses).to.have.length(1) - expect(state.allStatuses).to.have.length(2) - expect(state.allStatuses[0].id).to.equal('1') - expect(state.allStatuses[1].id).to.equal('2') - - // It refers to the modified status. - mutations.addNewStatuses(state, { - statuses: [modStatus], - timeline: 'public', - }) - expect(state.allStatuses).to.have.length(2) - expect(state.allStatuses[0].id).to.equal('1') - expect(state.allStatuses[0].text).to.equal(modStatus.text) - expect(state.allStatuses[1].id).to.equal('2') - expect(retweet.retweeted_status.text).to.eql(modStatus.text) - }) - - it('replaces existing statuses with the same id', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const modStatus = makeMockStatus({ id: '1', text: 'something else' }) - - // Add original status - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.allStatuses).to.have.length(1) - - // Add new version of status - mutations.addNewStatuses(state, { - statuses: [modStatus], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.allStatuses).to.have.length(1) - expect(state.allStatuses[0].text).to.eql(modStatus.text) - }) - - it('replaces existing statuses with the same id, coming from a retweet', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const modStatus = makeMockStatus({ id: '1', text: 'something else' }) - const retweet = makeMockStatus({ id: '2', type: 'retweet' }) - retweet.retweeted_status = modStatus - - // Add original status - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.allStatuses).to.have.length(1) - - // Add new version of status - mutations.addNewStatuses(state, { - statuses: [retweet], - showImmediately: false, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - // Don't add the retweet itself if the tweet is visible - expect(state.timelines.public.statuses).to.have.length(1) - expect(state.allStatuses).to.have.length(2) - expect(state.allStatuses[0].text).to.eql(modStatus.text) - }) - - it('handles favorite actions', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - const favorite = { - id: '2', - type: 'favorite', - in_reply_to_status_id: '1', // The API uses strings here... - uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', - text: 'a favorited something by b', - user: { id: '99' }, - } - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addNewStatuses(state, { - statuses: [favorite], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) - expect(state.timelines.public.maxId).to.eq(favorite.id) - - // Adding it again does nothing - mutations.addNewStatuses(state, { - statuses: [favorite], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) - expect(state.timelines.public.maxId).to.eq(favorite.id) - - // If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request. - const user = { - id: '1', - } - - const ownFavorite = { - id: '3', - type: 'favorite', - in_reply_to_status_id: '1', // The API uses strings here... - uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', - text: 'a favorited something by b', - user, - } - - mutations.addNewStatuses(state, { - statuses: [ownFavorite], - showImmediately: true, - timeline: 'public', - user, - }) - - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) - expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true) - }) - }) - - describe('emojiReactions', () => { - it('increments count in existing reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [{ name: '😂', count: 1, accounts: [] }] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(2) - expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true) - expect( - state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id, - ).to.equal('me') - }) - - it('adds a new reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1) - expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true) - expect( - state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id, - ).to.equal('me') - }) - - it('decreases count in existing reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [ - { name: '😂', count: 2, accounts: [{ id: 'me' }] }, - ] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.removeOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1) - expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(false) - expect(state.allStatusesObject['1'].emoji_reactions[0].accounts).to.eql( - [], - ) - }) - - it('removes a reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [ - { name: '😂', count: 1, accounts: [{ id: 'me' }] }, - ] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.removeOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions).to.have.length(0) - }) - }) - - describe('showNewStatuses', () => { - it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => { - const state = defaultState() - const status = makeMockStatus({ id: '10' }) - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - const newStatus = makeMockStatus({ id: '20' }) - mutations.addNewStatuses(state, { - statuses: [newStatus], - showImmediately: false, - timeline: 'public', - }) - state.timelines.public.minId = '5' - mutations.showNewStatuses(state, { timeline: 'public' }) - - expect(state.timelines.public.visibleStatuses).to.have.length(2) - expect(state.timelines.public.minVisibleId).to.equal('10') - expect(state.timelines.public.minId).to.equal('10') - }) - }) - - describe('clearTimeline', () => { - it('keeps userId when clearing user timeline when excludeUserId param is true', () => { - const state = defaultState() - state.timelines.user.userId = 123 - - mutations.clearTimeline(state, { timeline: 'user', excludeUserId: true }) - - expect(state.timelines.user.userId).to.eql(123) - }) - }) -}) diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js deleted file mode 100644 index 1b33f8c4e..000000000 --- a/test/unit/specs/modules/users.spec.js +++ /dev/null @@ -1,120 +0,0 @@ -import { cloneDeep } from 'lodash' - -import { - defaultState, - getters, - mutations, -} from '../../../../src/modules/users.js' - -describe('The users module', () => { - describe('mutations', () => { - it('adds new users to the set, merging in new information for old users', () => { - const state = cloneDeep(defaultState) - const user = { id: '1', name: 'Guy' } - const modUser = { id: '1', name: 'Dude' } - - mutations.addNewUsers(state, [user]) - expect(state.users).to.have.length(1) - expect(state.users).to.eql([user]) - - mutations.addNewUsers(state, [modUser]) - expect(state.users).to.have.length(1) - expect(state.users).to.eql([user]) - expect(state.users[0].name).to.eql('Dude') - }) - - it('merging array field in new information for old users', () => { - const state = cloneDeep(defaultState) - const user = { - id: '1', - fields: [{ name: 'Label 1', value: 'Content 1' }], - } - const firstModUser = { - id: '1', - fields: [ - { name: 'Label 2', value: 'Content 2' }, - { name: 'Label 3', value: 'Content 3' }, - ], - } - const secondModUser = { - id: '1', - fields: [{ name: 'Label 4', value: 'Content 4' }], - } - - mutations.addNewUsers(state, [user]) - expect(state.users[0].fields).to.have.length(1) - expect(state.users[0].fields[0].name).to.eql('Label 1') - - mutations.addNewUsers(state, [firstModUser]) - expect(state.users[0].fields).to.have.length(2) - expect(state.users[0].fields[0].name).to.eql('Label 2') - expect(state.users[0].fields[1].name).to.eql('Label 3') - - mutations.addNewUsers(state, [secondModUser]) - expect(state.users[0].fields).to.have.length(1) - expect(state.users[0].fields[0].name).to.eql('Label 4') - }) - }) - - describe('findUser', () => { - it('does not return user with matching screen_name', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const name = 'Guy' - expect(getters.findUser(state)(name)).to.be.undefined - }) - - it('returns user with matching id', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const id = '1' - const expected = { screen_name: 'Guy', id: '1' } - expect(getters.findUser(state)(id)).to.eql(expected) - }) - }) - - describe('findUserByName', () => { - it('returns user with matching screen_name', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const name = 'Guy' - const expected = { screen_name: 'Guy', id: '1' } - expect(getters.findUserByName(state)(name)).to.eql(expected) - }) - - it('does not return user with matching id', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const id = '1' - expect(getters.findUserByName(state)(id)).to.be.undefined - }) - }) -}) diff --git a/test/unit/specs/services/notification_utils/notification_utils.spec.js b/test/unit/specs/services/notification_utils/notification_utils.spec.js index 20c9379c6..09d39f704 100644 --- a/test/unit/specs/services/notification_utils/notification_utils.spec.js +++ b/test/unit/specs/services/notification_utils/notification_utils.spec.js @@ -1,13 +1,15 @@ import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { useNotificationsStore } from 'src/stores/notifications.js' import { useSyncConfigStore } from 'src/stores/sync_config.js' import * as NotificationUtils from 'src/services/notification_utils/notification_utils.js' describe('NotificationUtils', () => { beforeEach(() => { - const store = useSyncConfigStore(createTestingPinia()) - store.mergedConfig = { + setActivePinia(createTestingPinia()) + useSyncConfigStore().mergedConfig = { notificationVisibility: { likes: true, repeats: true, @@ -16,31 +18,26 @@ describe('NotificationUtils', () => { } }) - describe('filteredNotificationsFromStore', () => { + describe('filteredNotifications', () => { it('should return sorted notifications with configured types', () => { - const store = { - state: { - notifications: { - data: [ - { - id: 1, - action: { id: '1' }, - type: 'like', - }, - { - id: 2, - action: { id: '2' }, - type: 'mention', - }, - { - id: 3, - action: { id: '3' }, - type: 'repeat', - }, - ], - }, + useNotificationsStore().data = [ + { + id: 1, + action: { id: '1' }, + type: 'like', }, - } + { + id: 2, + action: { id: '2' }, + type: 'mention', + }, + { + id: 3, + action: { id: '3' }, + type: 'repeat', + }, + ] + const expected = [ { action: { id: '3' }, @@ -54,7 +51,7 @@ describe('NotificationUtils', () => { }, ] expect( - NotificationUtils.filteredNotificationsFromStore(store, { + NotificationUtils.filteredNotifications({ mentions: false, likes: true, repeats: true, @@ -63,26 +60,21 @@ describe('NotificationUtils', () => { }) }) - describe('unseenNotificationsFromStore', () => { + describe('unseenNotifications', () => { it('should return only notifications not marked as seen', () => { - const store = { - state: { - notifications: { - data: [ - { - action: { id: '1' }, - type: 'like', - seen: false, - }, - { - action: { id: '2' }, - type: 'mention', - seen: true, - }, - ], - }, + useNotificationsStore().data = [ + { + action: { id: '1' }, + type: 'like', + seen: false, }, - } + { + action: { id: '2' }, + type: 'mention', + seen: true, + }, + ] + const expected = [ { action: { id: '1' }, @@ -91,7 +83,7 @@ describe('NotificationUtils', () => { }, ] expect( - NotificationUtils.unseenNotificationsFromStore(store, { + NotificationUtils.unseenNotifications({ likes: true, repeats: true, mentions: false, diff --git a/test/unit/specs/stores/notifications.spec.js b/test/unit/specs/stores/notifications.spec.js new file mode 100644 index 000000000..ad8b6bf44 --- /dev/null +++ b/test/unit/specs/stores/notifications.spec.js @@ -0,0 +1,390 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +import { useI18nStore } from 'src/stores/i18n.js' +import { useNotificationsStore } from 'src/stores/notifications.js' +import { useReportsStore } from 'src/stores/reports.js' +import { useStatusesStore } from 'src/stores/statuses.js' +import { useStreamingStore } from 'src/stores/streaming.js' +import { useUsersStore } from 'src/stores/users.js' + +import * as USER_API from 'src/api/user.js' + +const userId = '1' +const userScreenName = 'user' +const userName = 'Guy' +const userUrl = 'http://localhost/user' + +const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, + pleroma: { + emoji_reactions: [], + }, +}) + +const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +const mockStatus = ({ + id = '1', + text, + summary, + type = 'status', + statusUser = mockUser(), +} = {}) => ({ + id, + user: statusUser, + summary: summary ?? `Summary number ${id}`, + name: 'status', + text: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id: 'c1', + emoji_reactions: [], +}) + +const mockStatusNotification = ({ + id = '1', + type = 'like', + status = mockStatus({ id }), + seen = false, + user = mockUser(), +} = {}) => ({ + type, + id, + status, + seen, + user, + from_profile: user, +}) + +describe('Notifications store', () => { + beforeEach(() => { + vi.useFakeTimers() + setActivePinia(createTestingPinia({ stubActions: false })) + useI18nStore().i18n = { + t: () => { + /* no-op */ + }, + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('activate', () => { + const store = useNotificationsStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.activate() + + expect(sub).to.have.been.called + expect(store.fetcher).to.not.be.null + expect(store.socket).to.not.be.null + + store.deactivate() + }) + + it('deactivate', () => { + const store = useNotificationsStore() + const unsub = vi.fn() + useStreamingStore().removeSubscriber = unsub + + store.activate() + // Checking so that they were set properly before + // since reset changes them to '' + store.maxId = '2' + store.minId = '1' + store.idStore = new Map() + store.idStore.set('1', {}) + store.idStore.set('2', {}) + store.deactivate() + + expect(unsub).to.have.been.called + expect(store.fetcher).to.be.null + expect(store.socket).to.be.null + expect(store.idStore).to.have.length(0) + expect(store).to.have.property('maxId', '') + expect(store).to.have.property('minId', '') + }) + + it('updateExtremes should update min and max ids', () => { + const store = useNotificationsStore() + store.maxId = '10' + store.minId = '05' + + store.updateExtremes('04') + store.updateExtremes('11') + + expect(store).to.have.property('maxId', '11') + expect(store).to.have.property('minId', '04') + }) + + describe('addNewNotifications', () => { + it('adds notifications to the list', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: 'a' }), + mockStatusNotification({ id: 'b' }), + ], + }) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['a', 'b']) + expect(store.idStore).to.have.keys(['a', 'b']) + expect(store).to.have.property('maxId', 'b') + expect(store).to.have.property('minId', 'a') + }) + + it('ignores duplicates', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '2' }), + mockStatusNotification({ id: '1' }), + ], + }) + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '3' }), + mockStatusNotification({ id: '2' }), + ], + }) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['3', '2', '1']) + expect(store.idStore).to.have.keys(['1', '2', '3']) + expect(store).to.have.property('maxId', '3') + expect(store).to.have.property('minId', '1') + }) + + it('appends notifications if fetching older', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '4' }), + mockStatusNotification({ id: '3' }), + ], + }) + + store.addNewNotifications( + { + timestamp: 1, + data: [ + mockStatusNotification({ id: '2' }), + mockStatusNotification({ id: '1' }), + ], + }, + true, + ) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['4', '3', '2', '1']) + expect(store.idStore).to.have.keys(['1', '2', '3', '4']) + expect(store).to.have.property('maxId', '4') + expect(store).to.have.property('minId', '1') + }) + + it('should update usersStore', () => { + const store = useNotificationsStore() + const mock = vi.spyOn(useUsersStore(), 'addNewUsers') + const mockedNotification = mockStatusNotification({ type: 'follow' }) + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.called + expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337) + expect(mock.mock.calls[0][0].data[0]).to.eql( + mockedNotification.from_profile, + ) + }) + + it('should update reportsStore', (notificationType) => { + const store = useNotificationsStore() + const mock = vi.spyOn(useReportsStore(), 'addReport') + const mockedNotification = mockStatusNotification({ + type: 'pleroma:report', + }) + mockedNotification.report = { data: '123' } + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.calledWith({ data: '123' }) + }) + + it.each([ + 'like', + 'mention', + 'status', + 'repeat', + 'pleroma:emoji_reaction', + 'poll', + ])('should update statusesStore on %s notification', (notificationType) => { + const store = useNotificationsStore() + const mock = vi.fn() + useStatusesStore().addNewStatuses = mock + const mockedNotification = mockStatusNotification({ + type: notificationType, + }) + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.called + expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337) + expect(mock.mock.calls[0][0].statuses[0]).to.eql( + mockedNotification.status, + ) + }) + }) + + describe('wipeStatuses', () => { + it('clears all statuses', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ + id: 'n2', + status: mockStatus({ id: 's2' }), + }), + mockStatusNotification({ + id: 'n1', + status: mockStatus({ id: 's1' }), + }), + ], + }) + + store.wipeStatuses(['s2']) + expect(store.idStore).to.not.have.members('n2') + expect(store.data.map(({ id }) => id)).to.eql(['n1']) + }) + }) + + describe('read/dismiss', () => { + it('read single', () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1337, + data: [ + mockStatusNotification({ id: 'n3' }), + mockStatusNotification({ id: 'n2' }), + mockStatusNotification({ id: 'n1' }), + ], + }) + + expect(store.data[1]).to.have.property('seen', false) + + store.markSingleNotificationAsSeen('n2') + + expect(store.data[0]).to.have.property('seen', false) + expect(store.data[1]).to.have.property('seen', true) + expect(store.data[0]).to.have.property('seen', false) + + const calls = mockFetch.mock.calls + expect(calls).to.have.length(1) + const callOne = calls[0] + expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL) + const formData = Object.fromEntries(callOne[1].body.entries()) + expect(formData).to.eql({ id: 'n2' }) + }) + + it('read all', () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useNotificationsStore() + + // FormData is weird to test + store.addNewNotifications({ + timestamp: 1337, + data: [ + mockStatusNotification({ id: 'n3' }), + mockStatusNotification({ id: 'n2' }), + mockStatusNotification({ id: 'n1' }), + ], + }) + + expect(store.data[0]).to.have.property('seen', false) + expect(store.data[1]).to.have.property('seen', false) + expect(store.data[2]).to.have.property('seen', false) + + store.markNotificationsAsSeen() + + expect(store.data[0]).to.have.property('seen', true) + expect(store.data[1]).to.have.property('seen', true) + expect(store.data[2]).to.have.property('seen', true) + + // FormData is weird to test + const calls = mockFetch.mock.calls + expect(calls).to.have.length(1) + const callOne = calls[0] + expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL) + const formData = Object.fromEntries(callOne[1].body.entries()) + expect(formData).to.eql({ max_id: 'n3' }) + }) + }) +}) diff --git a/test/unit/specs/stores/statuses.spec.js b/test/unit/specs/stores/statuses.spec.js new file mode 100644 index 000000000..f4064ec9d --- /dev/null +++ b/test/unit/specs/stores/statuses.spec.js @@ -0,0 +1,660 @@ +import { createTestingPinia } from '@pinia/testing' +import { snakeCase } from 'lodash' +import { setActivePinia } from 'pinia' + +import { useStatusesStore } from 'src/stores/statuses.js' +import { useStreamingStore } from 'src/stores/streaming.js' +import { useUsersStore } from 'src/stores/users.js' + +import * as PUBLIC_API from 'src/api/public.js' +import * as USER_API from 'src/api/user.js' + +const userId = '1' +const userScreenName = 'user' +const userName = 'Guy' +const userUrl = 'http://localhost/user' + +const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, + pleroma: { + emoji_reactions: [], + }, +}) + +const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +const mockStatus = ({ + id = '1', + text, + type = 'status', + statusUser = mockUser(), +} = {}) => ({ + id, + user: statusUser, + name: 'status', + text: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id: 'c1', + emoji_reactions: [], +}) + +const mockMastoAPIStatus = ({ + id = '1', + text, + type = 'status', + statusUser = mockMastoAPIUser(), +} = {}) => ({ + id, + account: statusUser, + name: 'status', + content: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id: 'c1', +}) + +const DEFAULT_OPTIONS = (method = 'GET') => ({ + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}) + +describe('Statuses store', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('init', () => { + const store = useStatusesStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.attachSocket() + + expect(store.socket).to.not.be.null + expect(sub).to.have.been.called + }) + + it('resetStatuses', () => { + const store = useStatusesStore() + const statuses = [...new Array(20)].map((empty, index) => + mockStatus({ + id: 's' + index, + statusUser: mockUser({ id: 'u' + index }), + }), + ) + + store.attachSocket() + store.addNewStatuses({ + statuses, + timestamp: 1, + }) + store.resetStatuses() + expect(store.allStatuses).to.have.length(0) + }) + + describe('addNewStatuses', () => { + beforeEach(() => { + const usersStore = useUsersStore() + usersStore.addNewUsers = vi.fn().mockReturnValue([mockUser()]) + }) + + it('adds the status to allStatuses', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + expect(store.allStatuses).to.eql(new Map([['1', status]])) + expect(store.conversations).to.eql(new Map([['c1', new Set(['1'])]])) + expect(usersStore.addNewUsers).to.have.callCount(1) + }) + + it('splits retweets from their status and links them', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const retweet = mockStatus({ + id: '2', + type: 'retweet', + user: mockUser({ id: '2' }), + }) + retweet.type = 'retweet' + retweet.retweeted_status = status + + store.addNewStatuses({ + statuses: [retweet], + timestamp: 1, + }) + + expect(store.allStatuses).to.eql( + new Map([ + ['1', status], + ['2', retweet], + ]), + ) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('splits quotes from their status and links them', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const quote = mockStatus({ id: '2' }) + quote.quote = status + + store.addNewStatuses({ + statuses: [quote], + timestamp: 1, + }) + + expect(store.allStatuses).to.eql( + new Map([ + ['1', status], + ['2', quote], + ]), + ) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('replaces existing statuses with the same id', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ id: '1', text: 'something else' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 1991, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 2000, + }) + expect(store.allStatuses).to.eql(new Map([['1', modStatus]])) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('handles edits', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ + id: '1', + text: 'something else', + type: 'edit', + }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 1991, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 2000, + }) + expect(store.allStatuses).to.eql(new Map([['1', modStatus]])) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('ignores updates with older timestamp', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ id: '1', text: 'something else' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 2000, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 1991, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('calls useUsersStore().addNewUsers() even on older timestamp', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ id: '1', text: 'something else' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 2000, + }) + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 1991, + }) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + }) + + describe('fetchers', () => { + it.each([ + [ + 'StatusSource', + { + content_type: 'text/plain', + text: 'Text', + spoiler_text: 'Text', + }, + ], + [ + 'EmojiReactions', + [ + { + accounts: [ + mockMastoAPIUser({ id: 'u1' }), + mockMastoAPIUser({ id: 'u2' }), + ], + count: 1, + me: false, + name: 'cofe', + url: '', + }, + ], + ], + [ + 'Favs', + [mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })], + ], + [ + 'Repeats', + [mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })], + ], + ])('fetch%s', async (group, mockedResponse) => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockedResponse), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + const addNewUsers = vi.spyOn(useUsersStore(), 'addNewUsers') + + let urlKey + let prefix = 'MASTODON' + if (group === 'Favs') { + urlKey = 'STATUS_FAVORITEDBY' + } else if (group === 'Repeats') { + urlKey = 'STATUS_REBLOGGEDBY' + } else { + urlKey = snakeCase(group).toUpperCase() + } + if (group === 'EmojiReactions') { + prefix = 'PLEROMA' + } + + const url = PUBLIC_API[`${prefix}_${urlKey}_URL`]('id') + + const store = useStatusesStore() + const status = mockStatus({ id: 'id' }) + store.addNewStatuses({ + statuses: [status], + timestamp: 2000, + }) + + const result = await store[`fetch${group}`]('id') + const updated = store.allStatuses.get('id') + + // Fetch called + expect(mockFetch).to.have.been.calledWith(url, DEFAULT_OPTIONS()) + + // Users updated + if (group !== 'StatusSource') { + // first call is the one for the status + expect(addNewUsers).to.have.been.calledTwice + const secondCallData = addNewUsers.mock.calls[1][0].data + expect(secondCallData).to.have.length(2) + expect(secondCallData[0]).to.have.property('id', 'u1') + expect(secondCallData[1]).to.have.property('id', 'u2') + } + + if (group === 'Favs') { + expect(store.favs).to.have.length(1) + expect(store.favs.get('id')).to.have.length(2) + expect(store.favs.get('id')).to.eql(new Set(['u1', 'u2'])) + expect(updated.fave_num).to.eql(2) + } else if (group === 'Repeats') { + expect(store.repeats).to.have.length(1) + expect(store.repeats.get('id')).to.have.length(2) + expect(store.repeats.get('id')).to.eql(new Set(['u1', 'u2'])) + expect(updated.repeat_num).to.eql(2) + } else if (group === 'EmojiReactions') { + expect(updated.emoji_reactions).to.have.length(mockedResponse.length) + expect(updated.emoji_reactions[0].name).to.eql(mockedResponse[0].name) + } else { + expect(result).to.eql(mockedResponse) + } + }) + + it('fetchFavsAndRepeats', async () => { + const store = useStatusesStore() + store.fetchFavs = vi.fn().mockResolvedValue(async () => { + /* no-op */ + }) + store.fetchRepeats = vi.fn().mockResolvedValue(async () => { + /* no-op */ + }) + + await store.fetchFavsAndRepeats('id') + expect(store.fetchFavs).to.have.been.calledWith('id') + expect(store.fetchRepeats).to.have.been.calledWith('id') + }) + + it('fetchStatus', async () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus()), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useStatusesStore() + await store.fetchStatus('id') + + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API.MASTODON_STATUS_URL('id'), + DEFAULT_OPTIONS(), + ) + expect(store.allStatuses).to.have.length(1) + }) + }) + + describe('interactions', () => { + it.each([ + ['favorite', 'MASTODON_FAVORITE_URL'], + ['unfavorite', 'MASTODON_UNFAVORITE_URL'], + ['retweet', 'MASTODON_RETWEET_URL'], + ['unretweet', 'MASTODON_UNRETWEET_URL'], + ['reactWithEmoji', 'PLEROMA_EMOJI_REACT_URL', 'PUT'], + ['unreactWithEmoji', 'PLEROMA_EMOJI_UNREACT_URL', 'DELETE'], + [ + 'bookmark', + 'MASTODON_BOOKMARK_STATUS_URL', + undefined, + { folder_id: 'argument' }, + ], + ['unbookmark', 'MASTODON_UNBOOKMARK_STATUS_URL'], + ['pinStatus', 'MASTODON_PIN_OWN_STATUS_URL'], + ['unpinStatus', 'MASTODON_UNPIN_OWN_STATUS_URL'], + ['muteConversation', 'MASTODON_MUTE_CONVERSATION_URL'], + ['unmuteConversation', 'MASTODON_UNMUTE_CONVERSATION_URL'], + ['deleteStatus', 'MASTODON_DELETE_URL', 'DELETE'], + ])('%s - api call', async (interaction, urlKey, method = 'POST', body) => { + const url = USER_API[urlKey]('1', 'argument') + + const status = mockStatus() + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + await store[interaction]('1', 'argument') + const expectedArg = DEFAULT_OPTIONS(method) + + if (body) { + expectedArg.body = JSON.stringify(body) + } + + expect(mockFetch).to.have.been.calledWith(url, expectedArg) + if (interaction === 'deleteStatus') { + expect(store.allStatuses.get('1').deleted).to.be.true + } else { + expect(store.allStatuses.get('1').text).to.eql('Updated') + } + }) + + const optimismInteractions = [ + ['favorite', 'favorited', 'fave_num'], + ['retweet', 'repeated', 'repeat_num'], + ['bookmark', 'bookmarked'], + ['muteConversation', 'thread_muted'], + ] + .map(([method, property, count]) => [ + [method, property, count], + ['un' + method, property, count], + ]) + .flat() + + it.each( + optimismInteractions, + )('%s - optimism call', async (method, property, count) => { + // Prepare our status + const status = mockStatus() + const negate = method.startsWith('un') + const oldCount = 9 + const newCount = negate ? 8 : 10 + status[property] = negate + if (count) { + status[count] = oldCount + } + if (method === 'unbookmark') { + status.bookmark_folder_id = 'argument' + } + + // Insert it + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + expect(store.allStatuses.get('1')).to.have.property(property, negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, oldCount) + } + vi.stubGlobal('fetch', mockFetch) + + store[method]('1', 'argument') + expect(store.allStatuses.get('1')).to.have.property(property, !negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, newCount) + } + if (property === 'bookmarked') { + expect(store.allStatuses.get('1')).to.have.property( + 'bookmark_folder_id', + 'argument', + ) + } + }) + + it.each( + optimismInteractions, + )('%s - optimism fail', async (method, property, count) => { + // Prepare our status + const status = mockStatus() + const negate = method.startsWith('un') + const oldCount = 9 + status[property] = negate + if (count) { + status[count] = oldCount + } + if (method === 'unbookmark') { + status.bookmark_folder_id = 'argument' + } + + // Insert it + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockRejectedValueOnce(new Error('Failure!')) + + expect(store.allStatuses.get('1')).to.have.property(property, negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, oldCount) + } + vi.stubGlobal('fetch', mockFetch) + + await store[method]('1', 'argument') + expect(store.allStatuses.get('1')).to.have.property(property, negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, oldCount) + } + + // Failing 'bookmark' method SHOULD clear folder id + if (method === 'unbookmark') { + expect(store.allStatuses.get('1')).to.have.property( + 'bookmark_folder_id', + 'argument', + ) + } + }) + + it.each([ + ['reactWithEmoji', 0], + ['unreactWithEmoji', 1], + ['reactWithEmoji', 1], + ['unreactWithEmoji', 2], + ])('%s count: %s - optimism call', async (method, oldCount) => { + // Prepare our status + const status = mockStatus() + const negate = method.startsWith('un') + const newCount = negate ? oldCount - 1 : oldCount + 1 + + useUsersStore().currentUser = mockUser() + + if (oldCount > 0) { + const reactors = [...new Array(oldCount)].map((empty, index) => + mockUser({ id: 'o' + index }), + ) + if (negate) { + // Replace one of reactors with ourselves + reactors[0] = useUsersStore().currentUser + } + status.emoji_reactions = [ + { + name: 'hyperlol', + count: oldCount, + accounts: reactors, + }, + ] + } else { + status.emoji_reactions = [] + } + + // Insert it + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + const expected = store.allStatuses.get('1') + + expect(expected.emoji_reactions).to.have.length(oldCount === 0 ? 0 : 1) + if (oldCount !== 0) { + expect(expected.emoji_reactions[0].name).to.eql('hyperlol') + expect(expected.emoji_reactions[0].count).to.eql(oldCount) + expect(expected.emoji_reactions[0].accounts).to.have.length(oldCount) + } + + store[method]('1', 'hyperlol') + + expect(expected.emoji_reactions).to.have.length(newCount === 0 ? 0 : 1) + if (newCount !== 0) { + expect(expected.emoji_reactions[0].name).to.eql('hyperlol') + expect(expected.emoji_reactions[0].count).to.eql(newCount) + expect(expected.emoji_reactions[0].accounts).to.have.length(newCount) + } + }) + }) + + it('wipeUserStatuses', () => { + const store = useStatusesStore() + const statuses = [...new Array(20)].map((empty, index) => + mockStatus({ + id: 's' + index, + statusUser: mockUser({ id: 'u' + index }), + }), + ) + + store.addNewStatuses({ + statuses, + timestamp: 1, + }) + + const result = store.wipeUserStatuses('u19') + + expect(store.allStatuses).to.have.length(19) + expect(result).to.eql(new Set(['s19'])) + }) +}) diff --git a/test/unit/specs/stores/timelines.spec.js b/test/unit/specs/stores/timelines.spec.js new file mode 100644 index 000000000..2b4704052 --- /dev/null +++ b/test/unit/specs/stores/timelines.spec.js @@ -0,0 +1,487 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +import { useStreamingStore } from 'src/stores/streaming.js' +import { useTimelinesStore } from 'src/stores/timelines.js' + +describe('Timelines store', () => { + beforeEach(() => { + vi.useFakeTimers() + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + afterEach(() => { + useTimelinesStore().deactivateAll() + vi.useRealTimers() + }) + + describe('activate', () => { + it('streamable', () => { + const store = useTimelinesStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.activate('friends', undefined, true) + + expect(sub).to.have.been.called + expect(store.friends.fetcher).to.not.be.null + expect(store.friends.socket).to.not.be.null + }) + + it('non-streamable', () => { + const store = useTimelinesStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.activate('user', '1') + + expect(sub).to.not.have.been.called + expect(store.user.fetcher).to.not.be.null + expect(store.user.socket).to.be.null + }) + }) + + describe('deactivate', () => { + it('streamable', () => { + const store = useTimelinesStore() + const unsub = vi.fn() + useStreamingStore().removeSubscriber = unsub + useStreamingStore().addSubscriber = vi.fn() + + store.activate('friends', undefined, true) + // Checking so that they were set properly before + // since reset changes them to '' + store.friends.maxId = '3' + store.friends.minId = '4' + store.friends.statusIds = new Set(['3', '4']) + store.friends.visibleStausIds = new Set(['3', '4']) + store.deactivate('friends', true) + + expect(unsub).to.have.been.called + expect(store.friends.fetcher).to.be.null + expect(store.friends.socket).to.be.null + expect(store.friends.statusIds).to.have.length(0) + expect(store.friends.visibleStatusIds).to.have.length(0) + expect(store.friends).to.have.property('maxId', '') + expect(store.friends).to.have.property('minId', '') + }) + + it('non-streamable', () => { + const store = useTimelinesStore() + const unsub = vi.fn() + useStreamingStore().removeSubscriber = unsub + useStreamingStore().addSubscriber = vi.fn() + + store.activate('user', '1') + // Checking so that they were set properly before + // since reset changes them to '' + store.user.maxId = '3' + store.user.minId = '4' + store.user.statusIds = new Set(['3', '4']) + store.user.visibleStausIds = new Set(['3', '4']) + store.deactivate('user') + + expect(unsub).to.not.have.been.called + expect(store.user.fetcher).to.be.null + expect(store.user.socket).to.be.null + expect(store.user.statusIds).to.have.length(0) + expect(store.user.visibleStatusIds).to.have.length(0) + expect(store.user).to.have.property('maxId', '') + expect(store.user).to.have.property('minId', '') + }) + }) + + describe('updateTimelineExtremes', () => { + it('should derive extremes from data', () => { + const store = useTimelinesStore() + const timeline = useTimelinesStore().friends + timeline.order = ['4', '1', '3', '2'] + timeline.statusesIds = new Set(timeline.order) + store.updateTimelineExtremes(timeline) + + expect(store.friends).to.have.property('maxId', '4') + expect(store.friends).to.have.property('minId', '2') + }) + + it('should use extremes from pagination', () => { + const store = useTimelinesStore() + const timeline = useTimelinesStore().friends + store.updateTimelineExtremes(timeline, { maxId: '1', minId: '2' }) + + // Min and max are swapped! + expect(store.friends).to.have.property('maxId', '2') + expect(store.friends).to.have.property('minId', '1') + }) + }) + + describe('addStatusesToTimeline', () => { + it('adds the status to the given timeline', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(3) + expect(store.list).to.have.property('maxId', '1') + expect(store.list).to.have.property('minId', '3') + }) + + it('ignores duplicates', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(3) + expect(store.list).to.have.property('maxId', '1') + expect(store.list).to.have.property('minId', '3') + }) + + it('adds the status the given timeline, directly visible', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + showImmediately: true, + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set(statuses)) + expect(store.list.newStatusCount).to.equal(0) + expect(store.list).to.have.property('maxId', '1') + expect(store.list).to.have.property('minId', '3') + }) + + it('does not update the maxId when the noIdUpdate flag is set', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + noIdUpdate: true, + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(3) + expect(store.list).to.have.property('maxId', '') + expect(store.list).to.have.property('minId', '') + }) + + it('does not update timeline if it belongs to a different arugment', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '2', { + statuses, + repeats: [], + noIdUpdate: true, + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql([]) + expect(store.list.statusIds).to.eql(new Set()) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(0) + expect(store.list).to.have.property('maxId', '') + expect(store.list).to.have.property('minId', '') + }) + + it('prepends timeline with new statuses', () => { + const store = useTimelinesStore() + const statuses1 = ['3', '2', '1'] + const statuses2 = ['6', '5', '4'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [], + pagination: { minId: '3', maxId: '1' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [], + pagination: { minId: '6', maxId: '4' }, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(6) + expect(store.list).to.have.property('maxId', '6') + expect(store.list).to.have.property('minId', '1') + }) + + it('appends timeline with new statuses if fetching older', () => { + const store = useTimelinesStore() + const statuses1 = ['6', '5', '4'] + const statuses2 = ['3', '2', '1'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [], + pagination: { minId: '6', maxId: '4' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [], + pagination: { minId: '3', maxId: '1' }, + older: true, + }) + + const newOrder = [...statuses1, ...statuses2] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(6) + expect(store.list).to.have.property('maxId', '6') + expect(store.list).to.have.property('minId', '1') + }) + + describe('repeat de-duplication', () => { + it('handles repeat de-duplication (older)', () => { + const store = useTimelinesStore() + const statuses1 = ['s7s3', 's6s3', 's5', 's4s0', 's3', 's2'] + const statuses2 = ['s1s0', 's0'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s7s3', 's2'], + ['s6s3', 's2'], + ['s4s0', 's0'], + ], + pagination: { minId: 's7s3', maxId: 's2' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s5', 's4s0', 's3', 's2']), + ) + expect(store.list).to.have.property('maxId', 's7s3') + expect(store.list).to.have.property('minId', 's2') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [['s1s0', 's0']], + pagination: { minId: 's1', maxId: 's0' }, + showImmediately: true, + older: true, + }) + + const newOrder = [...statuses1, ...statuses2] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s5', 's4s0', 's3', 's2', 's0']), + ) + expect(store.list).to.have.property('maxId', 's7s3') + expect(store.list).to.have.property('minId', 's0') + }) + + it('handles repeat de-duplication(reverse)', () => { + const store = useTimelinesStore() + const statuses1 = ['s6s2', 's5s2', 's4', 's3s0', 's2', 's1'] + const statuses2 = ['sAs0', 's9s1', 's8', 's7s2'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s6s2', 's2'], + ['s5s2', 's2'], + ['s3s0', 's0'], + ], + pagination: { minId: 's6s2', maxId: 's1' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + expect(store.list).to.have.property('maxId', 's6s2') + expect(store.list).to.have.property('minId', 's1') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [ + ['sAs0', 's0'], + ['s9s1', 's1'], + ['s7s2', 's2'], + ], + pagination: { minId: 'sAs0', maxId: 's7s2' }, + showImmediately: true, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql( + new Set([ + 's4', + 's3s0', + 's2', + 's1', + // Newer + 's8', + ]), + ) + expect(store.list).to.have.property('maxId', 'sAs0') + expect(store.list).to.have.property('minId', 's1') + }) + + it('showNewStatuses follows de-duplication rules', () => { + const store = useTimelinesStore() + const statuses1 = ['s6s2', 's5s2', 's4', 's3s0', 's2', 's1'] + const statuses2 = ['sAs0', 's9s1', 's8', 's7s2'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s6s2', 's2'], + ['s5s2', 's2'], + ['s3s0', 's0'], + ], + pagination: { minId: 's6s2', maxId: 's1' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + expect(store.list).to.have.property('maxId', 's6s2') + expect(store.list).to.have.property('minId', 's1') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [ + ['sAs0', 's0'], + ['s9s1', 's1'], + ['s7s2', 's2'], + ], + pagination: { minId: 'sAs0', maxId: 's7s2' }, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.newStatusCount).to.eql(1) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + + store.showNewStatuses('list') + expect(store.list.visibleStatusIds).to.eql( + new Set([ + 's4', + 's3s0', + 's2', + 's1', + // Newer + 's8', + ]), + ) + }) + }) + }) + + describe('showNewStatuses', () => { + it('resets counter and makes all ids visible', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('public') + store.addStatusesToTimeline('public', undefined, { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.public.statusIds).to.eql(new Set(statuses)) + expect(store.public.visibleStatusIds).to.eql(new Set()) + expect(store.public.newStatusCount).to.equal(3) + store.showNewStatuses('public') + expect(store.public.visibleStatusIds).to.eql(new Set(statuses)) + expect(store.public.newStatusCount).to.equal(0) + }) + + it('limits shown items to 50 for better performance', () => { + const store = useTimelinesStore() + const statuses = new Array(100).fill().map((_, index) => 's' + index) + + store.activate('public') + store.addStatusesToTimeline('public', undefined, { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.public.statusIds).to.eql(new Set(statuses)) + expect(store.public.visibleStatusIds).to.eql(new Set()) + expect(store.public.newStatusCount).to.equal(100) + store.showNewStatuses('public') + expect(store.public.visibleStatusIds).to.eql( + new Set(statuses.slice(0, 50)), + ) + expect(store.public.newStatusCount).to.equal(0) + }) + }) + + describe('wipeStatuses', () => { + it('clears all statuses', () => { + const store = useTimelinesStore() + + store.activate('friends') + store.activate('public') + store.addStatusesToTimeline('public', undefined, { + statuses: ['1', '2', '3', '0'], + repeats: [], + }) + store.addStatusesToTimeline('friends', undefined, { + statuses: ['5', '0', '9', '1'], + repeats: [], + }) + store.wipeStatuses(['0']) + expect(store.friends.statusIds).to.not.have.members('0') + expect(store.public.statusIds).to.not.have.members('0') + }) + }) +}) diff --git a/test/unit/specs/stores/user_highlight.spec.js b/test/unit/specs/stores/user_highlight.spec.js index e97f8f382..865544035 100644 --- a/test/unit/specs/stores/user_highlight.spec.js +++ b/test/unit/specs/stores/user_highlight.spec.js @@ -9,15 +9,6 @@ import { describe('The UserHighlight store', () => { beforeEach(() => { setActivePinia(createPinia()) - window.vuex = { - state: { - users: { - currentUser: { - fqn: 'foo@bar.tld', - }, - }, - }, - } }) describe('mutations', () => { diff --git a/test/unit/specs/stores/users.spec.js b/test/unit/specs/stores/users.spec.js new file mode 100644 index 000000000..c44c749f3 --- /dev/null +++ b/test/unit/specs/stores/users.spec.js @@ -0,0 +1,1356 @@ +import { createTestingPinia } from '@pinia/testing' +import { snakeCase } from 'lodash' +import { setActivePinia } from 'pinia' + +import { useAnnouncementsStore } from 'src/stores/announcements.js' +import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js' +import { useChatsStore } from 'src/stores/chats.js' +import { useEmojiStore } from 'src/stores/emoji.js' +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useInterfaceStore } from 'src/stores/interface.js' +import { useListsStore } from 'src/stores/lists.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useNotificationsStore } from 'src/stores/notifications.js' +import { useOAuthStore } from 'src/stores/oauth.js' +import { useStatusesStore } from 'src/stores/statuses.js' +import { useStreamingStore } from 'src/stores/streaming.js' +import { useSyncConfigStore } from 'src/stores/sync_config.js' +import { useTimelinesStore } from 'src/stores/timelines.js' +import { useUserHighlightStore } from 'src/stores/user_highlight.js' +import { useUsersStore } from 'src/stores/users.js' + +import * as PUBLIC_API from 'src/api/public.js' +import * as USER_API from 'src/api/user.js' + +const DEFAULT_OPTIONS = (method = 'POST') => ({ + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}) + +const actionKeys = (action) => { + const result = {} + if (action === 'removeUserFromFollowers') { + result.storeAction = action + } else { + result.storeAction = action + 'User' + } + + result.apiUrl = `MASTODON_${snakeCase(result.storeAction).toUpperCase()}_URL` + return result +} + +const userId = '1' +const userScreenName = 'user' +const userName = 'Guy' +const userUrl = 'http://localhost/user' + +const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, +}) + +const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +describe('Users store', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + describe('addNewUsers', () => { + describe('users', () => { + it('adds new users to the set, merging in new information for old users', () => { + const store = useUsersStore() + + const modUser = mockUser({ name: 'Dude' }) + + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + + store.addNewUsers({ data: [modUser], timestamp: 2 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Dude') + }) + + it('ignores new users if timestamp is older', () => { + const store = useUsersStore() + + const modUser = mockUser({ name: 'Old guy' }) + + store.addNewUsers({ data: [mockUser()], timestamp: 2000 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Guy') + + store.addNewUsers({ data: [modUser], timestamp: 1991 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Guy') + }) + + it('merging array field in new information for old users', () => { + const store = useUsersStore() + + const userFields = { + ...mockUser(), + fields: [{ name: 'Label 1', value: 'Content 1' }], + } + const firstModUser = { + ...mockUser(), + fields: [ + { name: 'Label 2', value: 'Content 2' }, + { name: 'Label 3', value: 'Content 3' }, + ], + } + const secondModUser = { + ...mockUser(), + fields: [{ name: 'Label 4', value: 'Content 4' }], + } + + store.addNewUsers({ data: [userFields], timestamp: 1 }) + const reactive = store.users.get(userId) + expect(reactive.fields).to.have.length(1) + expect(reactive.fields[0].name).to.eql('Label 1') + + store.addNewUsers({ data: [firstModUser], timestamp: 2 }) + expect(reactive.fields).to.have.length(2) + expect(reactive.fields[0].name).to.eql('Label 2') + expect(reactive.fields[1].name).to.eql('Label 3') + + store.addNewUsers({ data: [secondModUser], timestamp: 3 }) + expect(reactive.fields).to.have.length(1) + expect(reactive.fields[0].name).to.eql('Label 4') + }) + }) + + describe('relationships', () => { + it('updates relationship information if present', () => { + const store = useUsersStore() + + const modUser = { + ...mockUser(), + relationship: { + id: userId, + following: true, + }, + } + + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + store.addNewUsers({ data: [modUser], timestamp: 2 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it('updates relationship information if present even if user timestamp is older', () => { + const store = useUsersStore() + + const modUser = { + ...mockUser({ name: 'Old Dude' }), + relationship: { + id: userId, + following: true, + }, + } + + store.addNewUsers({ data: [mockUser()], timestamp: 2000 }) + store.addNewUsers({ data: [modUser], timestamp: 1991 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it("doesn't erase relationship information if new data has it missing", () => { + const store = useUsersStore() + + const modUser = mockUser({ name: 'Dude' }) + + store.addNewUsers({ + data: [ + { ...mockUser(), relationship: { id: userId, following: true } }, + ], + timestamp: 1, + }) + store.addNewUsers({ data: [modUser], timestamp: 2 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + }) + }) + + describe('updateUserRelationships', () => { + it('updates existing user relationship', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + store.updateUserRelationships({ data: relationship, timestamp: 2 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it('stores relationship for missing user', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.updateUserRelationships({ data: relationship, timestamp: 1 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + }) + + it('assigns relationship for missing user when it becomes available', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.updateUserRelationships({ data: relationship, timestamp: 1 }) + store.addNewUsers({ data: [mockUser()], timestamp: 2 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + expect(store.findUser(userId).relationship).to.eql({ + id: userId, + following: true, + }) + }) + + it('ignores relationship update if timestamp is older than existing', () => { + const store = useUsersStore() + const oldRelationship = { id: userId, following: true } + const newRelationship = { id: userId, following: false } + + store.addNewUsers({ + data: [{ ...mockUser(), relationship: newRelationship }], + timestamp: 2000, + }) + store.updateUserRelationships({ data: oldRelationship, timestamp: 1991 }) + + expect(store.relationship(userId)).to.eql({ + id: userId, + following: false, + }) + expect(store.findUser(userId).relationship.following).to.eql(false) + }) + }) + + describe('fetchers', () => { + describe('fetchUserIfMissing', () => { + it('should fetch requested user, add it to store and return it', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const expected = mockUser() + const store = useUsersStore() + const resultUser = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should re-use existing promise for other fetches', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: '1' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + // fetch by name yields user id which we request next + .mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockThrowOnce(new Error("Shouldn't be called more than once")), + ) + + const expected = mockUser() + const store = useUsersStore() + const resultUser1 = await store.fetchUserIfMissing({ name: 'user' }) + const resultUser2 = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser1).to.deep.include(expected) + expect(resultUser2).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should use cached data if present', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockThrowOnce(new Error("Shouldn't be called at all")), + ) + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + const resultUser1 = await store.fetchUserIfMissing({ id: '1' }) + const resultUser2 = await store.fetchUserIfMissing({ name: 'user' }) + const expected = mockUser() + + expect(resultUser1).to.deep.include(expected) + expect(resultUser2).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should handle 404 gracefully', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + const store = useUsersStore() + + const resultUser = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser).to.be.null + }) + + it('Should throw if no identifier is provided', async () => { + const store = useUsersStore() + + await expect( + async () => await store.fetchUserIfMissing({}), + ).rejects.to.throw(TypeError) + await expect( + async () => await store.fetchUserIfMissing(), + ).rejects.to.throw(TypeError) + }) + }) + + describe('relationships', () => { + it.each(['Friends', 'Followers'])('fetch%s', async (group) => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'snake', + name: 'John', + id: '2', + }), + mockMastoAPIUser({ + screen_name: 'zero', + name: 'David Oh', + id: '3', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'sigint', + name: 'Mr.Anderson', + id: '4', + }), + mockMastoAPIUser({ + screen_name: 'paramedic', + name: 'Dr.Clark', + id: '5', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: mockUser() }) + + const urlGroup = group === 'Friends' ? 'Following' : group + const us = store.users.get(userId) + + await store[`fetch${group}`](userId) + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, { + limit: 20, + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect( + store.relationshipsLists[group.toLowerCase()].get(us), + ).to.have.length(2) + + await store[`fetch${group}`](userId) + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, { + maxId: '3', + limit: 20, + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect( + store.relationshipsLists[group.toLowerCase()].get(us), + ).to.have.length(4) + }) + + it.each(['Mutes', 'Blocks'])('fetch%s', async (group) => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'snake', + name: 'John', + id: '2', + }), + mockMastoAPIUser({ + screen_name: 'zero', + name: 'David Oh', + id: '3', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'sigint', + name: 'Mr.Anderson', + id: '4', + }), + mockMastoAPIUser({ + screen_name: 'paramedic', + name: 'Dr.Clark', + id: '5', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: mockUser() }) + + const us = store.users.get(userId) + + store.currentUser = us + const ids = group === 'Mutes' ? 'muteIds' : 'blockIds' + + await store[`fetch${group}`]({ reset: true }) + expect(mockFetch).to.have.been.calledWith( + USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({ + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(us[ids]).to.have.length(2) + + await store[`fetch${group}`]() + expect(mockFetch).to.have.been.calledWith( + USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({ + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(us[ids]).to.have.length(4) + }) + + it('fetchDomainMutes', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(['example.com', 'example.org']), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: mockUser() }) + + const us = store.users.get(userId) + store.currentUser = us + + await store.fetchDomainMutes() + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_DOMAIN_BLOCKS_URL, + DEFAULT_OPTIONS('GET'), + ) + + expect(us.domainMutes).to.have.eql( + new Set(['example.com', 'example.org']), + ) + }) + + it('fetchInLists', async () => { + const inLists = [ + { exclusive: false, id: '1', title: 'Operatives' }, + { exclusive: true, id: '2', title: 'Agents' }, + ] + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(inLists), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ + timestamp: 1, + data: [ + mockUser(), + { ...mockUser({ name: 'John', screen_name: 'snake', id: '2' }) }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero', id: '3' }) }, + ], + }) + + const us = store.users.get(userId) + store.currentUser = us + + await store.fetchUserInLists('2') + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_USER_IN_LISTS('2'), + DEFAULT_OPTIONS('GET'), + ) + + expect(store.users.get('2').inLists).to.have.eql(inLists) + }) + }) + }) + + describe('misc updates', () => { + it('updateUserAdminData', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + const adminData = { is_active: true, tags: ['one', 'two'] } + store.updateUserAdminData(userId, adminData) + + const userData = store.users.get(userId) + expect(userData.deactivated).to.eql(false) + expect(userData.tags).to.eql(new Set(['one', 'two'])) + }) + + it('updateRight', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + store.updateRight(userId, 'right1', true) + store.updateRight(userId, 'right2', false) + + const userData = store.users.get(userId) + expect(userData.rights.right1).to.eql(true) + expect(userData.rights.right2).to.eql(false) + }) + + it('clearFollowLists', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + const userData = store.users.get(userId) + store.relationshipsLists.friends.get(userData).add('2') + store.relationshipsLists.friends.get(userData).add('3') + store.relationshipsLists.followers.get(userData).add('4') + store.relationshipsLists.followers.get(userData).add('5') + store.clearFollowLists(userId) + + expect(store.relationshipsLists.friends.get(userData)).to.have.length(0) + expect(store.relationshipsLists.followers.get(userData)).to.have.length(0) + }) + }) + + describe('login/logout', () => { + describe('login', () => { + it('normal login', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const spies = [ + // Misc initialization + vi.spyOn(useSyncConfigStore(), 'initSyncConfig'), + vi.spyOn(useUserHighlightStore(), 'initUserHighlight'), + vi.spyOn(useInterfaceStore(), 'applyTheme'), + vi.spyOn(useInterfaceStore(), 'onLogin'), + vi.spyOn(useEmojiStore(), 'fetchEmoji'), + + // Timeline / Notifications + vi.spyOn(useNotificationsStore(), 'activate'), + vi.spyOn(useTimelinesStore(), 'activatePersistents'), + + // Fetchers + vi.spyOn(useChatsStore(), 'startFetching'), + vi.spyOn(useListsStore(), 'startFetching'), + vi.spyOn(useAnnouncementsStore(), 'startFetching'), + vi.spyOn(useBookmarkFoldersStore(), 'startFetching'), + vi.spyOn(useStreamingStore(), 'initSocket'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + mockUser(), + { ...mockUser({ name: 'John', screen_name: 'snake' }) }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + + expect(store.loggedIn).to.eql(false) + await store.loginUser('ACCESS_TOKEN') + expect(store.loggedIn).to.eql(true) + + // We should be in the store ourselves + expect(store.users).to.have.length(1) + expect(store.usersByName).to.have.length(1) + expect(store.usersByURL).to.have.length(1) + expect(store.relationships).to.have.length(0) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + + it('bad credentials', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + status: 403, + statusText: 'Forbidden', + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const spies = [vi.spyOn(useOAuthStore(), 'clearToken')] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + const store = useUsersStore() + const exec = async () => { + await store.loginUser('ACCESS_TOKEN') + } + + await expect(exec).rejects.to.throw(Error) + + expect(store.loggedIn).to.eql(false) + + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + }) + + describe('logout', () => { + it('normal logout', async () => { + const revokeApi = vi + .fn() + .mockResolvedValueOnce( + // Ensure APP + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + // Revoke Token + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', revokeApi) + + const spies = [ + // Misc initialization + /* 0 */ vi.spyOn(useStatusesStore(), 'resetStatuses'), + /* 1 */ vi.spyOn(useInterfaceStore(), 'onLogout'), + + // Timeline / Notifications + /* 2 */ vi.spyOn(useNotificationsStore(), 'deactivate'), + /* 3 */ vi.spyOn(useNotificationsStore(), 'pause'), + /* 4 */ vi.spyOn(useNotificationsStore(), 'resume'), + /* 5 */ vi.spyOn(useTimelinesStore(), 'deactivateAll'), + /* 6 */ vi.spyOn(useTimelinesStore(), 'pauseAll'), + /* 7 */ vi.spyOn(useTimelinesStore(), 'resumeAll'), + + // Fetchers + /* 8 */ vi.spyOn(useChatsStore(), 'resetChats'), + /* 9 */ vi.spyOn(useChatsStore(), 'stopFetching'), + /* 10 */ vi.spyOn(useListsStore(), 'stopFetching'), + /* 11 */ vi.spyOn(useAnnouncementsStore(), 'stopFetching'), + /* 12 */ vi.spyOn(useBookmarkFoldersStore(), 'stopFetching'), + /* 13 */ vi.spyOn(useStreamingStore(), 'stopSocket'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + store.currentUser = mockUser() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + mockUser(), + { + ...mockUser({ name: 'John', screen_name: 'snake' }), + relationship: { id: userId, following: true }, + }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + expect(store.loggedIn).to.eql(true) + await store.logout() + expect(store.loggedIn).to.eql(false) + expect(revokeApi).to.have.been.called + expect(store.users).to.have.length(0) + expect(store.usersByName).to.have.length(0) + expect(store.usersByURL).to.have.length(0) + expect(store.relationships).to.have.length(0) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + + it('failed logout', async () => { + const revokeApi = vi + .fn() + .mockResolvedValueOnce( + // Ensure APP + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + // Revoke Token + new Response( + JSON.stringify('Oopsie-woopsie pleroma made a fucky-wucky'), + { + status: 500, + statusText: 'Internal Server Error', + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', revokeApi) + + // NOTE: Order is not checked for! + const spies = [ + // ## PAUSE ## + // Timeline / Notifications + /* 0 */ vi.spyOn(useTimelinesStore(), 'pauseAll'), + /* 1 */ vi.spyOn(useNotificationsStore(), 'pause'), + + // Fetchers (Pauseless) + /* 2 */ vi.spyOn(useListsStore(), 'stopFetching'), + /* 3 */ vi.spyOn(useChatsStore(), 'stopFetching'), + /* 4 */ vi.spyOn(useAnnouncementsStore(), 'stopFetching'), + /* 5 */ vi.spyOn(useBookmarkFoldersStore(), 'stopFetching'), + + // ## RESUME ## + // Timeline / Notifications + /* 6 */ vi.spyOn(useNotificationsStore(), 'resume'), + /* 7 */ vi.spyOn(useTimelinesStore(), 'resumeAll'), + + // Fetchers (Pauseless) + /* 8 */ vi.spyOn(useListsStore(), 'startFetching'), + /* 9 */ vi.spyOn(useChatsStore(), 'startFetching'), + /* 10 */ vi.spyOn(useAnnouncementsStore(), 'startFetching'), + /* 11 */ vi.spyOn(useBookmarkFoldersStore(), 'startFetching'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + store.currentUser = mockUser() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + mockUser(), + { + ...mockUser({ name: 'John', screen_name: 'snake' }), + relationship: { id: userId, following: true }, + }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + expect(store.loggedIn).to.eql(true) + await store.logout() + expect(store.loggedIn).to.eql(true) + expect(revokeApi).to.have.been.called + expect(store.users).to.have.length(1) + expect(store.usersByName).to.have.length(1) + expect(store.usersByURL).to.have.length(1) + expect(store.relationships).to.have.length(1) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + }) + }) + + describe('actions', () => { + describe('follow', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('instant follow case', async () => { + const followApi = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: true, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + const store = useUsersStore() + + await store.followUser(userId) + expect(followApi).to.have.been.called + + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + expect(followApi).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: '{}', + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('delayed follow case', async () => { + const followApi = vi + .fn() + .mockResolvedValueOnce( + // Follow + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + // Check relationship + new Response( + JSON.stringify({ + id: userId, + following: true, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + + const store = useUsersStore() + + await store.followUser(userId) + expect(followApi).to.have.been.called + + expect(store.followPollers).to.have.length(1) + expect(store.followPollersAttempts).to.have.length(1) + expect(store.followPollersAttempts.get(userId)).to.eql(3) + + await vi.runAllTimersAsync() + expect(followApi).to.have.been.called + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + }) + + it('unresolved follow case', async () => { + const followApi = vi.fn().mockResolvedValueOnce( + // Follow + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + followApi // mockImplementation because we need to re-create Response + .mockImplementation( + () => + // Check relationship + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + + const store = useUsersStore() + + await store.followUser(userId) + expect(store.followPollers).to.have.length(1) + expect(store.followPollersAttempts).to.have.length(1) + expect(store.followPollersAttempts.get(userId)).to.eql(3) + + await vi.runAllTimersAsync() + expect(store.followPollersAttempts.get(userId)).to.eql(2) + + await vi.runAllTimersAsync() + expect(store.followPollersAttempts.get(userId)).to.eql(1) + + await vi.runAllTimersAsync() + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + + expect(followApi).to.have.callCount(4) // 1 follow + 3 retries + }) + }) + + it('unfollow', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.unfollowUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_UNFOLLOW_URL(userId), + DEFAULT_OPTIONS(), + ) + }) + + it('subscribe', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + subscribing: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.subscribeUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ notify: true }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('unsubscribe', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + subscribing: false, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.unsubscribeUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ notify: false }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('showReblogs', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + showing_reblogs: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.showReblogs(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ reblogs: true }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('hideReblogs', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + showing_reblogs: false, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.hideReblogs(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ reblogs: false }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it.each([ + 'unmute', + 'unblock', + 'removeUserFromFollowers', + ])('%s', async (action) => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API[apiUrl](userId), + DEFAULT_OPTIONS(), + ) + }) + + describe.each(['mute', 'block'])('%s', (action) => { + it('normal', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useTimelinesStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useNotificationsStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId) + + expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), { + body: '{}', + ...DEFAULT_OPTIONS(), + }) + }) + + it('with expiration', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useTimelinesStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useNotificationsStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId, 20) + const argument = action === 'mute' ? 'expires_in' : 'duration' + + expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), { + body: `{"${argument}":20}`, + ...DEFAULT_OPTIONS(), + }) + }) + }) + + it.each(['mute', 'unmute'])('%s domain', async (action) => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.currentUser = mockUser() + store.currentUser.domainMutes = new Set() + if (action === 'unmute') { + store.currentUser.domainMutes.add('example.com') + } + await store[action + 'Domain']('example.com') + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_DOMAIN_BLOCKS_URL, + { + body: JSON.stringify({ domain: 'example.com' }), + ...DEFAULT_OPTIONS(action === 'mute' ? 'POST' : 'DELETE'), + }, + ) + + if (action === 'mute') { + expect(store.currentUser.domainMutes).to.include('example.com') + } else { + expect(store.currentUser.domainMutes).to.not.include('example.com') + } + }) + + it.each([ + 'muteDomain', + 'unmuteDomain', + 'muteUser', + 'unmuteUser', + 'blockUser', + 'unblockUser', + ])('%ss', async (action) => { + const store = useUsersStore() + store[action] = vi.fn().mockResolvedValue(async () => { + /* no-op */ + }) + store[action + 's'](['1', '2', '3']) + expect(store[action]).to.have.been.calledWith('1') + expect(store[action]).to.have.been.calledWith('2') + expect(store[action]).to.have.been.calledWith('3') + }) + }) + + describe('getters', () => { + it('relationship returns a placeholder if relationship info is missing', () => { + const store = useUsersStore() + + expect(store.relationship(userId)).to.eql({ id: userId, loading: true }) + }) + + it('relationship returns a placeholder if relationship info is missing while user is present', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.relationship(userId)).to.eql({ id: userId, loading: true }) + }) + + it('findUser returns user with matching id', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.findUser(userId).id).to.eql(userId) + }) + + it('findUserByName returns user with matching screen_name', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.findUserByName(mockUser().screen_name).id).to.eql(userId) + }) + + it('findUserByName returns user with matching url', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.findUserByUrl(mockUser().url).id).to.eql(userId) + }) + }) +})
-
+
diff --git a/src/components/user_note/user_note.js b/src/components/user_note/user_note.js
index bd246e9b3..dab3ac9f6 100644
--- a/src/components/user_note/user_note.js
+++ b/src/components/user_note/user_note.js
@@ -1,5 +1,7 @@
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
+import { useUsersStore } from 'src/stores/users.js'
+
const UserNote = {
props: {
user: Object,
@@ -29,11 +31,8 @@ const UserNote = {
finalizeEditing() {
this.frozen = true
- this.$store
- .dispatch('editUserNote', {
- id: this.user.id,
- comment: this.localNote,
- })
+ useUsersStore()
+ .editUserNote(this.user.id, this.localNote)
.then(() => {
this.frozen = false
this.editing = false
diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js
index 08270a1c6..ed74c86c7 100644
--- a/src/components/user_panel/user_panel.js
+++ b/src/components/user_panel/user_panel.js
@@ -1,15 +1,14 @@
-import { mapState } from 'vuex'
+import { mapState } from 'pinia'
import AuthForm from 'src/components/auth_form/auth_form.js'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import UserCard from 'src/components/user_card/user_card.vue'
+import { useUsersStore } from 'src/stores/users.js'
+
const UserPanel = {
computed: {
- signedIn() {
- return this.user
- },
- ...mapState({ user: (state) => state.users.currentUser }),
+ ...mapState(useUsersStore, ['currentUser', 'loggedIn']),
},
components: {
PostStatusForm,
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index 0f9260c4a..3dc1ef933 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -1,12 +1,12 @@
-
@@ -61,7 +62,7 @@
') const expected = ['Bruh', 'Bruh', mentionsLine(3), 'Bruh'].join('
') - const wrapper = shallowMount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = shallowMount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected)) }) @@ -304,16 +317,18 @@ describe('RichContent', () => { ' ', ].join('') - const wrapper = shallowMount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = shallowMount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected)) }) @@ -351,16 +366,18 @@ describe('RichContent', () => { p('Testing'), ].join('') - const wrapper = mount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = mount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect( wrapper @@ -424,16 +441,18 @@ describe('RichContent', () => { 'Testing', ].join('') - const wrapper = mount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = mount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect( wrapper @@ -473,16 +492,18 @@ describe('RichContent', () => { '', ].join('') - const wrapper = shallowMount(RichContent, { - global, - props: { - attentions, - handleLinks: true, - greentext: true, - emoji: [], - html, - }, - }) + const wrapper = shallowMount( + RichContent, + mountOpts({ + props: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html, + }, + }), + ) expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected)) }) @@ -519,14 +540,16 @@ describe('RichContent', () => { const ptest = (handleLinks, vhtml) => { const t0 = performance.now() - const wrapper = mount(TestComponent, { - global, - props: { - attentions, - handleLinks, - vhtml, - }, - }) + const wrapper = mount( + TestComponent, + mountOpts({ + props: { + attentions, + handleLinks, + vhtml, + }, + }), + ) const t1 = performance.now() diff --git a/test/unit/specs/components/user_profile.spec.js b/test/unit/specs/components/user_profile.spec.js deleted file mode 100644 index 36d323575..000000000 --- a/test/unit/specs/components/user_profile.spec.js +++ /dev/null @@ -1,212 +0,0 @@ -import { mount } from '@vue/test-utils' -import { createStore } from 'vuex' - -import UserProfile from 'src/components/user_profile/user_profile.vue' - -import { getters } from 'src/modules/users.js' - -const mutations = { - clearTimeline: () => { - /* no-op */ - }, -} - -const actions = { - fetchUser: () => { - /* no-op */ - }, - fetchUserByScreenName: () => { - /* no-op */ - }, -} - -const testGetters = { - findUser: (state) => getters.findUser(state.users), - findUserByName: (state) => getters.findUserByName(state.users), - relationship: (state) => getters.relationship(state.users), - mergedConfig: () => ({ - colors: '', - highlight: {}, - customTheme: { - colors: [], - }, - }), -} - -const localUser = { - id: 100, - is_local: true, - screen_name: 'testUser', - screen_name_ui: 'testUser', -} - -const extUser = { - id: 100, - is_local: false, - screen_name: 'testUser@test.instance', - screen_name_ui: 'testUser@test.instance', -} - -const externalProfileStore = createStore({ - mutations, - actions, - getters: testGetters, - state: { - interface: { - browserSupport: '', - }, - instance: { - hideUserStats: true, - }, - statuses: { - timelines: { - user: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - media: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - }, - }, - users: { - currentUser: { - credentials: '', - }, - usersObject: { 100: extUser }, - usersByNameObject: {}, - users: [extUser], - relationships: {}, - }, - }, -}) - -const localProfileStore = createStore({ - mutations, - actions, - getters: testGetters, - state: { - interface: { - browserSupport: '', - }, - config: { - colors: '', - highlight: {}, - customTheme: { - colors: [], - }, - }, - instance: { - hideUserStats: true, - }, - statuses: { - timelines: { - user: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - media: { - statuses: [], - statusesObject: {}, - faves: [], - visibleStatuses: [], - visibleStatusesObject: {}, - newStatusCount: 0, - maxId: 0, - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - viewing: 'statuses', - userId: 100, - flushMarker: 0, - }, - }, - }, - users: { - currentUser: { - credentials: '', - }, - usersObject: { 100: localUser }, - usersByNameObject: { testuser: localUser }, - users: [localUser], - relationships: {}, - }, - }, -}) - -// https://github.com/vuejs/test-utils/issues/1382 -describe.skip('UserProfile', () => { - it('renders external profile', () => { - const wrapper = mount(UserProfile, { - global: { - plugins: [externalProfileStore], - mocks: { - $route: { - params: { id: 100 }, - name: 'external-user-profile', - }, - $t: (msg) => msg, - }, - }, - }) - - expect(wrapper.find('.user-screen-name').text()).to.eql( - '@testUser@test.instance', - ) - }) - - it('renders local profile', () => { - const wrapper = mount(UserProfile, { - global: { - plugins: [localProfileStore], - mocks: { - $route: { - params: { name: 'testUser' }, - name: 'user-profile', - }, - $t: (msg) => msg, - }, - }, - }) - - expect(wrapper.find('.user-screen-name').text()).to.eql('@testUser') - }) -}) diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js deleted file mode 100644 index 04bd07ffe..000000000 --- a/test/unit/specs/modules/statuses.spec.js +++ /dev/null @@ -1,447 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' - -import { - defaultState, - mutations, - prepareStatus, -} from '../../../../src/modules/statuses.js' - -createTestingPinia() - -const makeMockStatus = ({ id, text, type = 'status' }) => { - return { - id, - user: { id: '0' }, - name: 'status', - text: text || `Text number ${id}`, - fave_num: 0, - uri: '', - type, - attentions: [], - } -} - -describe('Statuses module', () => { - describe('prepareStatus', () => { - it('sets deleted flag to false', () => { - const aStatus = makeMockStatus({ id: '1', text: 'Hello oniichan' }) - expect(prepareStatus(aStatus).deleted).to.eq(false) - }) - }) - - describe('addNewStatuses', () => { - it('adds the status to allStatuses and to the given timeline', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { - statuses: [status], - timeline: 'public', - }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([status]) - expect(state.timelines.public.visibleStatuses).to.eql([]) - expect(state.timelines.public.newStatusCount).to.equal(1) - }) - - it('counts the status as new if it has not been seen on this timeline', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { - statuses: [status], - timeline: 'public', - }) - mutations.addNewStatuses(state, { - statuses: [status], - timeline: 'friends', - }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([status]) - expect(state.timelines.public.visibleStatuses).to.eql([]) - expect(state.timelines.public.newStatusCount).to.equal(1) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.friends.statuses).to.eql([status]) - expect(state.timelines.friends.visibleStatuses).to.eql([]) - expect(state.timelines.friends.newStatusCount).to.equal(1) - }) - - it('add the statuses to allStatuses if no timeline is given', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { statuses: [status] }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([]) - expect(state.timelines.public.visibleStatuses).to.eql([]) - expect(state.timelines.public.newStatusCount).to.equal(0) - }) - - it('adds the status to allStatuses and to the given timeline, directly visible', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - - expect(state.allStatuses).to.eql([status]) - expect(state.timelines.public.statuses).to.eql([status]) - expect(state.timelines.public.visibleStatuses).to.eql([status]) - expect(state.timelines.public.newStatusCount).to.equal(0) - }) - - it('does not update the maxId when the noIdUpdate flag is set', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const secondStatus = makeMockStatus({ id: '2' }) - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.maxId).to.equal('1') - - mutations.addNewStatuses(state, { - statuses: [secondStatus], - showImmediately: true, - timeline: 'public', - noIdUpdate: true, - }) - expect(state.timelines.public.statuses).to.eql([secondStatus, status]) - expect(state.timelines.public.visibleStatuses).to.eql([ - secondStatus, - status, - ]) - expect(state.timelines.public.maxId).to.equal('1') - }) - - it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => { - const state = defaultState() - const nonVisibleStatus = makeMockStatus({ id: '1' }) - const status = makeMockStatus({ id: '3' }) - const statusTwo = makeMockStatus({ id: '2' }) - const statusThree = makeMockStatus({ id: '4' }) - - mutations.addNewStatuses(state, { - statuses: [nonVisibleStatus], - showImmediately: false, - timeline: 'public', - }) - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addNewStatuses(state, { - statuses: [statusTwo], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.minVisibleId).to.equal('2') - - mutations.addNewStatuses(state, { - statuses: [statusThree], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.statuses).to.eql([ - statusThree, - status, - statusTwo, - nonVisibleStatus, - ]) - expect(state.timelines.public.visibleStatuses).to.eql([ - statusThree, - status, - statusTwo, - ]) - }) - - it('splits retweets from their status and links them', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const retweet = makeMockStatus({ id: '2', type: 'retweet' }) - const modStatus = makeMockStatus({ id: '1', text: 'something else' }) - - retweet.retweeted_status = status - - // It adds both statuses, but only the retweet to visible. - mutations.addNewStatuses(state, { - statuses: [retweet], - timeline: 'public', - showImmediately: true, - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.statuses).to.have.length(1) - expect(state.allStatuses).to.have.length(2) - expect(state.allStatuses[0].id).to.equal('1') - expect(state.allStatuses[1].id).to.equal('2') - - // It refers to the modified status. - mutations.addNewStatuses(state, { - statuses: [modStatus], - timeline: 'public', - }) - expect(state.allStatuses).to.have.length(2) - expect(state.allStatuses[0].id).to.equal('1') - expect(state.allStatuses[0].text).to.equal(modStatus.text) - expect(state.allStatuses[1].id).to.equal('2') - expect(retweet.retweeted_status.text).to.eql(modStatus.text) - }) - - it('replaces existing statuses with the same id', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const modStatus = makeMockStatus({ id: '1', text: 'something else' }) - - // Add original status - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.allStatuses).to.have.length(1) - - // Add new version of status - mutations.addNewStatuses(state, { - statuses: [modStatus], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.allStatuses).to.have.length(1) - expect(state.allStatuses[0].text).to.eql(modStatus.text) - }) - - it('replaces existing statuses with the same id, coming from a retweet', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - const modStatus = makeMockStatus({ id: '1', text: 'something else' }) - const retweet = makeMockStatus({ id: '2', type: 'retweet' }) - retweet.retweeted_status = modStatus - - // Add original status - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.allStatuses).to.have.length(1) - - // Add new version of status - mutations.addNewStatuses(state, { - statuses: [retweet], - showImmediately: false, - timeline: 'public', - }) - expect(state.timelines.public.visibleStatuses).to.have.length(1) - // Don't add the retweet itself if the tweet is visible - expect(state.timelines.public.statuses).to.have.length(1) - expect(state.allStatuses).to.have.length(2) - expect(state.allStatuses[0].text).to.eql(modStatus.text) - }) - - it('handles favorite actions', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - - const favorite = { - id: '2', - type: 'favorite', - in_reply_to_status_id: '1', // The API uses strings here... - uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', - text: 'a favorited something by b', - user: { id: '99' }, - } - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addNewStatuses(state, { - statuses: [favorite], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) - expect(state.timelines.public.maxId).to.eq(favorite.id) - - // Adding it again does nothing - mutations.addNewStatuses(state, { - statuses: [favorite], - showImmediately: true, - timeline: 'public', - }) - - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) - expect(state.timelines.public.maxId).to.eq(favorite.id) - - // If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request. - const user = { - id: '1', - } - - const ownFavorite = { - id: '3', - type: 'favorite', - in_reply_to_status_id: '1', // The API uses strings here... - uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00', - text: 'a favorited something by b', - user, - } - - mutations.addNewStatuses(state, { - statuses: [ownFavorite], - showImmediately: true, - timeline: 'public', - user, - }) - - expect(state.timelines.public.visibleStatuses).to.have.length(1) - expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1) - expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true) - }) - }) - - describe('emojiReactions', () => { - it('increments count in existing reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [{ name: '😂', count: 1, accounts: [] }] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(2) - expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true) - expect( - state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id, - ).to.equal('me') - }) - - it('adds a new reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.addOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1) - expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true) - expect( - state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id, - ).to.equal('me') - }) - - it('decreases count in existing reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [ - { name: '😂', count: 2, accounts: [{ id: 'me' }] }, - ] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.removeOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1) - expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(false) - expect(state.allStatusesObject['1'].emoji_reactions[0].accounts).to.eql( - [], - ) - }) - - it('removes a reaction', () => { - const state = defaultState() - const status = makeMockStatus({ id: '1' }) - status.emoji_reactions = [ - { name: '😂', count: 1, accounts: [{ id: 'me' }] }, - ] - - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - mutations.removeOwnReaction(state, { - id: '1', - emoji: '😂', - currentUser: { id: 'me' }, - }) - expect(state.allStatusesObject['1'].emoji_reactions).to.have.length(0) - }) - }) - - describe('showNewStatuses', () => { - it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => { - const state = defaultState() - const status = makeMockStatus({ id: '10' }) - mutations.addNewStatuses(state, { - statuses: [status], - showImmediately: true, - timeline: 'public', - }) - const newStatus = makeMockStatus({ id: '20' }) - mutations.addNewStatuses(state, { - statuses: [newStatus], - showImmediately: false, - timeline: 'public', - }) - state.timelines.public.minId = '5' - mutations.showNewStatuses(state, { timeline: 'public' }) - - expect(state.timelines.public.visibleStatuses).to.have.length(2) - expect(state.timelines.public.minVisibleId).to.equal('10') - expect(state.timelines.public.minId).to.equal('10') - }) - }) - - describe('clearTimeline', () => { - it('keeps userId when clearing user timeline when excludeUserId param is true', () => { - const state = defaultState() - state.timelines.user.userId = 123 - - mutations.clearTimeline(state, { timeline: 'user', excludeUserId: true }) - - expect(state.timelines.user.userId).to.eql(123) - }) - }) -}) diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js deleted file mode 100644 index 1b33f8c4e..000000000 --- a/test/unit/specs/modules/users.spec.js +++ /dev/null @@ -1,120 +0,0 @@ -import { cloneDeep } from 'lodash' - -import { - defaultState, - getters, - mutations, -} from '../../../../src/modules/users.js' - -describe('The users module', () => { - describe('mutations', () => { - it('adds new users to the set, merging in new information for old users', () => { - const state = cloneDeep(defaultState) - const user = { id: '1', name: 'Guy' } - const modUser = { id: '1', name: 'Dude' } - - mutations.addNewUsers(state, [user]) - expect(state.users).to.have.length(1) - expect(state.users).to.eql([user]) - - mutations.addNewUsers(state, [modUser]) - expect(state.users).to.have.length(1) - expect(state.users).to.eql([user]) - expect(state.users[0].name).to.eql('Dude') - }) - - it('merging array field in new information for old users', () => { - const state = cloneDeep(defaultState) - const user = { - id: '1', - fields: [{ name: 'Label 1', value: 'Content 1' }], - } - const firstModUser = { - id: '1', - fields: [ - { name: 'Label 2', value: 'Content 2' }, - { name: 'Label 3', value: 'Content 3' }, - ], - } - const secondModUser = { - id: '1', - fields: [{ name: 'Label 4', value: 'Content 4' }], - } - - mutations.addNewUsers(state, [user]) - expect(state.users[0].fields).to.have.length(1) - expect(state.users[0].fields[0].name).to.eql('Label 1') - - mutations.addNewUsers(state, [firstModUser]) - expect(state.users[0].fields).to.have.length(2) - expect(state.users[0].fields[0].name).to.eql('Label 2') - expect(state.users[0].fields[1].name).to.eql('Label 3') - - mutations.addNewUsers(state, [secondModUser]) - expect(state.users[0].fields).to.have.length(1) - expect(state.users[0].fields[0].name).to.eql('Label 4') - }) - }) - - describe('findUser', () => { - it('does not return user with matching screen_name', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const name = 'Guy' - expect(getters.findUser(state)(name)).to.be.undefined - }) - - it('returns user with matching id', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const id = '1' - const expected = { screen_name: 'Guy', id: '1' } - expect(getters.findUser(state)(id)).to.eql(expected) - }) - }) - - describe('findUserByName', () => { - it('returns user with matching screen_name', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const name = 'Guy' - const expected = { screen_name: 'Guy', id: '1' } - expect(getters.findUserByName(state)(name)).to.eql(expected) - }) - - it('does not return user with matching id', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const id = '1' - expect(getters.findUserByName(state)(id)).to.be.undefined - }) - }) -}) diff --git a/test/unit/specs/services/notification_utils/notification_utils.spec.js b/test/unit/specs/services/notification_utils/notification_utils.spec.js index 20c9379c6..09d39f704 100644 --- a/test/unit/specs/services/notification_utils/notification_utils.spec.js +++ b/test/unit/specs/services/notification_utils/notification_utils.spec.js @@ -1,13 +1,15 @@ import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { useNotificationsStore } from 'src/stores/notifications.js' import { useSyncConfigStore } from 'src/stores/sync_config.js' import * as NotificationUtils from 'src/services/notification_utils/notification_utils.js' describe('NotificationUtils', () => { beforeEach(() => { - const store = useSyncConfigStore(createTestingPinia()) - store.mergedConfig = { + setActivePinia(createTestingPinia()) + useSyncConfigStore().mergedConfig = { notificationVisibility: { likes: true, repeats: true, @@ -16,31 +18,26 @@ describe('NotificationUtils', () => { } }) - describe('filteredNotificationsFromStore', () => { + describe('filteredNotifications', () => { it('should return sorted notifications with configured types', () => { - const store = { - state: { - notifications: { - data: [ - { - id: 1, - action: { id: '1' }, - type: 'like', - }, - { - id: 2, - action: { id: '2' }, - type: 'mention', - }, - { - id: 3, - action: { id: '3' }, - type: 'repeat', - }, - ], - }, + useNotificationsStore().data = [ + { + id: 1, + action: { id: '1' }, + type: 'like', }, - } + { + id: 2, + action: { id: '2' }, + type: 'mention', + }, + { + id: 3, + action: { id: '3' }, + type: 'repeat', + }, + ] + const expected = [ { action: { id: '3' }, @@ -54,7 +51,7 @@ describe('NotificationUtils', () => { }, ] expect( - NotificationUtils.filteredNotificationsFromStore(store, { + NotificationUtils.filteredNotifications({ mentions: false, likes: true, repeats: true, @@ -63,26 +60,21 @@ describe('NotificationUtils', () => { }) }) - describe('unseenNotificationsFromStore', () => { + describe('unseenNotifications', () => { it('should return only notifications not marked as seen', () => { - const store = { - state: { - notifications: { - data: [ - { - action: { id: '1' }, - type: 'like', - seen: false, - }, - { - action: { id: '2' }, - type: 'mention', - seen: true, - }, - ], - }, + useNotificationsStore().data = [ + { + action: { id: '1' }, + type: 'like', + seen: false, }, - } + { + action: { id: '2' }, + type: 'mention', + seen: true, + }, + ] + const expected = [ { action: { id: '1' }, @@ -91,7 +83,7 @@ describe('NotificationUtils', () => { }, ] expect( - NotificationUtils.unseenNotificationsFromStore(store, { + NotificationUtils.unseenNotifications({ likes: true, repeats: true, mentions: false, diff --git a/test/unit/specs/stores/notifications.spec.js b/test/unit/specs/stores/notifications.spec.js new file mode 100644 index 000000000..ad8b6bf44 --- /dev/null +++ b/test/unit/specs/stores/notifications.spec.js @@ -0,0 +1,390 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +import { useI18nStore } from 'src/stores/i18n.js' +import { useNotificationsStore } from 'src/stores/notifications.js' +import { useReportsStore } from 'src/stores/reports.js' +import { useStatusesStore } from 'src/stores/statuses.js' +import { useStreamingStore } from 'src/stores/streaming.js' +import { useUsersStore } from 'src/stores/users.js' + +import * as USER_API from 'src/api/user.js' + +const userId = '1' +const userScreenName = 'user' +const userName = 'Guy' +const userUrl = 'http://localhost/user' + +const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, + pleroma: { + emoji_reactions: [], + }, +}) + +const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +const mockStatus = ({ + id = '1', + text, + summary, + type = 'status', + statusUser = mockUser(), +} = {}) => ({ + id, + user: statusUser, + summary: summary ?? `Summary number ${id}`, + name: 'status', + text: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id: 'c1', + emoji_reactions: [], +}) + +const mockStatusNotification = ({ + id = '1', + type = 'like', + status = mockStatus({ id }), + seen = false, + user = mockUser(), +} = {}) => ({ + type, + id, + status, + seen, + user, + from_profile: user, +}) + +describe('Notifications store', () => { + beforeEach(() => { + vi.useFakeTimers() + setActivePinia(createTestingPinia({ stubActions: false })) + useI18nStore().i18n = { + t: () => { + /* no-op */ + }, + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('activate', () => { + const store = useNotificationsStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.activate() + + expect(sub).to.have.been.called + expect(store.fetcher).to.not.be.null + expect(store.socket).to.not.be.null + + store.deactivate() + }) + + it('deactivate', () => { + const store = useNotificationsStore() + const unsub = vi.fn() + useStreamingStore().removeSubscriber = unsub + + store.activate() + // Checking so that they were set properly before + // since reset changes them to '' + store.maxId = '2' + store.minId = '1' + store.idStore = new Map() + store.idStore.set('1', {}) + store.idStore.set('2', {}) + store.deactivate() + + expect(unsub).to.have.been.called + expect(store.fetcher).to.be.null + expect(store.socket).to.be.null + expect(store.idStore).to.have.length(0) + expect(store).to.have.property('maxId', '') + expect(store).to.have.property('minId', '') + }) + + it('updateExtremes should update min and max ids', () => { + const store = useNotificationsStore() + store.maxId = '10' + store.minId = '05' + + store.updateExtremes('04') + store.updateExtremes('11') + + expect(store).to.have.property('maxId', '11') + expect(store).to.have.property('minId', '04') + }) + + describe('addNewNotifications', () => { + it('adds notifications to the list', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: 'a' }), + mockStatusNotification({ id: 'b' }), + ], + }) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['a', 'b']) + expect(store.idStore).to.have.keys(['a', 'b']) + expect(store).to.have.property('maxId', 'b') + expect(store).to.have.property('minId', 'a') + }) + + it('ignores duplicates', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '2' }), + mockStatusNotification({ id: '1' }), + ], + }) + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '3' }), + mockStatusNotification({ id: '2' }), + ], + }) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['3', '2', '1']) + expect(store.idStore).to.have.keys(['1', '2', '3']) + expect(store).to.have.property('maxId', '3') + expect(store).to.have.property('minId', '1') + }) + + it('appends notifications if fetching older', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '4' }), + mockStatusNotification({ id: '3' }), + ], + }) + + store.addNewNotifications( + { + timestamp: 1, + data: [ + mockStatusNotification({ id: '2' }), + mockStatusNotification({ id: '1' }), + ], + }, + true, + ) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['4', '3', '2', '1']) + expect(store.idStore).to.have.keys(['1', '2', '3', '4']) + expect(store).to.have.property('maxId', '4') + expect(store).to.have.property('minId', '1') + }) + + it('should update usersStore', () => { + const store = useNotificationsStore() + const mock = vi.spyOn(useUsersStore(), 'addNewUsers') + const mockedNotification = mockStatusNotification({ type: 'follow' }) + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.called + expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337) + expect(mock.mock.calls[0][0].data[0]).to.eql( + mockedNotification.from_profile, + ) + }) + + it('should update reportsStore', (notificationType) => { + const store = useNotificationsStore() + const mock = vi.spyOn(useReportsStore(), 'addReport') + const mockedNotification = mockStatusNotification({ + type: 'pleroma:report', + }) + mockedNotification.report = { data: '123' } + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.calledWith({ data: '123' }) + }) + + it.each([ + 'like', + 'mention', + 'status', + 'repeat', + 'pleroma:emoji_reaction', + 'poll', + ])('should update statusesStore on %s notification', (notificationType) => { + const store = useNotificationsStore() + const mock = vi.fn() + useStatusesStore().addNewStatuses = mock + const mockedNotification = mockStatusNotification({ + type: notificationType, + }) + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.called + expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337) + expect(mock.mock.calls[0][0].statuses[0]).to.eql( + mockedNotification.status, + ) + }) + }) + + describe('wipeStatuses', () => { + it('clears all statuses', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ + id: 'n2', + status: mockStatus({ id: 's2' }), + }), + mockStatusNotification({ + id: 'n1', + status: mockStatus({ id: 's1' }), + }), + ], + }) + + store.wipeStatuses(['s2']) + expect(store.idStore).to.not.have.members('n2') + expect(store.data.map(({ id }) => id)).to.eql(['n1']) + }) + }) + + describe('read/dismiss', () => { + it('read single', () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1337, + data: [ + mockStatusNotification({ id: 'n3' }), + mockStatusNotification({ id: 'n2' }), + mockStatusNotification({ id: 'n1' }), + ], + }) + + expect(store.data[1]).to.have.property('seen', false) + + store.markSingleNotificationAsSeen('n2') + + expect(store.data[0]).to.have.property('seen', false) + expect(store.data[1]).to.have.property('seen', true) + expect(store.data[0]).to.have.property('seen', false) + + const calls = mockFetch.mock.calls + expect(calls).to.have.length(1) + const callOne = calls[0] + expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL) + const formData = Object.fromEntries(callOne[1].body.entries()) + expect(formData).to.eql({ id: 'n2' }) + }) + + it('read all', () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useNotificationsStore() + + // FormData is weird to test + store.addNewNotifications({ + timestamp: 1337, + data: [ + mockStatusNotification({ id: 'n3' }), + mockStatusNotification({ id: 'n2' }), + mockStatusNotification({ id: 'n1' }), + ], + }) + + expect(store.data[0]).to.have.property('seen', false) + expect(store.data[1]).to.have.property('seen', false) + expect(store.data[2]).to.have.property('seen', false) + + store.markNotificationsAsSeen() + + expect(store.data[0]).to.have.property('seen', true) + expect(store.data[1]).to.have.property('seen', true) + expect(store.data[2]).to.have.property('seen', true) + + // FormData is weird to test + const calls = mockFetch.mock.calls + expect(calls).to.have.length(1) + const callOne = calls[0] + expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL) + const formData = Object.fromEntries(callOne[1].body.entries()) + expect(formData).to.eql({ max_id: 'n3' }) + }) + }) +}) diff --git a/test/unit/specs/stores/statuses.spec.js b/test/unit/specs/stores/statuses.spec.js new file mode 100644 index 000000000..f4064ec9d --- /dev/null +++ b/test/unit/specs/stores/statuses.spec.js @@ -0,0 +1,660 @@ +import { createTestingPinia } from '@pinia/testing' +import { snakeCase } from 'lodash' +import { setActivePinia } from 'pinia' + +import { useStatusesStore } from 'src/stores/statuses.js' +import { useStreamingStore } from 'src/stores/streaming.js' +import { useUsersStore } from 'src/stores/users.js' + +import * as PUBLIC_API from 'src/api/public.js' +import * as USER_API from 'src/api/user.js' + +const userId = '1' +const userScreenName = 'user' +const userName = 'Guy' +const userUrl = 'http://localhost/user' + +const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, + pleroma: { + emoji_reactions: [], + }, +}) + +const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +const mockStatus = ({ + id = '1', + text, + type = 'status', + statusUser = mockUser(), +} = {}) => ({ + id, + user: statusUser, + name: 'status', + text: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id: 'c1', + emoji_reactions: [], +}) + +const mockMastoAPIStatus = ({ + id = '1', + text, + type = 'status', + statusUser = mockMastoAPIUser(), +} = {}) => ({ + id, + account: statusUser, + name: 'status', + content: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id: 'c1', +}) + +const DEFAULT_OPTIONS = (method = 'GET') => ({ + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}) + +describe('Statuses store', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('init', () => { + const store = useStatusesStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.attachSocket() + + expect(store.socket).to.not.be.null + expect(sub).to.have.been.called + }) + + it('resetStatuses', () => { + const store = useStatusesStore() + const statuses = [...new Array(20)].map((empty, index) => + mockStatus({ + id: 's' + index, + statusUser: mockUser({ id: 'u' + index }), + }), + ) + + store.attachSocket() + store.addNewStatuses({ + statuses, + timestamp: 1, + }) + store.resetStatuses() + expect(store.allStatuses).to.have.length(0) + }) + + describe('addNewStatuses', () => { + beforeEach(() => { + const usersStore = useUsersStore() + usersStore.addNewUsers = vi.fn().mockReturnValue([mockUser()]) + }) + + it('adds the status to allStatuses', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + expect(store.allStatuses).to.eql(new Map([['1', status]])) + expect(store.conversations).to.eql(new Map([['c1', new Set(['1'])]])) + expect(usersStore.addNewUsers).to.have.callCount(1) + }) + + it('splits retweets from their status and links them', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const retweet = mockStatus({ + id: '2', + type: 'retweet', + user: mockUser({ id: '2' }), + }) + retweet.type = 'retweet' + retweet.retweeted_status = status + + store.addNewStatuses({ + statuses: [retweet], + timestamp: 1, + }) + + expect(store.allStatuses).to.eql( + new Map([ + ['1', status], + ['2', retweet], + ]), + ) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('splits quotes from their status and links them', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const quote = mockStatus({ id: '2' }) + quote.quote = status + + store.addNewStatuses({ + statuses: [quote], + timestamp: 1, + }) + + expect(store.allStatuses).to.eql( + new Map([ + ['1', status], + ['2', quote], + ]), + ) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('replaces existing statuses with the same id', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ id: '1', text: 'something else' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 1991, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 2000, + }) + expect(store.allStatuses).to.eql(new Map([['1', modStatus]])) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('handles edits', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ + id: '1', + text: 'something else', + type: 'edit', + }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 1991, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 2000, + }) + expect(store.allStatuses).to.eql(new Map([['1', modStatus]])) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('ignores updates with older timestamp', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ id: '1', text: 'something else' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 2000, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 1991, + }) + expect(store.allStatuses).to.eql(new Map([['1', status]])) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + + it('calls useUsersStore().addNewUsers() even on older timestamp', () => { + const store = useStatusesStore() + const usersStore = useUsersStore() + const status = mockStatus({ id: '1' }) + const modStatus = mockStatus({ id: '1', text: 'something else' }) + + store.addNewStatuses({ + statuses: [status], + timestamp: 2000, + }) + store.addNewStatuses({ + statuses: [modStatus], + timestamp: 1991, + }) + expect(usersStore.addNewUsers).to.have.callCount(2) + }) + }) + + describe('fetchers', () => { + it.each([ + [ + 'StatusSource', + { + content_type: 'text/plain', + text: 'Text', + spoiler_text: 'Text', + }, + ], + [ + 'EmojiReactions', + [ + { + accounts: [ + mockMastoAPIUser({ id: 'u1' }), + mockMastoAPIUser({ id: 'u2' }), + ], + count: 1, + me: false, + name: 'cofe', + url: '', + }, + ], + ], + [ + 'Favs', + [mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })], + ], + [ + 'Repeats', + [mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })], + ], + ])('fetch%s', async (group, mockedResponse) => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockedResponse), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + const addNewUsers = vi.spyOn(useUsersStore(), 'addNewUsers') + + let urlKey + let prefix = 'MASTODON' + if (group === 'Favs') { + urlKey = 'STATUS_FAVORITEDBY' + } else if (group === 'Repeats') { + urlKey = 'STATUS_REBLOGGEDBY' + } else { + urlKey = snakeCase(group).toUpperCase() + } + if (group === 'EmojiReactions') { + prefix = 'PLEROMA' + } + + const url = PUBLIC_API[`${prefix}_${urlKey}_URL`]('id') + + const store = useStatusesStore() + const status = mockStatus({ id: 'id' }) + store.addNewStatuses({ + statuses: [status], + timestamp: 2000, + }) + + const result = await store[`fetch${group}`]('id') + const updated = store.allStatuses.get('id') + + // Fetch called + expect(mockFetch).to.have.been.calledWith(url, DEFAULT_OPTIONS()) + + // Users updated + if (group !== 'StatusSource') { + // first call is the one for the status + expect(addNewUsers).to.have.been.calledTwice + const secondCallData = addNewUsers.mock.calls[1][0].data + expect(secondCallData).to.have.length(2) + expect(secondCallData[0]).to.have.property('id', 'u1') + expect(secondCallData[1]).to.have.property('id', 'u2') + } + + if (group === 'Favs') { + expect(store.favs).to.have.length(1) + expect(store.favs.get('id')).to.have.length(2) + expect(store.favs.get('id')).to.eql(new Set(['u1', 'u2'])) + expect(updated.fave_num).to.eql(2) + } else if (group === 'Repeats') { + expect(store.repeats).to.have.length(1) + expect(store.repeats.get('id')).to.have.length(2) + expect(store.repeats.get('id')).to.eql(new Set(['u1', 'u2'])) + expect(updated.repeat_num).to.eql(2) + } else if (group === 'EmojiReactions') { + expect(updated.emoji_reactions).to.have.length(mockedResponse.length) + expect(updated.emoji_reactions[0].name).to.eql(mockedResponse[0].name) + } else { + expect(result).to.eql(mockedResponse) + } + }) + + it('fetchFavsAndRepeats', async () => { + const store = useStatusesStore() + store.fetchFavs = vi.fn().mockResolvedValue(async () => { + /* no-op */ + }) + store.fetchRepeats = vi.fn().mockResolvedValue(async () => { + /* no-op */ + }) + + await store.fetchFavsAndRepeats('id') + expect(store.fetchFavs).to.have.been.calledWith('id') + expect(store.fetchRepeats).to.have.been.calledWith('id') + }) + + it('fetchStatus', async () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus()), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useStatusesStore() + await store.fetchStatus('id') + + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API.MASTODON_STATUS_URL('id'), + DEFAULT_OPTIONS(), + ) + expect(store.allStatuses).to.have.length(1) + }) + }) + + describe('interactions', () => { + it.each([ + ['favorite', 'MASTODON_FAVORITE_URL'], + ['unfavorite', 'MASTODON_UNFAVORITE_URL'], + ['retweet', 'MASTODON_RETWEET_URL'], + ['unretweet', 'MASTODON_UNRETWEET_URL'], + ['reactWithEmoji', 'PLEROMA_EMOJI_REACT_URL', 'PUT'], + ['unreactWithEmoji', 'PLEROMA_EMOJI_UNREACT_URL', 'DELETE'], + [ + 'bookmark', + 'MASTODON_BOOKMARK_STATUS_URL', + undefined, + { folder_id: 'argument' }, + ], + ['unbookmark', 'MASTODON_UNBOOKMARK_STATUS_URL'], + ['pinStatus', 'MASTODON_PIN_OWN_STATUS_URL'], + ['unpinStatus', 'MASTODON_UNPIN_OWN_STATUS_URL'], + ['muteConversation', 'MASTODON_MUTE_CONVERSATION_URL'], + ['unmuteConversation', 'MASTODON_UNMUTE_CONVERSATION_URL'], + ['deleteStatus', 'MASTODON_DELETE_URL', 'DELETE'], + ])('%s - api call', async (interaction, urlKey, method = 'POST', body) => { + const url = USER_API[urlKey]('1', 'argument') + + const status = mockStatus() + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + await store[interaction]('1', 'argument') + const expectedArg = DEFAULT_OPTIONS(method) + + if (body) { + expectedArg.body = JSON.stringify(body) + } + + expect(mockFetch).to.have.been.calledWith(url, expectedArg) + if (interaction === 'deleteStatus') { + expect(store.allStatuses.get('1').deleted).to.be.true + } else { + expect(store.allStatuses.get('1').text).to.eql('Updated') + } + }) + + const optimismInteractions = [ + ['favorite', 'favorited', 'fave_num'], + ['retweet', 'repeated', 'repeat_num'], + ['bookmark', 'bookmarked'], + ['muteConversation', 'thread_muted'], + ] + .map(([method, property, count]) => [ + [method, property, count], + ['un' + method, property, count], + ]) + .flat() + + it.each( + optimismInteractions, + )('%s - optimism call', async (method, property, count) => { + // Prepare our status + const status = mockStatus() + const negate = method.startsWith('un') + const oldCount = 9 + const newCount = negate ? 8 : 10 + status[property] = negate + if (count) { + status[count] = oldCount + } + if (method === 'unbookmark') { + status.bookmark_folder_id = 'argument' + } + + // Insert it + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + expect(store.allStatuses.get('1')).to.have.property(property, negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, oldCount) + } + vi.stubGlobal('fetch', mockFetch) + + store[method]('1', 'argument') + expect(store.allStatuses.get('1')).to.have.property(property, !negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, newCount) + } + if (property === 'bookmarked') { + expect(store.allStatuses.get('1')).to.have.property( + 'bookmark_folder_id', + 'argument', + ) + } + }) + + it.each( + optimismInteractions, + )('%s - optimism fail', async (method, property, count) => { + // Prepare our status + const status = mockStatus() + const negate = method.startsWith('un') + const oldCount = 9 + status[property] = negate + if (count) { + status[count] = oldCount + } + if (method === 'unbookmark') { + status.bookmark_folder_id = 'argument' + } + + // Insert it + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockRejectedValueOnce(new Error('Failure!')) + + expect(store.allStatuses.get('1')).to.have.property(property, negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, oldCount) + } + vi.stubGlobal('fetch', mockFetch) + + await store[method]('1', 'argument') + expect(store.allStatuses.get('1')).to.have.property(property, negate) + if (count) { + expect(store.allStatuses.get('1')).to.have.property(count, oldCount) + } + + // Failing 'bookmark' method SHOULD clear folder id + if (method === 'unbookmark') { + expect(store.allStatuses.get('1')).to.have.property( + 'bookmark_folder_id', + 'argument', + ) + } + }) + + it.each([ + ['reactWithEmoji', 0], + ['unreactWithEmoji', 1], + ['reactWithEmoji', 1], + ['unreactWithEmoji', 2], + ])('%s count: %s - optimism call', async (method, oldCount) => { + // Prepare our status + const status = mockStatus() + const negate = method.startsWith('un') + const newCount = negate ? oldCount - 1 : oldCount + 1 + + useUsersStore().currentUser = mockUser() + + if (oldCount > 0) { + const reactors = [...new Array(oldCount)].map((empty, index) => + mockUser({ id: 'o' + index }), + ) + if (negate) { + // Replace one of reactors with ourselves + reactors[0] = useUsersStore().currentUser + } + status.emoji_reactions = [ + { + name: 'hyperlol', + count: oldCount, + accounts: reactors, + }, + ] + } else { + status.emoji_reactions = [] + } + + // Insert it + const store = useStatusesStore() + store.addNewStatuses({ + statuses: [status], + timestamp: 1, + }) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + const expected = store.allStatuses.get('1') + + expect(expected.emoji_reactions).to.have.length(oldCount === 0 ? 0 : 1) + if (oldCount !== 0) { + expect(expected.emoji_reactions[0].name).to.eql('hyperlol') + expect(expected.emoji_reactions[0].count).to.eql(oldCount) + expect(expected.emoji_reactions[0].accounts).to.have.length(oldCount) + } + + store[method]('1', 'hyperlol') + + expect(expected.emoji_reactions).to.have.length(newCount === 0 ? 0 : 1) + if (newCount !== 0) { + expect(expected.emoji_reactions[0].name).to.eql('hyperlol') + expect(expected.emoji_reactions[0].count).to.eql(newCount) + expect(expected.emoji_reactions[0].accounts).to.have.length(newCount) + } + }) + }) + + it('wipeUserStatuses', () => { + const store = useStatusesStore() + const statuses = [...new Array(20)].map((empty, index) => + mockStatus({ + id: 's' + index, + statusUser: mockUser({ id: 'u' + index }), + }), + ) + + store.addNewStatuses({ + statuses, + timestamp: 1, + }) + + const result = store.wipeUserStatuses('u19') + + expect(store.allStatuses).to.have.length(19) + expect(result).to.eql(new Set(['s19'])) + }) +}) diff --git a/test/unit/specs/stores/timelines.spec.js b/test/unit/specs/stores/timelines.spec.js new file mode 100644 index 000000000..2b4704052 --- /dev/null +++ b/test/unit/specs/stores/timelines.spec.js @@ -0,0 +1,487 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +import { useStreamingStore } from 'src/stores/streaming.js' +import { useTimelinesStore } from 'src/stores/timelines.js' + +describe('Timelines store', () => { + beforeEach(() => { + vi.useFakeTimers() + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + afterEach(() => { + useTimelinesStore().deactivateAll() + vi.useRealTimers() + }) + + describe('activate', () => { + it('streamable', () => { + const store = useTimelinesStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.activate('friends', undefined, true) + + expect(sub).to.have.been.called + expect(store.friends.fetcher).to.not.be.null + expect(store.friends.socket).to.not.be.null + }) + + it('non-streamable', () => { + const store = useTimelinesStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.activate('user', '1') + + expect(sub).to.not.have.been.called + expect(store.user.fetcher).to.not.be.null + expect(store.user.socket).to.be.null + }) + }) + + describe('deactivate', () => { + it('streamable', () => { + const store = useTimelinesStore() + const unsub = vi.fn() + useStreamingStore().removeSubscriber = unsub + useStreamingStore().addSubscriber = vi.fn() + + store.activate('friends', undefined, true) + // Checking so that they were set properly before + // since reset changes them to '' + store.friends.maxId = '3' + store.friends.minId = '4' + store.friends.statusIds = new Set(['3', '4']) + store.friends.visibleStausIds = new Set(['3', '4']) + store.deactivate('friends', true) + + expect(unsub).to.have.been.called + expect(store.friends.fetcher).to.be.null + expect(store.friends.socket).to.be.null + expect(store.friends.statusIds).to.have.length(0) + expect(store.friends.visibleStatusIds).to.have.length(0) + expect(store.friends).to.have.property('maxId', '') + expect(store.friends).to.have.property('minId', '') + }) + + it('non-streamable', () => { + const store = useTimelinesStore() + const unsub = vi.fn() + useStreamingStore().removeSubscriber = unsub + useStreamingStore().addSubscriber = vi.fn() + + store.activate('user', '1') + // Checking so that they were set properly before + // since reset changes them to '' + store.user.maxId = '3' + store.user.minId = '4' + store.user.statusIds = new Set(['3', '4']) + store.user.visibleStausIds = new Set(['3', '4']) + store.deactivate('user') + + expect(unsub).to.not.have.been.called + expect(store.user.fetcher).to.be.null + expect(store.user.socket).to.be.null + expect(store.user.statusIds).to.have.length(0) + expect(store.user.visibleStatusIds).to.have.length(0) + expect(store.user).to.have.property('maxId', '') + expect(store.user).to.have.property('minId', '') + }) + }) + + describe('updateTimelineExtremes', () => { + it('should derive extremes from data', () => { + const store = useTimelinesStore() + const timeline = useTimelinesStore().friends + timeline.order = ['4', '1', '3', '2'] + timeline.statusesIds = new Set(timeline.order) + store.updateTimelineExtremes(timeline) + + expect(store.friends).to.have.property('maxId', '4') + expect(store.friends).to.have.property('minId', '2') + }) + + it('should use extremes from pagination', () => { + const store = useTimelinesStore() + const timeline = useTimelinesStore().friends + store.updateTimelineExtremes(timeline, { maxId: '1', minId: '2' }) + + // Min and max are swapped! + expect(store.friends).to.have.property('maxId', '2') + expect(store.friends).to.have.property('minId', '1') + }) + }) + + describe('addStatusesToTimeline', () => { + it('adds the status to the given timeline', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(3) + expect(store.list).to.have.property('maxId', '1') + expect(store.list).to.have.property('minId', '3') + }) + + it('ignores duplicates', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(3) + expect(store.list).to.have.property('maxId', '1') + expect(store.list).to.have.property('minId', '3') + }) + + it('adds the status the given timeline, directly visible', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + showImmediately: true, + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set(statuses)) + expect(store.list.newStatusCount).to.equal(0) + expect(store.list).to.have.property('maxId', '1') + expect(store.list).to.have.property('minId', '3') + }) + + it('does not update the maxId when the noIdUpdate flag is set', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses, + repeats: [], + noIdUpdate: true, + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql(statuses) + expect(store.list.statusIds).to.eql(new Set(statuses)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(3) + expect(store.list).to.have.property('maxId', '') + expect(store.list).to.have.property('minId', '') + }) + + it('does not update timeline if it belongs to a different arugment', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '2', { + statuses, + repeats: [], + noIdUpdate: true, + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.list.order).to.eql([]) + expect(store.list.statusIds).to.eql(new Set()) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(0) + expect(store.list).to.have.property('maxId', '') + expect(store.list).to.have.property('minId', '') + }) + + it('prepends timeline with new statuses', () => { + const store = useTimelinesStore() + const statuses1 = ['3', '2', '1'] + const statuses2 = ['6', '5', '4'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [], + pagination: { minId: '3', maxId: '1' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [], + pagination: { minId: '6', maxId: '4' }, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(6) + expect(store.list).to.have.property('maxId', '6') + expect(store.list).to.have.property('minId', '1') + }) + + it('appends timeline with new statuses if fetching older', () => { + const store = useTimelinesStore() + const statuses1 = ['6', '5', '4'] + const statuses2 = ['3', '2', '1'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [], + pagination: { minId: '6', maxId: '4' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [], + pagination: { minId: '3', maxId: '1' }, + older: true, + }) + + const newOrder = [...statuses1, ...statuses2] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql(new Set()) + expect(store.list.newStatusCount).to.equal(6) + expect(store.list).to.have.property('maxId', '6') + expect(store.list).to.have.property('minId', '1') + }) + + describe('repeat de-duplication', () => { + it('handles repeat de-duplication (older)', () => { + const store = useTimelinesStore() + const statuses1 = ['s7s3', 's6s3', 's5', 's4s0', 's3', 's2'] + const statuses2 = ['s1s0', 's0'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s7s3', 's2'], + ['s6s3', 's2'], + ['s4s0', 's0'], + ], + pagination: { minId: 's7s3', maxId: 's2' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s5', 's4s0', 's3', 's2']), + ) + expect(store.list).to.have.property('maxId', 's7s3') + expect(store.list).to.have.property('minId', 's2') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [['s1s0', 's0']], + pagination: { minId: 's1', maxId: 's0' }, + showImmediately: true, + older: true, + }) + + const newOrder = [...statuses1, ...statuses2] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s5', 's4s0', 's3', 's2', 's0']), + ) + expect(store.list).to.have.property('maxId', 's7s3') + expect(store.list).to.have.property('minId', 's0') + }) + + it('handles repeat de-duplication(reverse)', () => { + const store = useTimelinesStore() + const statuses1 = ['s6s2', 's5s2', 's4', 's3s0', 's2', 's1'] + const statuses2 = ['sAs0', 's9s1', 's8', 's7s2'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s6s2', 's2'], + ['s5s2', 's2'], + ['s3s0', 's0'], + ], + pagination: { minId: 's6s2', maxId: 's1' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + expect(store.list).to.have.property('maxId', 's6s2') + expect(store.list).to.have.property('minId', 's1') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [ + ['sAs0', 's0'], + ['s9s1', 's1'], + ['s7s2', 's2'], + ], + pagination: { minId: 'sAs0', maxId: 's7s2' }, + showImmediately: true, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql( + new Set([ + 's4', + 's3s0', + 's2', + 's1', + // Newer + 's8', + ]), + ) + expect(store.list).to.have.property('maxId', 'sAs0') + expect(store.list).to.have.property('minId', 's1') + }) + + it('showNewStatuses follows de-duplication rules', () => { + const store = useTimelinesStore() + const statuses1 = ['s6s2', 's5s2', 's4', 's3s0', 's2', 's1'] + const statuses2 = ['sAs0', 's9s1', 's8', 's7s2'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s6s2', 's2'], + ['s5s2', 's2'], + ['s3s0', 's0'], + ], + pagination: { minId: 's6s2', maxId: 's1' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + expect(store.list).to.have.property('maxId', 's6s2') + expect(store.list).to.have.property('minId', 's1') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [ + ['sAs0', 's0'], + ['s9s1', 's1'], + ['s7s2', 's2'], + ], + pagination: { minId: 'sAs0', maxId: 's7s2' }, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.newStatusCount).to.eql(1) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + + store.showNewStatuses('list') + expect(store.list.visibleStatusIds).to.eql( + new Set([ + 's4', + 's3s0', + 's2', + 's1', + // Newer + 's8', + ]), + ) + }) + }) + }) + + describe('showNewStatuses', () => { + it('resets counter and makes all ids visible', () => { + const store = useTimelinesStore() + const statuses = ['1', '2', '3'] + + store.activate('public') + store.addStatusesToTimeline('public', undefined, { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.public.statusIds).to.eql(new Set(statuses)) + expect(store.public.visibleStatusIds).to.eql(new Set()) + expect(store.public.newStatusCount).to.equal(3) + store.showNewStatuses('public') + expect(store.public.visibleStatusIds).to.eql(new Set(statuses)) + expect(store.public.newStatusCount).to.equal(0) + }) + + it('limits shown items to 50 for better performance', () => { + const store = useTimelinesStore() + const statuses = new Array(100).fill().map((_, index) => 's' + index) + + store.activate('public') + store.addStatusesToTimeline('public', undefined, { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.public.statusIds).to.eql(new Set(statuses)) + expect(store.public.visibleStatusIds).to.eql(new Set()) + expect(store.public.newStatusCount).to.equal(100) + store.showNewStatuses('public') + expect(store.public.visibleStatusIds).to.eql( + new Set(statuses.slice(0, 50)), + ) + expect(store.public.newStatusCount).to.equal(0) + }) + }) + + describe('wipeStatuses', () => { + it('clears all statuses', () => { + const store = useTimelinesStore() + + store.activate('friends') + store.activate('public') + store.addStatusesToTimeline('public', undefined, { + statuses: ['1', '2', '3', '0'], + repeats: [], + }) + store.addStatusesToTimeline('friends', undefined, { + statuses: ['5', '0', '9', '1'], + repeats: [], + }) + store.wipeStatuses(['0']) + expect(store.friends.statusIds).to.not.have.members('0') + expect(store.public.statusIds).to.not.have.members('0') + }) + }) +}) diff --git a/test/unit/specs/stores/user_highlight.spec.js b/test/unit/specs/stores/user_highlight.spec.js index e97f8f382..865544035 100644 --- a/test/unit/specs/stores/user_highlight.spec.js +++ b/test/unit/specs/stores/user_highlight.spec.js @@ -9,15 +9,6 @@ import { describe('The UserHighlight store', () => { beforeEach(() => { setActivePinia(createPinia()) - window.vuex = { - state: { - users: { - currentUser: { - fqn: 'foo@bar.tld', - }, - }, - }, - } }) describe('mutations', () => { diff --git a/test/unit/specs/stores/users.spec.js b/test/unit/specs/stores/users.spec.js new file mode 100644 index 000000000..c44c749f3 --- /dev/null +++ b/test/unit/specs/stores/users.spec.js @@ -0,0 +1,1356 @@ +import { createTestingPinia } from '@pinia/testing' +import { snakeCase } from 'lodash' +import { setActivePinia } from 'pinia' + +import { useAnnouncementsStore } from 'src/stores/announcements.js' +import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js' +import { useChatsStore } from 'src/stores/chats.js' +import { useEmojiStore } from 'src/stores/emoji.js' +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useInterfaceStore } from 'src/stores/interface.js' +import { useListsStore } from 'src/stores/lists.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useNotificationsStore } from 'src/stores/notifications.js' +import { useOAuthStore } from 'src/stores/oauth.js' +import { useStatusesStore } from 'src/stores/statuses.js' +import { useStreamingStore } from 'src/stores/streaming.js' +import { useSyncConfigStore } from 'src/stores/sync_config.js' +import { useTimelinesStore } from 'src/stores/timelines.js' +import { useUserHighlightStore } from 'src/stores/user_highlight.js' +import { useUsersStore } from 'src/stores/users.js' + +import * as PUBLIC_API from 'src/api/public.js' +import * as USER_API from 'src/api/user.js' + +const DEFAULT_OPTIONS = (method = 'POST') => ({ + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}) + +const actionKeys = (action) => { + const result = {} + if (action === 'removeUserFromFollowers') { + result.storeAction = action + } else { + result.storeAction = action + 'User' + } + + result.apiUrl = `MASTODON_${snakeCase(result.storeAction).toUpperCase()}_URL` + return result +} + +const userId = '1' +const userScreenName = 'user' +const userName = 'Guy' +const userUrl = 'http://localhost/user' + +const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, +}) + +const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +describe('Users store', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + describe('addNewUsers', () => { + describe('users', () => { + it('adds new users to the set, merging in new information for old users', () => { + const store = useUsersStore() + + const modUser = mockUser({ name: 'Dude' }) + + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + + store.addNewUsers({ data: [modUser], timestamp: 2 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Dude') + }) + + it('ignores new users if timestamp is older', () => { + const store = useUsersStore() + + const modUser = mockUser({ name: 'Old guy' }) + + store.addNewUsers({ data: [mockUser()], timestamp: 2000 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Guy') + + store.addNewUsers({ data: [modUser], timestamp: 1991 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Guy') + }) + + it('merging array field in new information for old users', () => { + const store = useUsersStore() + + const userFields = { + ...mockUser(), + fields: [{ name: 'Label 1', value: 'Content 1' }], + } + const firstModUser = { + ...mockUser(), + fields: [ + { name: 'Label 2', value: 'Content 2' }, + { name: 'Label 3', value: 'Content 3' }, + ], + } + const secondModUser = { + ...mockUser(), + fields: [{ name: 'Label 4', value: 'Content 4' }], + } + + store.addNewUsers({ data: [userFields], timestamp: 1 }) + const reactive = store.users.get(userId) + expect(reactive.fields).to.have.length(1) + expect(reactive.fields[0].name).to.eql('Label 1') + + store.addNewUsers({ data: [firstModUser], timestamp: 2 }) + expect(reactive.fields).to.have.length(2) + expect(reactive.fields[0].name).to.eql('Label 2') + expect(reactive.fields[1].name).to.eql('Label 3') + + store.addNewUsers({ data: [secondModUser], timestamp: 3 }) + expect(reactive.fields).to.have.length(1) + expect(reactive.fields[0].name).to.eql('Label 4') + }) + }) + + describe('relationships', () => { + it('updates relationship information if present', () => { + const store = useUsersStore() + + const modUser = { + ...mockUser(), + relationship: { + id: userId, + following: true, + }, + } + + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + store.addNewUsers({ data: [modUser], timestamp: 2 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it('updates relationship information if present even if user timestamp is older', () => { + const store = useUsersStore() + + const modUser = { + ...mockUser({ name: 'Old Dude' }), + relationship: { + id: userId, + following: true, + }, + } + + store.addNewUsers({ data: [mockUser()], timestamp: 2000 }) + store.addNewUsers({ data: [modUser], timestamp: 1991 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it("doesn't erase relationship information if new data has it missing", () => { + const store = useUsersStore() + + const modUser = mockUser({ name: 'Dude' }) + + store.addNewUsers({ + data: [ + { ...mockUser(), relationship: { id: userId, following: true } }, + ], + timestamp: 1, + }) + store.addNewUsers({ data: [modUser], timestamp: 2 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + }) + }) + + describe('updateUserRelationships', () => { + it('updates existing user relationship', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + store.updateUserRelationships({ data: relationship, timestamp: 2 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it('stores relationship for missing user', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.updateUserRelationships({ data: relationship, timestamp: 1 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + }) + + it('assigns relationship for missing user when it becomes available', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.updateUserRelationships({ data: relationship, timestamp: 1 }) + store.addNewUsers({ data: [mockUser()], timestamp: 2 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + expect(store.findUser(userId).relationship).to.eql({ + id: userId, + following: true, + }) + }) + + it('ignores relationship update if timestamp is older than existing', () => { + const store = useUsersStore() + const oldRelationship = { id: userId, following: true } + const newRelationship = { id: userId, following: false } + + store.addNewUsers({ + data: [{ ...mockUser(), relationship: newRelationship }], + timestamp: 2000, + }) + store.updateUserRelationships({ data: oldRelationship, timestamp: 1991 }) + + expect(store.relationship(userId)).to.eql({ + id: userId, + following: false, + }) + expect(store.findUser(userId).relationship.following).to.eql(false) + }) + }) + + describe('fetchers', () => { + describe('fetchUserIfMissing', () => { + it('should fetch requested user, add it to store and return it', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const expected = mockUser() + const store = useUsersStore() + const resultUser = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should re-use existing promise for other fetches', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: '1' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + // fetch by name yields user id which we request next + .mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockThrowOnce(new Error("Shouldn't be called more than once")), + ) + + const expected = mockUser() + const store = useUsersStore() + const resultUser1 = await store.fetchUserIfMissing({ name: 'user' }) + const resultUser2 = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser1).to.deep.include(expected) + expect(resultUser2).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should use cached data if present', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockThrowOnce(new Error("Shouldn't be called at all")), + ) + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + const resultUser1 = await store.fetchUserIfMissing({ id: '1' }) + const resultUser2 = await store.fetchUserIfMissing({ name: 'user' }) + const expected = mockUser() + + expect(resultUser1).to.deep.include(expected) + expect(resultUser2).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should handle 404 gracefully', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + const store = useUsersStore() + + const resultUser = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser).to.be.null + }) + + it('Should throw if no identifier is provided', async () => { + const store = useUsersStore() + + await expect( + async () => await store.fetchUserIfMissing({}), + ).rejects.to.throw(TypeError) + await expect( + async () => await store.fetchUserIfMissing(), + ).rejects.to.throw(TypeError) + }) + }) + + describe('relationships', () => { + it.each(['Friends', 'Followers'])('fetch%s', async (group) => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'snake', + name: 'John', + id: '2', + }), + mockMastoAPIUser({ + screen_name: 'zero', + name: 'David Oh', + id: '3', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'sigint', + name: 'Mr.Anderson', + id: '4', + }), + mockMastoAPIUser({ + screen_name: 'paramedic', + name: 'Dr.Clark', + id: '5', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: mockUser() }) + + const urlGroup = group === 'Friends' ? 'Following' : group + const us = store.users.get(userId) + + await store[`fetch${group}`](userId) + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, { + limit: 20, + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect( + store.relationshipsLists[group.toLowerCase()].get(us), + ).to.have.length(2) + + await store[`fetch${group}`](userId) + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, { + maxId: '3', + limit: 20, + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect( + store.relationshipsLists[group.toLowerCase()].get(us), + ).to.have.length(4) + }) + + it.each(['Mutes', 'Blocks'])('fetch%s', async (group) => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'snake', + name: 'John', + id: '2', + }), + mockMastoAPIUser({ + screen_name: 'zero', + name: 'David Oh', + id: '3', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + mockMastoAPIUser({ + screen_name: 'sigint', + name: 'Mr.Anderson', + id: '4', + }), + mockMastoAPIUser({ + screen_name: 'paramedic', + name: 'Dr.Clark', + id: '5', + }), + ]), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: mockUser() }) + + const us = store.users.get(userId) + + store.currentUser = us + const ids = group === 'Mutes' ? 'muteIds' : 'blockIds' + + await store[`fetch${group}`]({ reset: true }) + expect(mockFetch).to.have.been.calledWith( + USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({ + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(us[ids]).to.have.length(2) + + await store[`fetch${group}`]() + expect(mockFetch).to.have.been.calledWith( + USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({ + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(us[ids]).to.have.length(4) + }) + + it('fetchDomainMutes', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(['example.com', 'example.org']), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: mockUser() }) + + const us = store.users.get(userId) + store.currentUser = us + + await store.fetchDomainMutes() + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_DOMAIN_BLOCKS_URL, + DEFAULT_OPTIONS('GET'), + ) + + expect(us.domainMutes).to.have.eql( + new Set(['example.com', 'example.org']), + ) + }) + + it('fetchInLists', async () => { + const inLists = [ + { exclusive: false, id: '1', title: 'Operatives' }, + { exclusive: true, id: '2', title: 'Agents' }, + ] + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(inLists), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ + timestamp: 1, + data: [ + mockUser(), + { ...mockUser({ name: 'John', screen_name: 'snake', id: '2' }) }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero', id: '3' }) }, + ], + }) + + const us = store.users.get(userId) + store.currentUser = us + + await store.fetchUserInLists('2') + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_USER_IN_LISTS('2'), + DEFAULT_OPTIONS('GET'), + ) + + expect(store.users.get('2').inLists).to.have.eql(inLists) + }) + }) + }) + + describe('misc updates', () => { + it('updateUserAdminData', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + const adminData = { is_active: true, tags: ['one', 'two'] } + store.updateUserAdminData(userId, adminData) + + const userData = store.users.get(userId) + expect(userData.deactivated).to.eql(false) + expect(userData.tags).to.eql(new Set(['one', 'two'])) + }) + + it('updateRight', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + store.updateRight(userId, 'right1', true) + store.updateRight(userId, 'right2', false) + + const userData = store.users.get(userId) + expect(userData.rights.right1).to.eql(true) + expect(userData.rights.right2).to.eql(false) + }) + + it('clearFollowLists', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + const userData = store.users.get(userId) + store.relationshipsLists.friends.get(userData).add('2') + store.relationshipsLists.friends.get(userData).add('3') + store.relationshipsLists.followers.get(userData).add('4') + store.relationshipsLists.followers.get(userData).add('5') + store.clearFollowLists(userId) + + expect(store.relationshipsLists.friends.get(userData)).to.have.length(0) + expect(store.relationshipsLists.followers.get(userData)).to.have.length(0) + }) + }) + + describe('login/logout', () => { + describe('login', () => { + it('normal login', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const spies = [ + // Misc initialization + vi.spyOn(useSyncConfigStore(), 'initSyncConfig'), + vi.spyOn(useUserHighlightStore(), 'initUserHighlight'), + vi.spyOn(useInterfaceStore(), 'applyTheme'), + vi.spyOn(useInterfaceStore(), 'onLogin'), + vi.spyOn(useEmojiStore(), 'fetchEmoji'), + + // Timeline / Notifications + vi.spyOn(useNotificationsStore(), 'activate'), + vi.spyOn(useTimelinesStore(), 'activatePersistents'), + + // Fetchers + vi.spyOn(useChatsStore(), 'startFetching'), + vi.spyOn(useListsStore(), 'startFetching'), + vi.spyOn(useAnnouncementsStore(), 'startFetching'), + vi.spyOn(useBookmarkFoldersStore(), 'startFetching'), + vi.spyOn(useStreamingStore(), 'initSocket'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + mockUser(), + { ...mockUser({ name: 'John', screen_name: 'snake' }) }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + + expect(store.loggedIn).to.eql(false) + await store.loginUser('ACCESS_TOKEN') + expect(store.loggedIn).to.eql(true) + + // We should be in the store ourselves + expect(store.users).to.have.length(1) + expect(store.usersByName).to.have.length(1) + expect(store.usersByURL).to.have.length(1) + expect(store.relationships).to.have.length(0) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + + it('bad credentials', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockMastoAPIUser()), { + status: 403, + statusText: 'Forbidden', + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const spies = [vi.spyOn(useOAuthStore(), 'clearToken')] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + const store = useUsersStore() + const exec = async () => { + await store.loginUser('ACCESS_TOKEN') + } + + await expect(exec).rejects.to.throw(Error) + + expect(store.loggedIn).to.eql(false) + + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + }) + + describe('logout', () => { + it('normal logout', async () => { + const revokeApi = vi + .fn() + .mockResolvedValueOnce( + // Ensure APP + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + // Revoke Token + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', revokeApi) + + const spies = [ + // Misc initialization + /* 0 */ vi.spyOn(useStatusesStore(), 'resetStatuses'), + /* 1 */ vi.spyOn(useInterfaceStore(), 'onLogout'), + + // Timeline / Notifications + /* 2 */ vi.spyOn(useNotificationsStore(), 'deactivate'), + /* 3 */ vi.spyOn(useNotificationsStore(), 'pause'), + /* 4 */ vi.spyOn(useNotificationsStore(), 'resume'), + /* 5 */ vi.spyOn(useTimelinesStore(), 'deactivateAll'), + /* 6 */ vi.spyOn(useTimelinesStore(), 'pauseAll'), + /* 7 */ vi.spyOn(useTimelinesStore(), 'resumeAll'), + + // Fetchers + /* 8 */ vi.spyOn(useChatsStore(), 'resetChats'), + /* 9 */ vi.spyOn(useChatsStore(), 'stopFetching'), + /* 10 */ vi.spyOn(useListsStore(), 'stopFetching'), + /* 11 */ vi.spyOn(useAnnouncementsStore(), 'stopFetching'), + /* 12 */ vi.spyOn(useBookmarkFoldersStore(), 'stopFetching'), + /* 13 */ vi.spyOn(useStreamingStore(), 'stopSocket'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + store.currentUser = mockUser() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + mockUser(), + { + ...mockUser({ name: 'John', screen_name: 'snake' }), + relationship: { id: userId, following: true }, + }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + expect(store.loggedIn).to.eql(true) + await store.logout() + expect(store.loggedIn).to.eql(false) + expect(revokeApi).to.have.been.called + expect(store.users).to.have.length(0) + expect(store.usersByName).to.have.length(0) + expect(store.usersByURL).to.have.length(0) + expect(store.relationships).to.have.length(0) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + + it('failed logout', async () => { + const revokeApi = vi + .fn() + .mockResolvedValueOnce( + // Ensure APP + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + // Revoke Token + new Response( + JSON.stringify('Oopsie-woopsie pleroma made a fucky-wucky'), + { + status: 500, + statusText: 'Internal Server Error', + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', revokeApi) + + // NOTE: Order is not checked for! + const spies = [ + // ## PAUSE ## + // Timeline / Notifications + /* 0 */ vi.spyOn(useTimelinesStore(), 'pauseAll'), + /* 1 */ vi.spyOn(useNotificationsStore(), 'pause'), + + // Fetchers (Pauseless) + /* 2 */ vi.spyOn(useListsStore(), 'stopFetching'), + /* 3 */ vi.spyOn(useChatsStore(), 'stopFetching'), + /* 4 */ vi.spyOn(useAnnouncementsStore(), 'stopFetching'), + /* 5 */ vi.spyOn(useBookmarkFoldersStore(), 'stopFetching'), + + // ## RESUME ## + // Timeline / Notifications + /* 6 */ vi.spyOn(useNotificationsStore(), 'resume'), + /* 7 */ vi.spyOn(useTimelinesStore(), 'resumeAll'), + + // Fetchers (Pauseless) + /* 8 */ vi.spyOn(useListsStore(), 'startFetching'), + /* 9 */ vi.spyOn(useChatsStore(), 'startFetching'), + /* 10 */ vi.spyOn(useAnnouncementsStore(), 'startFetching'), + /* 11 */ vi.spyOn(useBookmarkFoldersStore(), 'startFetching'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + store.currentUser = mockUser() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + mockUser(), + { + ...mockUser({ name: 'John', screen_name: 'snake' }), + relationship: { id: userId, following: true }, + }, + { ...mockUser({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + expect(store.loggedIn).to.eql(true) + await store.logout() + expect(store.loggedIn).to.eql(true) + expect(revokeApi).to.have.been.called + expect(store.users).to.have.length(1) + expect(store.usersByName).to.have.length(1) + expect(store.usersByURL).to.have.length(1) + expect(store.relationships).to.have.length(1) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + }) + }) + + describe('actions', () => { + describe('follow', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('instant follow case', async () => { + const followApi = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: true, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + const store = useUsersStore() + + await store.followUser(userId) + expect(followApi).to.have.been.called + + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + expect(followApi).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: '{}', + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('delayed follow case', async () => { + const followApi = vi + .fn() + .mockResolvedValueOnce( + // Follow + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + // Check relationship + new Response( + JSON.stringify({ + id: userId, + following: true, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + + const store = useUsersStore() + + await store.followUser(userId) + expect(followApi).to.have.been.called + + expect(store.followPollers).to.have.length(1) + expect(store.followPollersAttempts).to.have.length(1) + expect(store.followPollersAttempts.get(userId)).to.eql(3) + + await vi.runAllTimersAsync() + expect(followApi).to.have.been.called + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + }) + + it('unresolved follow case', async () => { + const followApi = vi.fn().mockResolvedValueOnce( + // Follow + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + followApi // mockImplementation because we need to re-create Response + .mockImplementation( + () => + // Check relationship + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + + const store = useUsersStore() + + await store.followUser(userId) + expect(store.followPollers).to.have.length(1) + expect(store.followPollersAttempts).to.have.length(1) + expect(store.followPollersAttempts.get(userId)).to.eql(3) + + await vi.runAllTimersAsync() + expect(store.followPollersAttempts.get(userId)).to.eql(2) + + await vi.runAllTimersAsync() + expect(store.followPollersAttempts.get(userId)).to.eql(1) + + await vi.runAllTimersAsync() + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + + expect(followApi).to.have.callCount(4) // 1 follow + 3 retries + }) + }) + + it('unfollow', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.unfollowUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_UNFOLLOW_URL(userId), + DEFAULT_OPTIONS(), + ) + }) + + it('subscribe', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + subscribing: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.subscribeUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ notify: true }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('unsubscribe', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + subscribing: false, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.unsubscribeUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ notify: false }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('showReblogs', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + showing_reblogs: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.showReblogs(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ reblogs: true }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('hideReblogs', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + showing_reblogs: false, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.hideReblogs(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ reblogs: false }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it.each([ + 'unmute', + 'unblock', + 'removeUserFromFollowers', + ])('%s', async (action) => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API[apiUrl](userId), + DEFAULT_OPTIONS(), + ) + }) + + describe.each(['mute', 'block'])('%s', (action) => { + it('normal', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useTimelinesStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useNotificationsStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId) + + expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), { + body: '{}', + ...DEFAULT_OPTIONS(), + }) + }) + + it('with expiration', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useTimelinesStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useNotificationsStore(), 'wipeStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId, 20) + const argument = action === 'mute' ? 'expires_in' : 'duration' + + expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), { + body: `{"${argument}":20}`, + ...DEFAULT_OPTIONS(), + }) + }) + }) + + it.each(['mute', 'unmute'])('%s domain', async (action) => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.currentUser = mockUser() + store.currentUser.domainMutes = new Set() + if (action === 'unmute') { + store.currentUser.domainMutes.add('example.com') + } + await store[action + 'Domain']('example.com') + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_DOMAIN_BLOCKS_URL, + { + body: JSON.stringify({ domain: 'example.com' }), + ...DEFAULT_OPTIONS(action === 'mute' ? 'POST' : 'DELETE'), + }, + ) + + if (action === 'mute') { + expect(store.currentUser.domainMutes).to.include('example.com') + } else { + expect(store.currentUser.domainMutes).to.not.include('example.com') + } + }) + + it.each([ + 'muteDomain', + 'unmuteDomain', + 'muteUser', + 'unmuteUser', + 'blockUser', + 'unblockUser', + ])('%ss', async (action) => { + const store = useUsersStore() + store[action] = vi.fn().mockResolvedValue(async () => { + /* no-op */ + }) + store[action + 's'](['1', '2', '3']) + expect(store[action]).to.have.been.calledWith('1') + expect(store[action]).to.have.been.calledWith('2') + expect(store[action]).to.have.been.calledWith('3') + }) + }) + + describe('getters', () => { + it('relationship returns a placeholder if relationship info is missing', () => { + const store = useUsersStore() + + expect(store.relationship(userId)).to.eql({ id: userId, loading: true }) + }) + + it('relationship returns a placeholder if relationship info is missing while user is present', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.relationship(userId)).to.eql({ id: userId, loading: true }) + }) + + it('findUser returns user with matching id', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.findUser(userId).id).to.eql(userId) + }) + + it('findUserByName returns user with matching screen_name', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.findUserByName(mockUser().screen_name).id).to.eql(userId) + }) + + it('findUserByName returns user with matching url', () => { + const store = useUsersStore() + store.addNewUsers({ data: [mockUser()], timestamp: 1 }) + + expect(store.findUserByUrl(mockUser().url).id).to.eql(userId) + }) + }) +})