diff --git a/app/modules/Common/services/listService.ts b/app/modules/Common/services/listService.ts
index ef24e17a..c12f2fb3 100644
--- a/app/modules/Common/services/listService.ts
+++ b/app/modules/Common/services/listService.ts
@@ -1,3 +1,4 @@
+import { useNuxtApp } from '#app'
export const listService = {
async fetchList(path: string, nextCursor: string): Promise {
diff --git a/app/modules/Common/test/unit/MediaGrid.spec.ts b/app/modules/Common/test/unit/MediaGrid.spec.ts
new file mode 100644
index 00000000..a5a8e046
--- /dev/null
+++ b/app/modules/Common/test/unit/MediaGrid.spec.ts
@@ -0,0 +1,337 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { ref } from 'vue'
+import { createI18n } from 'vue-i18n'
+import MediaGrid from '../../components/MediaGrid/MediaGrid.vue'
+
+const i18n = createI18n({
+ locale: 'en',
+ messages: {
+ en: {
+ tweets: {
+ loading: { tweets: 'Loading tweets...' },
+ errors: { loadFailed: 'Failed to load', tryAgain: 'Try again' },
+ empty: { noTweets: 'No tweets', noTweetsDescription: 'No tweets to display' },
+ },
+ },
+ ar: {},
+ },
+})
+
+// Mock useGenericInfiniteQuery composable
+vi.mock('~/modules/Common/composables/useGenericInfiniteQuery', () => ({
+ useGenericInfiniteQuery: vi.fn(() => ({
+ items: ref([]),
+ isFetching: ref(false),
+ isFetchingNextPage: ref(false),
+ isPending: ref(false),
+ error: ref(null),
+ loadMoreTrigger: ref(null),
+ refetch: vi.fn(),
+ })),
+}))
+
+// Mock InfiniteList component
+vi.mock('~/modules/Common/components/InfiniteList', () => ({
+ InfiniteList: {
+ name: 'InfiniteList',
+ props: ['modelValue', 'items', 'isPending', 'isFetching', 'isFetchingNextPage', 'error'],
+ emits: ['update:modelValue', 'retry'],
+ template: '
',
+ },
+}))
+
+vi.mock('#app', () => ({
+ useNuxtApp: () => ({
+ $listService: {
+ fetchList: vi.fn(),
+ },
+ }),
+}))
+
+describe('MediaGrid Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render media grid container', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.find('.infinite-list').exists()).toBe(true)
+ })
+
+ it('should handle fetchingSource prop', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: 'profile',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.props('fetchingSource')).toBe('profile')
+ })
+
+ it('should initialize useGenericInfiniteQuery hook', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should have videoDurations reactive object', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should render InfiniteList component', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ const infiniteList = wrapper.findComponent({ name: 'InfiniteList' })
+ expect(infiniteList.exists()).toBe(true)
+ })
+
+ it('should pass fetchingSource to query', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: 'timeline',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.props('fetchingSource')).toBe('timeline')
+ })
+
+ it('should handle null fetchingSource', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.props('fetchingSource')).toBeNull()
+ })
+
+ it('should have formatDuration method', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should handle handleVideoMetadata callback', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should support prop updates', async () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: 'profile',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ await wrapper.setProps({ fetchingSource: 'search' })
+ expect(wrapper.props('fetchingSource')).toBe('search')
+ })
+
+ it('should maintain reactive state', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.find('.infinite-list').exists()).toBe(true)
+ })
+
+ it('should render without errors', () => {
+ expect(() => {
+ mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+ }).not.toThrow()
+ })
+
+ it('should support slot rendering', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.find('.infinite-list').exists()).toBe(true)
+ })
+
+ it('should handle component lifecycle', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ wrapper.unmount()
+ })
+
+ it('should use correct grid styling', () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: null,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ const infiniteList = wrapper.findComponent({ name: 'InfiniteList' })
+ expect(infiniteList.exists()).toBe(true)
+ })
+
+ it('should handle reactive fetchingSource changes', async () => {
+ const wrapper = mount(MediaGrid, {
+ props: {
+ fetchingSource: 'profile',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ NuxtImg: true,
+ NuxtLink: true,
+ },
+ },
+ })
+
+ const before = wrapper.props('fetchingSource')
+ await wrapper.setProps({ fetchingSource: 'explore' })
+ const after = wrapper.props('fetchingSource')
+
+ expect(before).not.toBe(after)
+ expect(after).toBe('explore')
+ })
+})
diff --git a/app/modules/Common/test/unit/Popup.spec.ts b/app/modules/Common/test/unit/Popup.spec.ts
new file mode 100644
index 00000000..5f8324d2
--- /dev/null
+++ b/app/modules/Common/test/unit/Popup.spec.ts
@@ -0,0 +1,333 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createI18n } from 'vue-i18n'
+import Popup from '../../components/Popup/Popup.vue'
+
+const i18n = createI18n({
+ locale: 'en',
+ messages: {
+ en: {},
+ ar: {},
+ },
+})
+
+// Mock Teleport component
+vi.mock('vue', async () => {
+ const actual = await vi.importActual('vue')
+ return {
+ ...actual,
+ Teleport: {
+ name: 'Teleport',
+ props: ['to'],
+ template: '',
+ },
+ }
+})
+
+describe('Popup Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render when isOpen is true', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ title: 'Test Title',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ slots: {
+ default: 'Popup Content',
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should not render when isOpen is false', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: false,
+ title: 'Test Title',
+ },
+ global: {
+ plugins: [i18n],
+ },
+ })
+
+ expect(wrapper.html()).not.toContain('popup-content')
+ })
+
+ it('should display title when provided', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ title: 'My Title',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ const text = wrapper.text()
+ expect(text).toContain('My Title')
+ })
+
+ it('should show close button when hasCloseButton is true', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ hasCloseButton: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.find('#close-popup-btn').exists()).toBe(true)
+ })
+
+ it('should show back button when hasBackButton is true', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ hasBackButton: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.find('#back-popup-btn').exists()).toBe(true)
+ })
+
+ it('should emit close event when close button is clicked', async () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ hasCloseButton: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ const closeBtn = wrapper.find('#close-popup-btn')
+ if (closeBtn.exists()) {
+ await closeBtn.trigger('click')
+ expect(wrapper.emitted('close')).toBeTruthy()
+ }
+ })
+
+ it('should emit back event when back button is clicked', async () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ hasBackButton: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ const backBtn = wrapper.find('#back-popup-btn')
+ if (backBtn.exists()) {
+ await backBtn.trigger('click')
+ expect(wrapper.emitted('back')).toBeTruthy()
+ }
+ })
+
+ it('should support different positioning classes', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ xPosition: 'start',
+ yPosition: 'end',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should accept custom contentClass', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ contentClass: 'custom-content-class',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ const content = wrapper.find('#popup-content')
+ if (content.exists()) {
+ expect(content.classes()).toContain('custom-content-class')
+ }
+ })
+
+ it('should apply header class when provided', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ headerClass: 'custom-header-class',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ const html = wrapper.html()
+ expect(html).toContain('custom-header-class')
+ })
+
+ it('should render slot content', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ slots: {
+ default: 'Test Slot Content',
+ },
+ })
+
+ expect(wrapper.text()).toContain('Test Slot Content')
+ })
+
+ it('should support custom background color', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ bgColor: 'bg-custom-color',
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should render with default props', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.vm).toBeDefined()
+ })
+
+ it('should handle hasCloseButton default value', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.props('hasCloseButton')).toBe(true)
+ })
+
+ it('should handle hasBackButton default value', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.props('hasBackButton')).toBe(false)
+ })
+
+ it('should hide close button when hasCloseButton is false', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ hasCloseButton: false,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.find('#close-popup-btn').exists()).toBe(false)
+ })
+
+ it('should hide back button when hasBackButton is false', () => {
+ const wrapper = mount(Popup, {
+ props: {
+ isOpen: true,
+ hasBackButton: false,
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Teleport: false,
+ },
+ },
+ })
+
+ expect(wrapper.find('#back-popup-btn').exists()).toBe(false)
+ })
+})
diff --git a/app/modules/Common/test/unit/Tabs.spec.ts b/app/modules/Common/test/unit/Tabs.spec.ts
new file mode 100644
index 00000000..3afc6033
--- /dev/null
+++ b/app/modules/Common/test/unit/Tabs.spec.ts
@@ -0,0 +1,191 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { ref } from 'vue'
+import Tabs from '../../components/Tabs/Tabs.vue'
+
+describe('Tabs Component', () => {
+ const defaultTabs = [
+ { label: 'Tab 1', value: 'tab1', test_id: 'tab-1' },
+ { label: 'Tab 2', value: 'tab2', test_id: 'tab-2' },
+ { label: 'Tab 3', value: 'tab3', test_id: 'tab-3' },
+ ]
+
+ const mockOnChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render all tabs', () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ expect(wrapper.text()).toContain('Tab 1')
+ expect(wrapper.text()).toContain('Tab 2')
+ expect(wrapper.text()).toContain('Tab 3')
+ })
+
+ it('should highlight active tab', () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ const activeButton = wrapper.find('[class*="text-primary"]')
+ expect(activeButton.exists()).toBe(true)
+ })
+
+ it('should call onChange when tab is clicked', async () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ const tab2 = wrapper.find('#tab-2')
+ await tab2.trigger('click')
+
+ expect(mockOnChange).toHaveBeenCalledWith('tab2')
+ })
+
+ it('should render correct number of tabs', () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ const tabs = wrapper.findAll('li')
+ expect(tabs.length).toBe(3)
+ })
+
+ it('should handle single tab', () => {
+ const singleTab = [{ label: 'Only Tab', value: 'only', test_id: 'only-tab' }]
+
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: singleTab,
+ activeTab: 'only',
+ onChange: mockOnChange,
+ },
+ })
+
+ expect(wrapper.text()).toContain('Only Tab')
+ const tabs = wrapper.findAll('li')
+ expect(tabs.length).toBe(1)
+ })
+
+ it('should display underline on active tab', () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab2',
+ onChange: mockOnChange,
+ },
+ })
+
+ const activeTab = wrapper.find('#tab-2 button span')
+ expect(activeTab.exists()).toBe(true)
+ })
+
+ it('should update active tab when prop changes', async () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ await wrapper.setProps({ activeTab: 'tab3' })
+
+ expect(wrapper.emitted()).toBeDefined()
+ })
+
+ it('should handle tabs with special characters in labels', () => {
+ const specialTabs = [
+ { label: 'Tab & Test', value: 'tab1', test_id: 'tab-1' },
+ { label: 'Tab ', value: 'tab2', test_id: 'tab-2' },
+ ]
+
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: specialTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ expect(wrapper.text()).toContain('Tab & Test')
+ expect(wrapper.text()).toContain('Tab ')
+ })
+
+ it('should have correct test_id attributes', () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ expect(wrapper.find('#tab-1').exists()).toBe(true)
+ expect(wrapper.find('#tab-2').exists()).toBe(true)
+ expect(wrapper.find('#tab-3').exists()).toBe(true)
+ })
+
+ it('should not call onChange on initial render', () => {
+ mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should handle rapid tab switching', async () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ await wrapper.find('#tab-2').trigger('click')
+ await wrapper.find('#tab-3').trigger('click')
+ await wrapper.find('#tab-1').trigger('click')
+
+ expect(mockOnChange).toHaveBeenCalledTimes(3)
+ })
+
+ it('should maintain tab order', () => {
+ const wrapper = mount(Tabs, {
+ props: {
+ tabs: defaultTabs,
+ activeTab: 'tab1',
+ onChange: mockOnChange,
+ },
+ })
+
+ const labels = wrapper.findAll('button').map(btn => btn.text())
+ expect(labels[0]).toContain('Tab 1')
+ expect(labels[1]).toContain('Tab 2')
+ expect(labels[2]).toContain('Tab 3')
+ })
+})
diff --git a/app/modules/Common/test/unit/UserCard.spec.ts b/app/modules/Common/test/unit/UserCard.spec.ts
new file mode 100644
index 00000000..f1dbd588
--- /dev/null
+++ b/app/modules/Common/test/unit/UserCard.spec.ts
@@ -0,0 +1,418 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import UserCard from '../../components/UserCard/UserCard.vue'
+import type { FollowUser } from '~/modules/profile/types/user'
+
+vi.mock('~/modules/profile/components/ProfileHeader/SubComponents/ProfileFollowAction.vue', () => ({
+ default: {
+ name: 'ProfileFollowAction',
+ template: '',
+ props: ['userId', 'username'],
+ },
+}))
+
+vi.mock('../../components/UserImage/UserImage.vue', () => ({
+ default: {
+ name: 'UserImage',
+ template: 'Avatar
',
+ props: ['imageUrl', 'name', 'compact'],
+ },
+}))
+
+describe('UserCard Component', () => {
+ const mockUser: FollowUser = {
+ user_id: '1',
+ id: '1',
+ name: 'John Doe',
+ username: 'johndoe',
+ bio: 'Software developer',
+ avatar_url: 'https://example.com/avatar.jpg',
+ is_following: false,
+ is_follower: false,
+ is_muted: false,
+ is_blocked: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render user card', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('John Doe')
+ })
+
+ it('should display user name', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('John Doe')
+ })
+
+ it('should display user username', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('@johndoe')
+ })
+
+ it('should display user bio when hideBio is false', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ hideBio: false,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('Software developer')
+ })
+
+ it('should hide bio when hideBio is true', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ hideBio: true,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).not.toContain('Software developer')
+ })
+
+ it('should show "Follows you" badge when is_follower is true', () => {
+ const followerUser = { ...mockUser, is_follower: true }
+
+ const wrapper = mount(UserCard, {
+ props: {
+ user: followerUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('Follows you')
+ })
+
+ it('should not show "Follows you" badge when is_follower is false', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).not.toContain('Follows you')
+ })
+
+ it('should render follow action button', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('.follow-action-btn').exists()).toBe(true)
+ })
+
+ it('should link to user profile', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ props: ['to'],
+ },
+ },
+ },
+ })
+
+ const link = wrapper.find('a')
+ expect(link.attributes('to')).toBe('/johndoe')
+ })
+
+ it('should render user image component', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('.user-image').exists()).toBe(true)
+ })
+
+ it('should handle user without bio', () => {
+ const userNoBio = { ...mockUser, bio: '' }
+
+ const wrapper = mount(UserCard, {
+ props: {
+ user: userNoBio,
+ hideBio: false,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('John Doe')
+ expect(wrapper.text()).toContain('@johndoe')
+ })
+
+ it('should handle special characters in user data', () => {
+ const specialUser: FollowUser = {
+ ...mockUser,
+ name: 'John & Jane',
+ username: 'john_jane_123',
+ bio: 'Developer @ Company',
+ }
+
+ const wrapper = mount(UserCard, {
+ props: {
+ user: specialUser,
+ hideBio: false,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('John & Jane')
+ expect(wrapper.text()).toContain('@john_jane_123')
+ })
+
+ it('should handle user with multiple follower states', async () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ hideBio: false,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ await wrapper.setProps({
+ user: { ...mockUser, is_follower: true },
+ })
+
+ expect(wrapper.text()).toContain('Follows you')
+ })
+
+ it('should render truncated text for long usernames', () => {
+ const longUsernameUser: FollowUser = {
+ ...mockUser,
+ username: 'very_long_username_that_should_be_truncated',
+ }
+
+ const wrapper = mount(UserCard, {
+ props: {
+ user: longUsernameUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('very_long_username_that_should_be_truncated')
+ })
+
+ it('should apply hover effects', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ const container = wrapper.find('[class*="hover:"]')
+ expect(container.exists()).toBe(true)
+ })
+
+ it('should render with correct component structure', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('.follow-action-btn').exists()).toBe(true)
+ expect(wrapper.find('.user-image').exists()).toBe(true)
+ })
+
+ it('should pass correct props to UserImage', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('.user-image').exists()).toBe(true)
+ })
+
+ it('should pass correct props to ProfileFollowAction', () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('.follow-action-btn').exists()).toBe(true)
+ })
+
+ it('should handle bio line clamping', () => {
+ const userLongBio = {
+ ...mockUser,
+ bio: 'This is a very long bio that should be clamped to show only a limited number of lines and then truncated with ellipsis to indicate there is more content',
+ }
+
+ const wrapper = mount(UserCard, {
+ props: {
+ user: userLongBio,
+ hideBio: false,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('[class*="line-clamp"]').exists()).toBe(true)
+ })
+
+ it('should be fully interactive', async () => {
+ const wrapper = mount(UserCard, {
+ props: {
+ user: mockUser,
+ },
+ global: {
+ stubs: {
+ NuxtLink: {
+ template: '',
+ props: ['to'],
+ },
+ },
+ },
+ })
+
+ expect(wrapper.find('a').attributes('to')).toBe('/johndoe')
+ expect(wrapper.find('.follow-action-btn').exists()).toBe(true)
+ })
+})
diff --git a/app/modules/Common/test/unit/cacheInvalidation.spec.ts b/app/modules/Common/test/unit/cacheInvalidation.spec.ts
new file mode 100644
index 00000000..ed4bcbbc
--- /dev/null
+++ b/app/modules/Common/test/unit/cacheInvalidation.spec.ts
@@ -0,0 +1,418 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { cacheInvalidation } from '../../queries/cacheInvalidation'
+import { queryKeys } from '../../queries/queryKeys'
+
+describe('cacheInvalidation', () => {
+ let mockQueryClient: any
+
+ beforeEach(() => {
+ mockQueryClient = {
+ setQueryData: vi.fn(),
+ invalidateQueries: vi.fn(),
+ removeQueries: vi.fn(),
+ setQueriesData: vi.fn(),
+ clear: vi.fn(),
+ }
+ vi.clearAllMocks()
+ })
+
+ describe('toggleBlockedInCache', () => {
+ it('should update blocked status in cache', () => {
+ const userId = 'user-123'
+ const isBlocked = true
+
+ cacheInvalidation.toggleBlockedInCache(mockQueryClient, userId, isBlocked)
+
+ expect(mockQueryClient.setQueryData).toHaveBeenCalledWith(
+ queryKeys.settings.blockedUsers(),
+ expect.any(Function),
+ )
+ })
+
+ it('should not modify cache if data is undefined', () => {
+ const userId = 'user-123'
+ const updater = mockQueryClient.setQueryData.mock.calls[0]?.[1]
+
+ cacheInvalidation.toggleBlockedInCache(mockQueryClient, userId, true)
+
+ expect(mockQueryClient.setQueryData).toHaveBeenCalled()
+ })
+
+ it('should handle toggle from blocked to unblocked', () => {
+ cacheInvalidation.toggleBlockedInCache(mockQueryClient, 'user-123', false)
+ cacheInvalidation.toggleBlockedInCache(mockQueryClient, 'user-123', true)
+
+ expect(mockQueryClient.setQueryData).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('toggleMutedInCache', () => {
+ it('should update muted status in cache', () => {
+ const userId = 'user-456'
+ const isMuted = true
+
+ cacheInvalidation.toggleMutedInCache(mockQueryClient, userId, isMuted)
+
+ expect(mockQueryClient.setQueryData).toHaveBeenCalledWith(
+ queryKeys.settings.mutedUsers(),
+ expect.any(Function),
+ )
+ })
+
+ it('should handle toggle from muted to unmuted', () => {
+ cacheInvalidation.toggleMutedInCache(mockQueryClient, 'user-456', true)
+ cacheInvalidation.toggleMutedInCache(mockQueryClient, 'user-456', false)
+
+ expect(mockQueryClient.setQueryData).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('Tweet Mutations', () => {
+ it('onTweetCreate should invalidate user posts cache', () => {
+ const userId = 'user-123'
+ cacheInvalidation.onTweetCreate(mockQueryClient, userId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.list(`/users/${userId}/posts`),
+ })
+ })
+
+ it('onTweetDelete should remove tweet details cache', () => {
+ const tweetId = 'tweet-123'
+ cacheInvalidation.onTweetDelete(mockQueryClient, tweetId)
+
+ expect(mockQueryClient.removeQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.details(tweetId),
+ })
+ })
+
+ it('onReplyCreate should invalidate parent tweet and related caches', () => {
+ const parentTweetId = 'tweet-456'
+ const userId = 'user-123'
+
+ cacheInvalidation.onReplyCreate(mockQueryClient, parentTweetId, userId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.details(parentTweetId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.list(`/users/${userId}/replies`),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.list('/timeline/following'),
+ })
+ })
+
+ it('onReplyDelete should invalidate parent tweet and timeline caches', () => {
+ const parentTweetId = 'tweet-456'
+
+ cacheInvalidation.onReplyDelete(mockQueryClient, parentTweetId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.details(parentTweetId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.list('/timeline/following'),
+ })
+ })
+
+ it('onTweetLikeChange should invalidate tweet details and likes cache', () => {
+ const tweetId = 'tweet-123'
+
+ cacheInvalidation.onTweetLikeChange(mockQueryClient, tweetId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.details(tweetId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.list('/users/me/liked-posts'),
+ })
+ })
+
+ it('onTweetRepostChange should invalidate tweet details and user timeline caches', () => {
+ const tweetId = 'tweet-123'
+ const path = '/timeline/following'
+
+ cacheInvalidation.onTweetRepostChange(mockQueryClient, tweetId, path)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.details(tweetId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.list(path),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.list('/timeline/following'),
+ })
+ })
+
+ it('onTweetBookmarkChange should invalidate tweet details and bookmarks cache', () => {
+ const tweetId = 'tweet-123'
+
+ cacheInvalidation.onTweetBookmarkChange(mockQueryClient, tweetId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.details(tweetId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.bookmarks.all,
+ })
+ })
+
+ it('onTweetUpdate should invalidate tweet summary, details and search cache', () => {
+ const tweetId = 'tweet-123'
+
+ cacheInvalidation.onTweetUpdate(mockQueryClient, tweetId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.summary(tweetId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.details(tweetId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.search.all,
+ })
+ })
+ })
+
+ describe('Profile Mutations', () => {
+ it('onProfileUpdate should invalidate profile and tweets caches', () => {
+ const username = 'johndoe'
+
+ cacheInvalidation.onProfileUpdate(mockQueryClient, username)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.me(),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.profile(username),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.tweets.all,
+ })
+ })
+
+ it('onUsernameChange should remove old profile and invalidate caches', () => {
+ const oldUsername = 'oldname'
+
+ cacheInvalidation.onUsernameChange(mockQueryClient, oldUsername)
+
+ expect(mockQueryClient.removeQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.profile(oldUsername),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.me(),
+ })
+ })
+
+ it('onAvatarChange should invalidate profile and tweets caches', () => {
+ const username = 'johndoe'
+
+ cacheInvalidation.onAvatarChange(mockQueryClient, username)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.me(),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.profile(username),
+ })
+ })
+
+ it('onCoverPhotoChange should invalidate me and profile caches', () => {
+ const username = 'johndoe'
+
+ cacheInvalidation.onCoverPhotoChange(mockQueryClient, username)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.me(),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.profile(username),
+ })
+ })
+ })
+
+ describe('User Action Mutations', () => {
+ it('onFollowChange should invalidate relevant user and tweet caches', () => {
+ const targetUserId = 'user-123'
+ const targetUsername = 'johndoe'
+ const currentUserId = 'user-456'
+ const isFollowing = true
+
+ cacheInvalidation.onFollowChange(
+ mockQueryClient,
+ targetUserId,
+ targetUsername,
+ currentUserId,
+ isFollowing,
+ )
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.byId(targetUserId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.profile(targetUsername),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.followers(targetUserId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.following(currentUserId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.me(),
+ })
+ })
+
+ it('onFollowChange should also update tweet caches with follower delta', () => {
+ const targetUserId = 'user-123'
+ const targetUsername = 'johndoe'
+ const currentUserId = 'user-456'
+
+ cacheInvalidation.onFollowChange(
+ mockQueryClient,
+ targetUserId,
+ targetUsername,
+ currentUserId,
+ true,
+ )
+
+ expect(mockQueryClient.setQueriesData).toHaveBeenCalledWith(
+ { queryKey: ['tweets'] },
+ expect.any(Function),
+ )
+ })
+
+ it('onBlockChange should invalidate user and notifications caches', () => {
+ const targetUserId = 'user-123'
+
+ cacheInvalidation.onBlockChange(mockQueryClient, targetUserId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.byId(targetUserId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.search.all,
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.notifications.all,
+ })
+ })
+
+ it('onMuteChange should invalidate user and search caches', () => {
+ const targetUserId = 'user-123'
+
+ cacheInvalidation.onMuteChange(mockQueryClient, targetUserId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.byId(targetUserId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.search.all,
+ })
+ })
+
+ it('onRemoveFollower should invalidate follower and me caches', () => {
+ const currentUserId = 'user-123'
+
+ cacheInvalidation.onRemoveFollower(mockQueryClient, currentUserId)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.followers(currentUserId),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.me(),
+ })
+ })
+ })
+
+ describe('Auth Mutations', () => {
+ it('onLogout should clear all query data', () => {
+ cacheInvalidation.onLogout(mockQueryClient)
+
+ expect(mockQueryClient.clear).toHaveBeenCalled()
+ })
+
+ it('onLogin should invalidate user and auth caches', () => {
+ cacheInvalidation.onLogin(mockQueryClient)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.users.me(),
+ })
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.auth.user(),
+ })
+ })
+ })
+
+ describe('Chat/Conversation Mutations', () => {
+ it('onConversationCreate should invalidate conversations cache', () => {
+ cacheInvalidation.onConversationCreate(mockQueryClient)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.conversations.all,
+ })
+ })
+
+ it('onFirstMessageSent should invalidate conversations cache', () => {
+ cacheInvalidation.onFirstMessageSent(mockQueryClient)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.conversations.all,
+ })
+ })
+
+ it('onRemoveNotification should invalidate notifications cache', () => {
+ cacheInvalidation.onRemoveNotification(mockQueryClient)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.notifications.all,
+ })
+ })
+
+ it('onRemoveMention should invalidate mentions cache', () => {
+ cacheInvalidation.onRemoveMention(mockQueryClient)
+
+ expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.notifications.mentions,
+ })
+ })
+ })
+
+ describe('cache invalidation methods', () => {
+ it('should have all expected methods', () => {
+ const expectedMethods = [
+ 'toggleBlockedInCache',
+ 'toggleMutedInCache',
+ 'onTweetCreate',
+ 'onTweetDelete',
+ 'onReplyCreate',
+ 'onReplyDelete',
+ 'onTweetLikeChange',
+ 'onTweetRepostChange',
+ 'onTweetBookmarkChange',
+ 'onTweetUpdate',
+ 'onProfileUpdate',
+ 'onUsernameChange',
+ 'onAvatarChange',
+ 'onCoverPhotoChange',
+ 'onFollowChange',
+ 'onBlockChange',
+ 'onMuteChange',
+ 'onRemoveFollower',
+ 'onLogout',
+ 'onLogin',
+ 'onConversationCreate',
+ 'onFirstMessageSent',
+ 'onRemoveNotification',
+ 'onRemoveMention',
+ ]
+
+ expectedMethods.forEach((method) => {
+ expect(cacheInvalidation).toHaveProperty(method)
+ expect(typeof cacheInvalidation[method as keyof typeof cacheInvalidation]).toBe('function')
+ })
+ })
+ })
+})
diff --git a/app/modules/Common/test/unit/constants.spec.ts b/app/modules/Common/test/unit/constants.spec.ts
new file mode 100644
index 00000000..5915e8c8
--- /dev/null
+++ b/app/modules/Common/test/unit/constants.spec.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest'
+import { LOCALE_COOKIE_KEY } from '../../constants/localStorageConstants'
+import { tooltipContentClass } from '../../constants/stylesConstants'
+
+describe('Constants', () => {
+ describe('localStorageConstants', () => {
+ it('should export LOCALE_COOKIE_KEY', () => {
+ expect(LOCALE_COOKIE_KEY).toBeDefined()
+ })
+
+ it('should have correct LOCALE_COOKIE_KEY value', () => {
+ expect(LOCALE_COOKIE_KEY).toBe('i18n_redirected')
+ })
+
+ it('should be a string', () => {
+ expect(typeof LOCALE_COOKIE_KEY).toBe('string')
+ })
+
+ it('should not be empty', () => {
+ expect(LOCALE_COOKIE_KEY.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('stylesConstants', () => {
+ it('should export tooltipContentClass', () => {
+ expect(tooltipContentClass).toBeDefined()
+ })
+
+ it('should be a string', () => {
+ expect(typeof tooltipContentClass).toBe('string')
+ })
+
+ it('should contain tailwind classes', () => {
+ expect(tooltipContentClass).toContain('text-white')
+ expect(tooltipContentClass).toContain('bg-')
+ expect(tooltipContentClass).toContain('rounded-md')
+ })
+
+ it('should have specific styling', () => {
+ expect(tooltipContentClass).toBe(
+ 'text-white bg-[#536471] text-[12px] font-medium p-1 rounded-md',
+ )
+ })
+
+ it('should not be empty', () => {
+ expect(tooltipContentClass.length).toBeGreaterThan(0)
+ })
+
+ it('should include color hex value', () => {
+ expect(tooltipContentClass).toContain('#536471')
+ })
+
+ it('should include padding', () => {
+ expect(tooltipContentClass).toContain('p-1')
+ })
+
+ it('should include font styling', () => {
+ expect(tooltipContentClass).toContain('font-medium')
+ })
+
+ it('should include text size', () => {
+ expect(tooltipContentClass).toContain('text-[12px]')
+ })
+ })
+
+ describe('Constants exports', () => {
+ it('should not have name conflicts', () => {
+ const locale = LOCALE_COOKIE_KEY
+ const tooltip = tooltipContentClass
+ expect(locale).not.toBe(tooltip)
+ })
+
+ it('should have distinct purposes', () => {
+ expect(LOCALE_COOKIE_KEY).toMatch(/i18n|locale/i)
+ expect(tooltipContentClass).toMatch(/text|bg|rounded/i)
+ })
+ })
+})
diff --git a/app/modules/Common/test/unit/listService.spec.ts b/app/modules/Common/test/unit/listService.spec.ts
new file mode 100644
index 00000000..19c09204
--- /dev/null
+++ b/app/modules/Common/test/unit/listService.spec.ts
@@ -0,0 +1,247 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { useNuxtApp } from '#app'
+import { listService } from '../../services/listService'
+
+vi.mock('#app', () => ({
+ useNuxtApp: vi.fn(),
+}))
+
+const mockUseNuxtApp = vi.mocked(useNuxtApp)
+
+describe('listService', () => {
+ let mockAxios: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockAxios = {
+ get: vi.fn(),
+ }
+
+ mockUseNuxtApp.mockReturnValue({
+ $axios: mockAxios,
+ } as any)
+ })
+
+ describe('fetchList', () => {
+ it('should fetch data without cursor', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [{ id: 1, name: 'Item 1' }],
+ pagination: {
+ next_cursor: 'cursor-2',
+ has_more: true,
+ },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ const result = await listService.fetchList('/tweets', '')
+
+ expect(mockAxios.get).toHaveBeenCalledWith('/tweets')
+ expect(result).toEqual({
+ data: [{ id: 1, name: 'Item 1' }],
+ nextCursor: 'cursor-2',
+ hasMore: true,
+ })
+ })
+
+ it('should fetch data with cursor parameter', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [{ id: 2, name: 'Item 2' }],
+ pagination: {
+ next_cursor: 'cursor-3',
+ has_more: true,
+ },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ const result = await listService.fetchList('/tweets', 'cursor-2')
+
+ expect(mockAxios.get).toHaveBeenCalledWith('/tweets?cursor=cursor-2')
+ expect(result).toEqual({
+ data: [{ id: 2, name: 'Item 2' }],
+ nextCursor: 'cursor-3',
+ hasMore: true,
+ })
+ })
+
+ it('should handle paths with existing query parameters', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [{ id: 1 }],
+ pagination: {
+ next_cursor: null,
+ has_more: false,
+ },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ await listService.fetchList('/tweets?filter=latest', 'cursor-1')
+
+ expect(mockAxios.get).toHaveBeenCalledWith('/tweets?filter=latest&cursor=cursor-1')
+ })
+
+ it('should handle last page (no next cursor)', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [{ id: 3 }],
+ pagination: {
+ next_cursor: null,
+ has_more: false,
+ },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ const result = await listService.fetchList('/tweets', 'last-cursor')
+
+ expect(result).toEqual({
+ data: [{ id: 3 }],
+ nextCursor: undefined,
+ hasMore: false,
+ })
+ })
+
+ it('should handle alternative pagination format (next_cursor at root level)', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [{ id: 1 }],
+ next_cursor: 'cursor-2',
+ has_more: true,
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ const result = await listService.fetchList('/tweets', '')
+
+ expect(result.nextCursor).toBe('cursor-2')
+ expect(result.hasMore).toBe(true)
+ })
+
+ it('should handle multiple items in response', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [
+ { id: 1, name: 'Item 1' },
+ { id: 2, name: 'Item 2' },
+ { id: 3, name: 'Item 3' },
+ ],
+ pagination: {
+ next_cursor: 'cursor-next',
+ has_more: true,
+ },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ const result = await listService.fetchList('/users', '')
+
+ expect(result.data.length).toBe(3)
+ expect(result.data[0].id).toBe(1)
+ expect(result.data[2].id).toBe(3)
+ })
+
+ it('should handle empty data array', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [],
+ pagination: {
+ next_cursor: null,
+ has_more: false,
+ },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ const result = await listService.fetchList('/tweets', '')
+
+ expect(result.data).toEqual([])
+ expect(result.hasMore).toBe(false)
+ })
+
+ it('should throw error on API failure', async () => {
+ const error = new Error('Network error')
+ mockAxios.get.mockRejectedValue(error)
+
+ await expect(listService.fetchList('/tweets', '')).rejects.toThrow('Network error')
+ })
+
+ it('should use correct separator for URL construction', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [],
+ pagination: { next_cursor: null, has_more: false },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ // Path without query params should use ?
+ await listService.fetchList('/tweets', 'cursor-1')
+ expect(mockAxios.get).toHaveBeenCalledWith('/tweets?cursor=cursor-1')
+
+ mockAxios.get.mockClear()
+
+ // Path with query params should use &
+ await listService.fetchList('/tweets?sort=desc', 'cursor-2')
+ expect(mockAxios.get).toHaveBeenCalledWith('/tweets?sort=desc&cursor=cursor-2')
+ })
+
+ it('should extract data correctly from nested response structure', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [{ id: 1, title: 'Test' }],
+ pagination: {
+ next_cursor: 'next',
+ has_more: true,
+ },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ const result = await listService.fetchList('/tweets', '')
+
+ expect(result.data).toEqual([{ id: 1, title: 'Test' }])
+ expect(result.nextCursor).toBe('next')
+ expect(result.hasMore).toBe(true)
+ })
+
+ it('should handle null cursor correctly', async () => {
+ const mockResponse = {
+ data: {
+ data: {
+ data: [],
+ pagination: { next_cursor: null, has_more: false },
+ },
+ },
+ }
+ mockAxios.get.mockResolvedValue(mockResponse)
+
+ await listService.fetchList('/tweets', '')
+
+ expect(mockAxios.get).toHaveBeenCalledWith('/tweets')
+ })
+ })
+})
+
diff --git a/app/modules/Common/test/unit/mediaService.spec.ts b/app/modules/Common/test/unit/mediaService.spec.ts
new file mode 100644
index 00000000..28d289e9
--- /dev/null
+++ b/app/modules/Common/test/unit/mediaService.spec.ts
@@ -0,0 +1,181 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { createMediaService } from '../../services/mediaService'
+import { useNuxtApp } from '#app'
+
+vi.mock('#app', () => ({
+ useNuxtApp: vi.fn(),
+}))
+
+const mockUseNuxtApp = vi.mocked(useNuxtApp)
+
+describe('createMediaService', () => {
+ let mockAxios: any
+ let mockFormData: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockAxios = {
+ post: vi.fn(),
+ }
+
+ mockUseNuxtApp.mockReturnValue({
+ $axios: mockAxios,
+ } as any)
+
+ // Mock FormData
+ mockFormData = {
+ append: vi.fn(),
+ }
+ global.FormData = vi.fn(() => mockFormData) as any
+ })
+ describe('uploadMedia', () => {
+ it('should upload image successfully', async () => {
+ const mockFile = new File(['image content'], 'test.jpg', { type: 'image/jpeg' })
+ const mockResponse = {
+ data: {
+ url: 'https://cdn.example.com/images/abc123.jpg',
+ id: 'img-123',
+ },
+ }
+ mockAxios.post.mockResolvedValue(mockResponse)
+
+ const result = await createMediaService.uploadMedia(mockFile, 'image')
+
+ expect(mockFormData.append).toHaveBeenCalledWith('file', mockFile)
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ '/tweets/upload/image',
+ mockFormData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ timeout: 60000,
+ },
+ )
+ expect(result).toEqual(mockResponse.data)
+ })
+
+ it('should upload video successfully', async () => {
+ const mockFile = new File(['video content'], 'test.mp4', { type: 'video/mp4' })
+ const mockResponse = {
+ data: {
+ url: 'https://cdn.example.com/videos/abc123.mp4',
+ id: 'vid-123',
+ },
+ }
+ mockAxios.post.mockResolvedValue(mockResponse)
+
+ const result = await createMediaService.uploadMedia(mockFile, 'video')
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ '/tweets/upload/video',
+ mockFormData,
+ expect.objectContaining({
+ timeout: 60000,
+ }),
+ )
+ expect(result).toEqual(mockResponse.data)
+ })
+
+ it('should use correct endpoint for image upload', async () => {
+ const mockFile = new File([''], 'test.jpg')
+ mockAxios.post.mockResolvedValue({ data: {} })
+
+ await createMediaService.uploadMedia(mockFile, 'image')
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ '/tweets/upload/image',
+ expect.any(Object),
+ expect.any(Object),
+ )
+ })
+
+ it('should use correct endpoint for video upload', async () => {
+ const mockFile = new File([''], 'test.mp4')
+ mockAxios.post.mockResolvedValue({ data: {} })
+
+ await createMediaService.uploadMedia(mockFile, 'video')
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ '/tweets/upload/video',
+ expect.any(Object),
+ expect.any(Object),
+ )
+ })
+
+ it('should set multipart form-data headers', async () => {
+ const mockFile = new File([''], 'test.jpg')
+ mockAxios.post.mockResolvedValue({ data: {} })
+
+ await createMediaService.uploadMedia(mockFile, 'image')
+
+ const callArgs = mockAxios.post.mock.calls[0]
+ expect(callArgs[2]).toEqual({
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ timeout: 60000,
+ })
+ })
+
+ it('should append file to FormData', async () => {
+ const mockFile = new File(['content'], 'test.jpg')
+ mockAxios.post.mockResolvedValue({ data: {} })
+
+ await createMediaService.uploadMedia(mockFile, 'image')
+
+ expect(mockFormData.append).toHaveBeenCalledWith('file', mockFile)
+ })
+
+ it('should set 60 second timeout for uploads', async () => {
+ const mockFile = new File([''], 'test.mp4')
+ mockAxios.post.mockResolvedValue({ data: {} })
+
+ await createMediaService.uploadMedia(mockFile, 'video')
+
+ const callArgs = mockAxios.post.mock.calls[0]
+ expect(callArgs[2].timeout).toBe(60000)
+ })
+
+ it('should return response data correctly', async () => {
+ const mockFile = new File([''], 'test.jpg')
+ const mockResponse = {
+ data: {
+ url: 'https://example.com/image.jpg',
+ id: 'media-id',
+ width: 1920,
+ height: 1080,
+ },
+ }
+ mockAxios.post.mockResolvedValue(mockResponse)
+
+ const result = await createMediaService.uploadMedia(mockFile, 'image')
+
+ expect(result).toEqual(mockResponse.data)
+ expect(result.url).toBe('https://example.com/image.jpg')
+ expect(result.width).toBe(1920)
+ })
+
+ it('should throw error on upload failure', async () => {
+ const mockFile = new File([''], 'test.jpg')
+ const error = new Error('Upload failed')
+ mockAxios.post.mockRejectedValue(error)
+
+ await expect(createMediaService.uploadMedia(mockFile, 'image')).rejects.toThrow(
+ 'Upload failed',
+ )
+ })
+
+ it('should handle network errors', async () => {
+ const mockFile = new File([''], 'test.jpg')
+ const error = new Error('Network error')
+ mockAxios.post.mockRejectedValue(error)
+
+ await expect(createMediaService.uploadMedia(mockFile, 'image')).rejects.toThrow(
+ 'Network error',
+ )
+ })
+ })
+})
+
diff --git a/app/modules/Common/test/unit/queryKeys.spec.ts b/app/modules/Common/test/unit/queryKeys.spec.ts
new file mode 100644
index 00000000..58c93f5b
--- /dev/null
+++ b/app/modules/Common/test/unit/queryKeys.spec.ts
@@ -0,0 +1,158 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { queryKeys } from '../../queries/queryKeys'
+
+describe('queryKeys', () => {
+ describe('tweets', () => {
+ it('should have tweets.all key', () => {
+ expect(queryKeys.tweets.all).toEqual(['tweets'])
+ })
+
+ it('should generate tweets.list key with path', () => {
+ const key = queryKeys.tweets.list('/timeline')
+ expect(key).toEqual(['tweets', '/timeline'])
+ })
+
+ it('should generate tweets.details key with tweetId', () => {
+ const key = queryKeys.tweets.details('123')
+ expect(key).toEqual(['tweetDetails', '123'])
+ })
+
+ it('should generate tweets.summary key with tweetId', () => {
+ const key = queryKeys.tweets.summary('456')
+ expect(key).toEqual(['tweetSummary', '456'])
+ })
+
+ it('should handle different paths in list keys', () => {
+ const paths = ['/timeline/following', '/users/me/liked-posts', '/search']
+ paths.forEach((path) => {
+ const key = queryKeys.tweets.list(path)
+ expect(key).toEqual(['tweets', path])
+ expect(key[1]).toBe(path)
+ })
+ })
+ })
+
+ describe('users', () => {
+ it('should have users.all key', () => {
+ expect(queryKeys.users.all).toEqual(['user'])
+ })
+
+ it('should generate users.profile key with username', () => {
+ const key = queryKeys.users.profile('johndoe')
+ expect(key).toEqual(['user', 'johndoe'])
+ })
+
+ it('should generate users.byId key with userId', () => {
+ const key = queryKeys.users.byId('user-123')
+ expect(key).toEqual(['user', 'user-123'])
+ })
+
+ it('should generate users.me key', () => {
+ const key = queryKeys.users.me()
+ expect(key).toEqual(['me'])
+ })
+
+ it('should generate users.followers key', () => {
+ const key = queryKeys.users.followers('user-123')
+ expect(key).toEqual(['followers', 'user-123'])
+ })
+
+ it('should generate users.following key', () => {
+ const key = queryKeys.users.following('user-456')
+ expect(key).toEqual(['following', 'user-456'])
+ })
+ })
+
+ describe('settings', () => {
+ it('should generate settings.mutedUsers key', () => {
+ const key = queryKeys.settings.mutedUsers()
+ expect(key).toEqual(['muted-users'])
+ })
+
+ it('should generate settings.blockedUsers key', () => {
+ const key = queryKeys.settings.blockedUsers()
+ expect(key).toEqual(['blocked-users'])
+ })
+
+ it('should generate settings.usernameRecommendation key', () => {
+ const key = queryKeys.settings.usernameRecommendation()
+ expect(key).toEqual(['username-recommendation'])
+ })
+ })
+
+ describe('auth', () => {
+ it('should generate auth.user key', () => {
+ const key = queryKeys.auth.user()
+ expect(key).toEqual(['getUser'])
+ })
+ })
+
+ describe('conversations', () => {
+ it('should have conversations.all key', () => {
+ expect(queryKeys.conversations.all).toEqual(['conversations'])
+ })
+ })
+
+ describe('notifications', () => {
+ it('should have notifications.all key', () => {
+ expect(queryKeys.notifications.all).toEqual(['notifications'])
+ })
+
+ it('should have notifications.mentions key', () => {
+ expect(queryKeys.notifications.mentions).toEqual(['mentions'])
+ })
+ })
+
+ describe('search', () => {
+ it('should have search.all key', () => {
+ expect(queryKeys.search.all).toEqual(['tweets', '/search'])
+ })
+ })
+
+ describe('bookmarks', () => {
+ it('should have bookmarks.all key', () => {
+ expect(queryKeys.bookmarks.all).toEqual(['tweets', 'tweets/bookmarks'])
+ })
+ })
+
+ describe('query key consistency', () => {
+ it('should have proper key structure for nested queries', () => {
+ expect(Array.isArray(queryKeys.tweets.all)).toBe(true)
+ expect(Array.isArray(queryKeys.users.all)).toBe(true)
+ expect(Array.isArray(queryKeys.conversations.all)).toBe(true)
+ })
+
+ it('should have proper key structure for functions', () => {
+ const tweetKey = queryKeys.tweets.details('123')
+ const userKey = queryKeys.users.profile('user')
+ expect(Array.isArray(tweetKey)).toBe(true)
+ expect(Array.isArray(userKey)).toBe(true)
+ expect(tweetKey.length).toBe(2)
+ expect(userKey.length).toBe(2)
+ })
+
+ it('should not have duplicate top-level keys', () => {
+ const topKeys = Object.keys(queryKeys)
+ const uniqueKeys = new Set(topKeys)
+ expect(topKeys.length).toBe(uniqueKeys.size)
+ })
+ })
+
+ describe('key isolation', () => {
+ it('tweets and search keys should be distinct', () => {
+ expect(queryKeys.tweets.all).not.toEqual(queryKeys.search.all)
+ })
+
+ it('different user keys should be distinct', () => {
+ const profile1 = queryKeys.users.profile('user1')
+ const profile2 = queryKeys.users.profile('user2')
+ expect(profile1).not.toEqual(profile2)
+ })
+
+ it('different tweet detail keys should be distinct', () => {
+ const tweet1 = queryKeys.tweets.details('123')
+ const tweet2 = queryKeys.tweets.details('456')
+ expect(tweet1).not.toEqual(tweet2)
+ })
+ })
+})
diff --git a/app/modules/TimeLine/test/unit/useUploadMedia.spec.ts b/app/modules/TimeLine/test/unit/useUploadMedia.spec.ts
new file mode 100644
index 00000000..5eb7a47c
--- /dev/null
+++ b/app/modules/TimeLine/test/unit/useUploadMedia.spec.ts
@@ -0,0 +1,156 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+let lastMutationOptions: any
+let mockMediaService: any
+let mockNuxtApp: any
+
+vi.mock('nuxt/app', () => ({
+ useNuxtApp: () => mockNuxtApp,
+}))
+
+vi.mock('@tanstack/vue-query', () => ({
+ useMutation: (options: any) => {
+ lastMutationOptions = options
+ return {
+ mutate: vi.fn(),
+ mutateAsync: vi.fn(async (variables: any) => {
+ return options.mutationFn(variables)
+ }),
+ isPending: { value: false },
+ isError: { value: false },
+ isSuccess: { value: false },
+ }
+ },
+}))
+
+const { useUploadMedia } = await import('../../queries/useUploadMedia')
+
+describe('useUploadMedia', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ lastMutationOptions = null
+
+ mockMediaService = {
+ uploadMedia: vi.fn(async (file: File, type: string) => ({
+ url: `https://example.com/uploaded-${Date.now()}.jpg`,
+ })),
+ }
+
+ mockNuxtApp = {
+ $mediaService: mockMediaService,
+ }
+ })
+
+ it('should create mutation for media upload', () => {
+ const mutation = useUploadMedia()
+ expect(mutation).toBeDefined()
+ expect(lastMutationOptions).toBeDefined()
+ expect(lastMutationOptions.mutationFn).toBeDefined()
+ })
+
+ it('should call mutationFn with correct parameters', async () => {
+ const mutation = useUploadMedia()
+ const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' })
+
+ await mutation.mutateAsync({ media: mockFile, type: 'image' })
+
+ expect(mockMediaService.uploadMedia).toHaveBeenCalledWith(mockFile, 'image')
+ })
+
+ it('should handle image upload correctly', async () => {
+ const mutation = useUploadMedia()
+ const mockFile = new File(['image'], 'photo.jpg', { type: 'image/jpeg' })
+
+ await mutation.mutateAsync({ media: mockFile, type: 'image' })
+
+ expect(mockMediaService.uploadMedia).toHaveBeenCalledWith(mockFile, 'image')
+ })
+
+ it('should handle video upload correctly', async () => {
+ const mutation = useUploadMedia()
+ const mockFile = new File(['video'], 'video.mp4', { type: 'video/mp4' })
+
+ await mutation.mutateAsync({ media: mockFile, type: 'video' })
+
+ expect(mockMediaService.uploadMedia).toHaveBeenCalledWith(mockFile, 'video')
+ })
+
+ it('should return uploaded file URL', async () => {
+ const mutation = useUploadMedia()
+ const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' })
+ const expectedUrl = 'https://example.com/uploaded-file.jpg'
+
+ mockMediaService.uploadMedia.mockResolvedValueOnce({ url: expectedUrl })
+
+ const result = await mutation.mutateAsync({ media: mockFile, type: 'image' })
+
+ expect(result.url).toBe(expectedUrl)
+ })
+
+ it('should handle upload errors gracefully', async () => {
+ const mutation = useUploadMedia()
+ const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' })
+ const error = new Error('Network error')
+
+ mockMediaService.uploadMedia.mockRejectedValueOnce(error)
+
+ await expect(
+ mutation.mutateAsync({ media: mockFile, type: 'image' })
+ ).rejects.toThrow('Network error')
+ })
+
+ it('should support multiple file uploads', async () => {
+ const mutation = useUploadMedia()
+ const file1 = new File(['content1'], 'file1.jpg', { type: 'image/jpeg' })
+ const file2 = new File(['content2'], 'file2.png', { type: 'image/png' })
+
+ mockMediaService.uploadMedia
+ .mockResolvedValueOnce({ url: 'https://example.com/file1.jpg' })
+ .mockResolvedValueOnce({ url: 'https://example.com/file2.png' })
+
+ const result1 = await mutation.mutateAsync({ media: file1, type: 'image' })
+ const result2 = await mutation.mutateAsync({ media: file2, type: 'image' })
+
+ expect(result1.url).toBe('https://example.com/file1.jpg')
+ expect(result2.url).toBe('https://example.com/file2.png')
+ expect(mockMediaService.uploadMedia).toHaveBeenCalledTimes(2)
+ })
+
+ it('should handle large file uploads', async () => {
+ const mutation = useUploadMedia()
+ const largeFile = new File(['x'.repeat(10 * 1024 * 1024)], 'large.mp4', { type: 'video/mp4' })
+
+ mockMediaService.uploadMedia.mockResolvedValueOnce({ url: 'https://example.com/large.mp4' })
+
+ const result = await mutation.mutateAsync({ media: largeFile, type: 'video' })
+
+ expect(mockMediaService.uploadMedia).toHaveBeenCalledWith(largeFile, 'video')
+ expect(result.url).toBe('https://example.com/large.mp4')
+ })
+
+ it('should return mutation object with mutate method', () => {
+ const mutation = useUploadMedia()
+ expect(mutation).toHaveProperty('mutate')
+ expect(typeof mutation.mutate).toBe('function')
+ })
+
+ it('should return mutation object with mutateAsync method', () => {
+ const mutation = useUploadMedia()
+ expect(mutation).toHaveProperty('mutateAsync')
+ expect(typeof mutation.mutateAsync).toBe('function')
+ })
+
+ it('should handle different file types', async () => {
+ const mutation = useUploadMedia()
+ const imageFile = new File(['img'], 'test.jpg', { type: 'image/jpeg' })
+ const videoFile = new File(['vid'], 'test.mp4', { type: 'video/mp4' })
+
+ mockMediaService.uploadMedia.mockClear()
+
+ await mutation.mutateAsync({ media: imageFile, type: 'image' })
+ await mutation.mutateAsync({ media: videoFile, type: 'video' })
+
+ expect(mockMediaService.uploadMedia).toHaveBeenNthCalledWith(1, imageFile, 'image')
+ expect(mockMediaService.uploadMedia).toHaveBeenNthCalledWith(2, videoFile, 'video')
+ })
+})
diff --git a/app/modules/auth/components/CompleteAccount.vue b/app/modules/auth/components/CompleteAccount.vue
index 2bb900fb..b369338e 100644
--- a/app/modules/auth/components/CompleteAccount.vue
+++ b/app/modules/auth/components/CompleteAccount.vue
@@ -191,7 +191,7 @@ const onInterestsBack = () => {
}
// Who to Follow handlers
-const onWhoToFollowFinish = (followedUsers: string[]) => {
+const onWhoToFollowFinish = () => {
showWhoToFollow.value = false
showLoading.value = true
enableUserQuery.value = true
diff --git a/app/modules/auth/components/subComponents/CompleteAccountComponents/Interests.vue b/app/modules/auth/components/subComponents/CompleteAccountComponents/Interests.vue
index d37c7b1b..34b97f8f 100644
--- a/app/modules/auth/components/subComponents/CompleteAccountComponents/Interests.vue
+++ b/app/modules/auth/components/subComponents/CompleteAccountComponents/Interests.vue
@@ -5,7 +5,7 @@
:hasCloseButton="false"
contentClass="sm:max-w-2xl w-full"
:headerClass="isArabic ? 'absolute top-4 right-4 z-10 bg-transparent p-0' : 'absolute top-4 left-4 z-10 bg-transparent p-0'"
- slotClass="pt-4 px-8 pb-8 sm:pt-6 sm:px-10 sm:pb-10"
+ slotClass="pt-4 px-8 pb-0 sm:pt-6 sm:px-10 sm:pb-0 flex flex-col"
@back="$emit('back')"
:hasBackButton="true"
>
@@ -19,12 +19,12 @@
{{ $t('auth.interests.info') }}
-
+
-
+
@@ -91,52 +58,45 @@
diff --git a/app/modules/auth/test/unit/completeAcc.spec.ts b/app/modules/auth/test/unit/completeAcc.spec.ts
index d960892a..dd41e69a 100644
--- a/app/modules/auth/test/unit/completeAcc.spec.ts
+++ b/app/modules/auth/test/unit/completeAcc.spec.ts
@@ -39,6 +39,11 @@ const mockUserStore = {
setAuth: vi.fn(),
setUser: vi.fn(),
user: null,
+ getUser: vi.fn(() => ({
+ user_id: 'test-user-id',
+ username: 'testuser',
+ name: 'Test User',
+ })),
}
vi.mock('~/modules/auth/stores/userStore', () => ({
useUserStore: () => mockUserStore,
@@ -160,6 +165,13 @@ vi.mock('#app', () => ({
$authService: {
getUserData: vi.fn(() => Promise.resolve({ id: 1, name: 'Test User' })),
},
+ $queryClient: {
+ invalidateQueries: vi.fn(),
+ },
+ $userInfoService: {
+ followUser: vi.fn(() => Promise.resolve({ success: true })),
+ unfollowUser: vi.fn(() => Promise.resolve({ success: true })),
+ },
runWithContext: (fn: any) => fn(),
callHook: vi.fn(),
}),
@@ -172,6 +184,66 @@ vi.mock('#app', () => ({
useCookie: () => mockCookie,
}))
+// Mock profile composables for ProfileFollowAction
+vi.mock('~/modules/profile/composables/useFollow', () => ({
+ useFollow: () => ({
+ buttonClass: 'test-class',
+ buttonText: ref('Follow'),
+ handleMouseOver: vi.fn(),
+ handleMouseOut: vi.fn(),
+ }),
+}))
+
+vi.mock('~/modules/profile/composables/useUserInfo', () => ({
+ useUserInfo: () => ({
+ isBlocked: ref(false),
+ isFollowing: ref(false),
+ id: ref('test-id'),
+ username: ref('testuser'),
+ }),
+}))
+
+vi.mock('~/modules/profile/composables/useUserInteractions', () => ({
+ useUserInteractions: () => ({
+ handleFollowAction: vi.fn(),
+ handleUnfollowWithConfirmation: vi.fn(),
+ isFollowLoading: ref(false),
+ }),
+}))
+
+vi.mock('~/modules/profile/composables/useSnackbar', () => ({
+ useSnackbar: () => ({
+ showSnackbar: ref(false),
+ snackbar: ref({}),
+ handleShowSnackbar: vi.fn(),
+ }),
+}))
+
+vi.mock('~/modules/profile/composables/useConfirmation', () => ({
+ useConfirmation: () => ({
+ showConfirmation: ref(false),
+ confirmData: ref({}),
+ handleShowConfirmation: vi.fn(),
+ }),
+}))
+
+// Mock WhoToFollowList to avoid ProfileFollowAction complexity
+vi.mock('~/modules/explore/components/common/WhoToFollowList.vue', () => ({
+ default: {
+ name: 'WhoToFollowList',
+ props: ['users', 'hideBio'],
+ template: `
+
+
+ {{ user.name }}
+ @{{ user.username }}
+
+
+
+ `,
+ },
+}))
+
function mountCompleteAccount(props = {}) {
const defaultProps = {
Recommendations: ['user123', 'user456', 'user789'],
diff --git a/app/modules/notifications/test/unit/NotificationItem.spec.ts b/app/modules/notifications/test/unit/NotificationItem.spec.ts
index 2e505ec5..7f843ee0 100644
--- a/app/modules/notifications/test/unit/NotificationItem.spec.ts
+++ b/app/modules/notifications/test/unit/NotificationItem.spec.ts
@@ -92,57 +92,183 @@ describe('NotificationItem', () => {
expect(wrapper.exists()).toBe(true)
})
- // it('marks notification as new on mount', async () => {
- // const wrapper = mountWrapper({ created_at: recentDate() })
- // await nextTick()
- // await new Promise((resolve) => setTimeout(resolve, 0))
- // vi.runAllTimers()
- // await nextTick()
+ it('computes correct notification message for follow type', () => {
+ const wrapper = mountWrapper({
+ type: 'follow',
+ })
+ const message = wrapper.vm.notificationMessage
+ expect(message).toBe('notifications.content.followedYou')
+ })
- // expect(wrapper.vm.isNew).toBe(true)
- // expect(wrapper.classes()).toContain('is-new')
- // })
+ it('computes correct notification message for message type', () => {
+ const wrapper = mountWrapper({
+ type: 'message' as const,
+ sender: {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ } as any,
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ } as any)
+ const message = (wrapper.vm as any).notificationMessage
+ expect(message).toBe('notifications.content.sentYouAMessage')
+ })
- // it('removes isNew after timeout', async () => {
- // const wrapper = mountWrapper({ created_at: recentDate() })
- // await nextTick()
+ it('computes correct notification icon for follow', () => {
+ const wrapper = mountWrapper({
+ type: 'follow',
+ })
+ expect((wrapper.vm as any).notificationIcon).toBeDefined()
+ })
- // await new Promise((resolve) => setTimeout(resolve, 0))
- // vi.runAllTimers()
- // await nextTick()
+ it('computes correct notification icon for like', () => {
+ const wrapper = mountWrapper({
+ type: 'like' as const,
+ tweets: [],
+ } as any)
+ expect((wrapper.vm as any).notificationIcon).toBeDefined()
+ })
- // expect(wrapper.vm.isNew).toBe(true)
+ it('computes correct notification icon for repost', () => {
+ const wrapper = mountWrapper({
+ type: 'repost' as const,
+ tweets: [],
+ } as any)
+ expect((wrapper.vm as any).notificationIcon).toBeDefined()
+ })
- // // Advance time by 10 seconds to trigger the timeout that sets isNew to false
- // vi.advanceTimersByTime(10000)
- // await nextTick()
+ it('computes correct notification icon for message', () => {
+ const wrapper = mountWrapper({
+ type: 'message' as const,
+ sender: { id: 'u1', name: 'U', username: 'u', avatar_url: null } as any,
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ } as any)
+ expect((wrapper.vm as any).notificationIcon).toBeDefined()
+ })
- // expect(wrapper.vm.isNew).toBe(false)
- // expect(wrapper.classes()).not.toContain('is-new')
- // })
+ it('computes correct notification icon color for follow', () => {
+ const wrapper = mountWrapper({ type: 'follow' })
+ expect((wrapper.vm as any).notificationIconColor).toBe('#1d9bf0')
+ })
- // it('reacts to created_at changes', async () => {
- // const wrapper = mountWrapper({ created_at: oldDate() })
- // await nextTick()
- // await new Promise((resolve) => setTimeout(resolve, 0))
- // vi.runAllTimers()
- // await nextTick()
+ it('computes correct notification icon color for like', () => {
+ const wrapper = mountWrapper({ type: 'like' as const, tweets: [] } as any)
+ expect((wrapper.vm as any).notificationIconColor).toBe('#f91880')
+ })
- // expect(wrapper.vm.isNew).toBe(false)
+ it('computes correct notification icon color for repost', () => {
+ const wrapper = mountWrapper({ type: 'repost' as const, tweets: [] } as any)
+ expect((wrapper.vm as any).notificationIconColor).toBe('#00ba7c')
+ })
- // await wrapper.setProps({
- // notification: { ...wrapper.props().notification, created_at: recentDate() },
- // })
+ it('computes correct notification icon color for message', () => {
+ const wrapper = mountWrapper({
+ type: 'message' as const,
+ sender: { id: 'u1', name: 'U', username: 'u', avatar_url: null } as any,
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ } as any)
+ expect((wrapper.vm as any).notificationIconColor).toBe('#7856ff')
+ })
- // await nextTick()
- // await new Promise((resolve) => setTimeout(resolve, 0))
- // vi.runAllTimers()
- // await nextTick()
+ it('computes correct fill color for like', () => {
+ const wrapper = mountWrapper({ type: 'like' as const, tweets: [] } as any)
+ expect((wrapper.vm as any).notificationFillColor).toBe('#f91880')
+ })
+
+ it('computes correct fill color for follow', () => {
+ const wrapper = mountWrapper({ type: 'follow' })
+ expect((wrapper.vm as any).notificationFillColor).toBe('#1d9bf0')
+ })
- // expect(wrapper.vm.isNew).toBe(true)
- // expect(wrapper.classes()).toContain('is-new')
- // })
+ it('computes notification link for single follower', () => {
+ const wrapper = mountWrapper({
+ type: 'follow',
+ followers: [
+ {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ } as any,
+ ],
+ })
+ expect(wrapper.vm.notificationLink).toBe('/user1')
+ })
+
+ it('computes notification link for like notification', () => {
+ const wrapper = mountWrapper({
+ type: 'like',
+ likers: [
+ {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ } as any,
+ ],
+ tweets: [
+ {
+ tweet_id: 'tweet1',
+ type: 'tweet',
+ content: 'Hello',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ } as any,
+ ],
+ })
+ expect(wrapper.vm.notificationLink).toContain('user1')
+ expect(wrapper.vm.notificationLink).toContain('status')
+ })
+
+ it('computes notification link for repost notification', () => {
+ const wrapper = mountWrapper({
+ type: 'repost',
+ reposters: [
+ {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ } as any,
+ ],
+ tweets: [
+ {
+ tweet_id: 'tweet1',
+ type: 'tweet',
+ content: 'Hello',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ } as any,
+ ],
+ })
+ expect(wrapper.vm.notificationLink).toContain('user1')
+ expect(wrapper.vm.notificationLink).toContain('status')
+ })
+
+ it('computes notification link for message notification', () => {
+ const wrapper = mountWrapper({
+ type: 'message',
+ sender: { id: 'u1', name: 'U', username: 'u', avatar_url: null } as any,
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ })
+ expect(wrapper.vm.notificationLink).toBe('/messages/chat1')
+ })
+
+
+ it('exposes isNew to parent component', () => {
+ const wrapper = mountWrapper()
+ expect(wrapper.vm.isNew).toBeDefined()
+ })
})
afterAll(() => {
diff --git a/app/modules/notifications/test/unit/NotificationsSocketService.spec.ts b/app/modules/notifications/test/unit/NotificationsSocketService.spec.ts
index 7f811481..da94cdc2 100644
--- a/app/modules/notifications/test/unit/NotificationsSocketService.spec.ts
+++ b/app/modules/notifications/test/unit/NotificationsSocketService.spec.ts
@@ -2,8 +2,16 @@ import { describe, it, vi, beforeEach, expect } from 'vitest'
import { createNotificationsSocketService } from '~/modules/notifications/services/NotificationsSocketService'
import { SOCKET_EVENTS } from '~/modules/notifications/types/notificationsSocketEvents'
+const mockRoute = { path: '/' }
vi.mock('vue-router', () => ({
- useRoute: () => ({ path: '/' }),
+ useRoute: () => mockRoute,
+}))
+
+vi.mock('~/modules/Common/queries/cacheInvalidation', () => ({
+ cacheInvalidation: {
+ onRemoveNotification: vi.fn(),
+ onRemoveMention: vi.fn(),
+ },
}))
describe('NotificationsSocketService', () => {
@@ -11,7 +19,27 @@ describe('NotificationsSocketService', () => {
let queryClient: any
let service: ReturnType
+ const mockUser = {
+ id: 'user1',
+ name: 'Test User',
+ username: 'testuser',
+ avatar_url: 'avatar.jpg',
+ }
+
+ const mockTweet = {
+ tweet_id: 'tweet1',
+ type: 'tweet' as const,
+ content: 'Test tweet',
+ images: [],
+ videos: [],
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ }
+
beforeEach(() => {
+ mockRoute.path = '/'
+ vi.clearAllMocks()
+
socketService = {
on: vi.fn(),
off: vi.fn(),
@@ -28,58 +56,293 @@ describe('NotificationsSocketService', () => {
service = createNotificationsSocketService({ socketService, queryClient })
})
- it('should initialize and remove listeners', () => {
+ it('should initialize listeners only once, handle NEWEST_COUNT, and remove listeners correctly', () => {
service.initializeListeners()
- expect(socketService.on).toHaveBeenCalledWith(
- SOCKET_EVENTS.NEWEST_COUNT,
- expect.any(Function),
- )
+ expect(socketService.on).toHaveBeenCalledTimes(8)
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.NEWEST_COUNT, expect.any(Function))
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.FOLLOW, expect.any(Function))
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.LIKE, expect.any(Function))
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.REPLY, expect.any(Function))
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.REPOST, expect.any(Function))
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.QUOTE, expect.any(Function))
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.MENTION, expect.any(Function))
+ expect(socketService.on).toHaveBeenCalledWith(SOCKET_EVENTS.MESSAGE, expect.any(Function))
+
+ const newestCountCallback = socketService.on.mock.calls.find(
+ (c) => c[0] === SOCKET_EVENTS.NEWEST_COUNT,
+ )[1]
+ newestCountCallback({ newest_count: 5 })
+ expect(service.unreadCount.value).toBe(5)
+ newestCountCallback({ newest_count: 10 })
+ expect(service.unreadCount.value).toBe(10)
+
+ socketService.on.mockClear()
+ service.initializeListeners()
+ expect(socketService.on).not.toHaveBeenCalled()
+
service.removeListeners()
+ expect(socketService.off).toHaveBeenCalledWith(SOCKET_EVENTS.FOLLOW)
+ expect(socketService.off).toHaveBeenCalledWith(SOCKET_EVENTS.LIKE)
+ expect(socketService.off).toHaveBeenCalledWith(SOCKET_EVENTS.REPLY)
+ expect(socketService.off).toHaveBeenCalledWith(SOCKET_EVENTS.REPOST)
+ expect(socketService.off).toHaveBeenCalledWith(SOCKET_EVENTS.QUOTE)
+ expect(socketService.off).toHaveBeenCalledWith(SOCKET_EVENTS.MENTION)
expect(socketService.off).toHaveBeenCalledWith(SOCKET_EVENTS.NEWEST_COUNT)
})
- it('should update unread count on NEWEST_COUNT event', () => {
+ it('should handle add events for all notification types and update cache correctly', () => {
service.initializeListeners()
- const callback = socketService.on.mock.calls.find(
- (c) => c[0] === SOCKET_EVENTS.NEWEST_COUNT,
- )[1]
- callback({ newest_count: 5 })
- expect(service.unreadCount.value).toBe(5)
+
+ const followEvent = {
+ type: 'follow',
+ action: 'add',
+ id: 'notif1',
+ created_at: '2024-01-01T00:00:00Z',
+ follower: mockUser,
+ }
+ const followCallback = socketService.on.mock.calls.find((c) => c[0] === 'follow')[1]
+ followCallback(followEvent)
+ expect(queryClient.setQueryData).toHaveBeenCalledWith(['notifications'], expect.any(Function))
+
+ const likeEvent = {
+ type: 'like',
+ action: 'add',
+ id: 'notif2',
+ created_at: '2024-01-01T00:00:00Z',
+ liker: mockUser,
+ tweet: mockTweet,
+ like_to: 'user2',
+ liked_by: 'user1',
+ }
+ const likeCallback = socketService.on.mock.calls.find((c) => c[0] === 'like')[1]
+ likeCallback(likeEvent)
+
+ // reply add event (should also add to mentions cache)
+ queryClient.setQueryData.mockClear()
+ const replyEvent = {
+ type: 'reply',
+ action: 'add',
+ id: 'notif3',
+ created_at: '2024-01-01T00:00:00Z',
+ replier: mockUser,
+ reply_tweet: mockTweet,
+ original_tweet: mockTweet,
+ conversation_id: 'conv1',
+ replied_by: 'user1',
+ reply_to: 'user2',
+ }
+ const replyCallback = socketService.on.mock.calls.find((c) => c[0] === 'reply')[1]
+ replyCallback(replyEvent)
+ expect(queryClient.setQueryData).toHaveBeenCalledWith(['notifications'], expect.any(Function))
+ expect(queryClient.setQueryData).toHaveBeenCalledWith(['mentions'], expect.any(Function))
+
+ // repost add event
+ const repostEvent = {
+ type: 'repost',
+ action: 'add',
+ id: 'notif4',
+ created_at: '2024-01-01T00:00:00Z',
+ reposter: mockUser,
+ tweet: mockTweet,
+ repost_to: 'user2',
+ reposted_by: 'user1',
+ }
+ const repostCallback = socketService.on.mock.calls.find((c) => c[0] === 'repost')[1]
+ repostCallback(repostEvent)
+ const quoteEvent = {
+ type: 'quote',
+ action: 'add',
+ id: 'notif5',
+ created_at: '2024-01-01T00:00:00Z',
+ quoter: mockUser,
+ quote_tweet: { ...mockTweet, parent_tweet: mockTweet },
+ }
+ const quoteCallback = socketService.on.mock.calls.find((c) => c[0] === 'quote')[1]
+ quoteCallback(quoteEvent)
+
+ queryClient.setQueryData.mockClear()
+ const mentionEvent = {
+ type: 'mention',
+ action: 'add',
+ id: 'notif6',
+ created_at: '2024-01-01T00:00:00Z',
+ mentioner: mockUser,
+ tweet: mockTweet,
+ tweet_type: 'tweet' as const,
+ }
+ const mentionCallback = socketService.on.mock.calls.find((c) => c[0] === 'mention')[1]
+ mentionCallback(mentionEvent)
+ expect(queryClient.setQueryData).toHaveBeenCalledWith(['notifications'], expect.any(Function))
+ expect(queryClient.setQueryData).toHaveBeenCalledWith(['mentions'], expect.any(Function))
+
+ const messageEvent = {
+ type: 'message',
+ action: 'add',
+ id: 'notif7',
+ created_at: '2024-01-01T00:00:00Z',
+ sender: mockUser,
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ }
+ const messageCallback = socketService.on.mock.calls.find((c) => c[0] === 'message')[1]
+ messageCallback(messageEvent)
+
+ expect(queryClient.setQueryData).toHaveBeenCalled()
+ })
+
+ it('should handle aggregate events and replace old notifications in cache', () => {
+ service.initializeListeners()
+
+ const followAggregateEvent = {
+ type: 'follow',
+ action: 'aggregate',
+ id: 'notif_new',
+ created_at: '2024-01-01T00:00:00Z',
+ followers: [mockUser, { ...mockUser, id: 'user2' }],
+ old_notification: {
+ type: 'follow',
+ id: 'notif_old',
+ created_at: '2024-01-01T00:00:00Z',
+ follower_id: ['user1'],
+ },
+ }
+ const followCallback = socketService.on.mock.calls.find((c) => c[0] === 'follow')[1]
+ followCallback(followAggregateEvent)
+ expect(queryClient.setQueryData).toHaveBeenCalledWith(['notifications'], expect.any(Function))
+ const likeAggregateEvent = {
+ type: 'like',
+ action: 'aggregate',
+ id: 'notif_new2',
+ created_at: '2024-01-01T00:00:00Z',
+ likers: [mockUser],
+ tweets: [mockTweet],
+ old_notification: {
+ type: 'like',
+ id: 'notif_old2',
+ created_at: '2024-01-01T00:00:00Z',
+ tweet_id: ['tweet1'],
+ liked_by: ['user1'],
+ },
+ }
+ const likeCallback = socketService.on.mock.calls.find((c) => c[0] === 'like')[1]
+ likeCallback(likeAggregateEvent)
+ const repostAggregateEvent = {
+ type: 'repost',
+ action: 'aggregate',
+ id: 'notif_new3',
+ created_at: '2024-01-01T00:00:00Z',
+ reposters: [mockUser],
+ tweets: [mockTweet],
+ old_notification: {
+ type: 'repost',
+ id: 'notif_old3',
+ created_at: '2024-01-01T00:00:00Z',
+ tweet_id: ['tweet1'],
+ reposted_by: ['user1'],
+ },
+ }
+ const repostCallback = socketService.on.mock.calls.find((c) => c[0] === 'repost')[1]
+ repostCallback(repostAggregateEvent)
+
+ expect(queryClient.setQueryData).toHaveBeenCalled()
})
- it('should increment unread count if not on notifications page', () => {
+ it('should increment unread count when not on notifications page and mark as seen when on page', () => {
+ mockRoute.path = '/home'
service.unreadCount.value = 0
service.initializeListeners()
- const fakeEvent = { type: 'message', action: 'add', id: '1' }
- const handleCallback = socketService.on.mock.calls.find((c) => c[0] === 'message')[1]
- handleCallback(fakeEvent)
+
+ const messageEvent = {
+ type: 'message',
+ action: 'add',
+ id: '1',
+ sender: mockUser,
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ created_at: '2024-01-01T00:00:00Z',
+ }
+ const messageCallback = socketService.on.mock.calls.find((c) => c[0] === 'message')[1]
+ messageCallback(messageEvent)
expect(service.unreadCount.value).toBe(1)
+
+ messageCallback(messageEvent)
+ expect(service.unreadCount.value).toBe(2)
+
+ vi.clearAllMocks()
+ mockRoute.path = '/notifications'
+ socketService.emit.mockClear()
+ const newService = createNotificationsSocketService({ socketService, queryClient })
+ newService.initializeListeners()
+ newService.unreadCount.value = 5
+
+ const newMessageCallback = socketService.on.mock.calls.find((c) => c[0] === 'message')[1]
+ newMessageCallback(messageEvent)
+ expect(socketService.emit).toHaveBeenCalledWith(SOCKET_EVENTS.MARK_SEEN, {})
+ expect(newService.unreadCount.value).toBe(0)
})
- it('should mark notifications as seen and reset unread count', () => {
+ it('should handle edge cases: socket disconnected and null cache data', () => {
+ socketService.isConnected.mockReturnValue(false)
service.unreadCount.value = 5
service.markNotificationsAsSeen()
- expect(service.unreadCount.value).toBe(0)
- expect(socketService.emit).toHaveBeenCalledWith(SOCKET_EVENTS.MARK_SEEN, {})
- })
+ expect(socketService.emit).not.toHaveBeenCalled()
+ expect(service.unreadCount.value).toBe(5)
+
+ socketService.isConnected.mockReturnValue(true)
- it('should add notification to cache', () => {
service.initializeListeners()
- const fakeEvent = { type: 'message', action: 'add', id: '1' }
+ queryClient.setQueryData.mockImplementation((key, updater) => {
+ const result = updater(null)
+ expect(result).toBeNull()
+ })
- const messageCall = socketService.on.mock.calls.find((c: any) => c[0] === 'message')
- if (messageCall && messageCall[1]) {
- messageCall[1](fakeEvent)
- expect(queryClient.setQueryData).toHaveBeenCalled()
+ const messageEvent = {
+ type: 'message',
+ action: 'add',
+ id: '1',
+ sender: mockUser,
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ created_at: '2024-01-01T00:00:00Z',
}
- })
+ const messageCallback = socketService.on.mock.calls.find((c) => c[0] === 'message')[1]
+ messageCallback(messageEvent)
- it('should handle remove notification event', () => {
- const removeEvent = { type: 'reply', action: 'remove', id: '1' }
- const replyCall = socketService.on.mock.calls.find((c: any) => c[0] === 'reply')
- if (replyCall && replyCall[1]) {
- replyCall[1](removeEvent)
- expect(queryClient.setQueryData).not.toThrow()
+ queryClient.setQueryData.mockImplementation((key, updater) => {
+ const result = updater({ someOtherData: true })
+ expect(result).toEqual({ someOtherData: true })
+ })
+ messageCallback(messageEvent)
+
+ queryClient.setQueryData.mockImplementation((key, updater) => {
+ const mockData = {
+ pages: [
+ { notifications: [{ id: 'old_notif', type: 'like' }], total: 1 },
+ { notifications: [], total: 0 },
+ ],
+ }
+ const result = updater(mockData)
+ expect(result.pages[0].notifications[0].id).toBe('new_notif')
+ })
+
+ const likeAggregateEvent = {
+ type: 'like',
+ action: 'aggregate',
+ id: 'new_notif',
+ created_at: '2024-01-01T00:00:00Z',
+ likers: [mockUser],
+ tweets: [mockTweet],
+ old_notification: {
+ type: 'like',
+ id: 'old_notif',
+ created_at: '2024-01-01T00:00:00Z',
+ tweet_id: ['tweet1'],
+ liked_by: ['user1'],
+ },
}
+ const likeCallback = socketService.on.mock.calls.find((c) => c[0] === 'like')[1]
+ likeCallback(likeAggregateEvent)
+
+ expect(queryClient.setQueryData).toHaveBeenCalled()
})
})
diff --git a/app/modules/notifications/test/unit/notificationsSocketEventsTypes.spec.ts b/app/modules/notifications/test/unit/notificationsSocketEventsTypes.spec.ts
new file mode 100644
index 00000000..94bb8494
--- /dev/null
+++ b/app/modules/notifications/test/unit/notificationsSocketEventsTypes.spec.ts
@@ -0,0 +1,387 @@
+import { describe, it, expect } from 'vitest'
+import {
+ isFollowEvent,
+ isLikeEvent,
+ isReplyEvent,
+ isRepostEvent,
+ isQuoteEvent,
+ isMentionEvent,
+ isAddAction,
+ isRemoveAction,
+ isAggregateAction,
+ SOCKET_EVENTS,
+ type FollowEvent,
+ type LikeEvent,
+ type ReplyEvent,
+ type RepostEvent,
+ type QuoteEvent,
+ type MentionEvent,
+ type NotificationEvent,
+ type AddEvent,
+ type RemoveEvent,
+ type AggregateEvent,
+} from '~/modules/notifications/types/notificationsSocketEvents'
+
+describe('Socket Events Type Guards', () => {
+ it('should identify follow events correctly', () => {
+ const event: FollowEvent = {
+ type: 'follow',
+ id: '1',
+ created_at: '2025-12-15T10:00:00Z',
+ action: 'add',
+ follower: {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ },
+ }
+ expect(isFollowEvent(event)).toBe(true)
+ expect(isLikeEvent(event)).toBe(false)
+ expect(isReplyEvent(event)).toBe(false)
+ })
+
+ it('should identify like events correctly', () => {
+ const event: LikeEvent = {
+ type: 'like',
+ id: '2',
+ created_at: '2025-12-15T11:00:00Z',
+ action: 'add',
+ liker: {
+ id: 'user2',
+ name: 'User Two',
+ username: 'user2',
+ avatar_url: null,
+ },
+ tweet: {
+ tweet_id: 'tweet1',
+ type: 'tweet',
+ content: 'Hello',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ },
+ like_to: 'tweet1',
+ liked_by: 'user2',
+ }
+ expect(isLikeEvent(event)).toBe(true)
+ expect(isFollowEvent(event)).toBe(false)
+ })
+
+ it('should identify reply events correctly', () => {
+ const event: ReplyEvent = {
+ type: 'reply',
+ id: '3',
+ created_at: '2025-12-15T12:00:00Z',
+ action: 'add',
+ replier: {
+ id: 'user3',
+ name: 'User Three',
+ username: 'user3',
+ avatar_url: null,
+ },
+ reply_tweet: {
+ tweet_id: 'reply1',
+ type: 'reply',
+ content: 'Great post',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T11:00:00Z',
+ updated_at: '2025-12-15T11:00:00Z',
+ },
+ original_tweet: {
+ tweet_id: 'tweet2',
+ type: 'tweet',
+ content: 'Hello everyone',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ },
+ replied_by: 'user3',
+ reply_to: 'tweet2',
+ conversation_id: 'conv1',
+ }
+ expect(isReplyEvent(event)).toBe(true)
+ expect(isFollowEvent(event)).toBe(false)
+ })
+
+ it('should identify repost events correctly', () => {
+ const event: RepostEvent = {
+ type: 'repost',
+ id: '4',
+ created_at: '2025-12-15T13:00:00Z',
+ action: 'add',
+ reposter: {
+ id: 'user4',
+ name: 'User Four',
+ username: 'user4',
+ avatar_url: null,
+ },
+ repost_to: 'tweet1',
+ reposted_by: 'user4',
+ tweet: {
+ tweet_id: 'tweet1',
+ type: 'tweet',
+ content: 'Shared content',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T12:00:00Z',
+ updated_at: '2025-12-15T12:00:00Z',
+ },
+ }
+ expect(isRepostEvent(event)).toBe(true)
+ expect(isFollowEvent(event)).toBe(false)
+ })
+
+ it('should identify quote events correctly', () => {
+ const event: QuoteEvent = {
+ type: 'quote',
+ id: '5',
+ created_at: '2025-12-15T14:00:00Z',
+ action: 'add',
+ quoter: {
+ id: 'user5',
+ name: 'User Five',
+ username: 'user5',
+ avatar_url: null,
+ },
+ quote_tweet: {
+ tweet_id: 'quote1',
+ type: 'quote',
+ content: 'My thoughts',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T13:00:00Z',
+ updated_at: '2025-12-15T13:00:00Z',
+ },
+ }
+ expect(isQuoteEvent(event)).toBe(true)
+ expect(isFollowEvent(event)).toBe(false)
+ })
+
+ it('should identify mention events correctly', () => {
+ const event: MentionEvent = {
+ type: 'mention',
+ id: '6',
+ created_at: '2025-12-15T15:00:00Z',
+ action: 'add',
+ mentioner: {
+ id: 'user6',
+ name: 'User Six',
+ username: 'user6',
+ avatar_url: null,
+ },
+ tweet_type: 'tweet',
+ tweet: {
+ tweet_id: 'tweet4',
+ type: 'tweet',
+ content: '@me check this',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T14:00:00Z',
+ updated_at: '2025-12-15T14:00:00Z',
+ },
+ }
+ expect(isMentionEvent(event)).toBe(true)
+ expect(isFollowEvent(event)).toBe(false)
+ })
+
+ it('should identify add actions correctly', () => {
+ const event: AddEvent = {
+ type: 'follow',
+ id: '1',
+ created_at: '2025-12-15T10:00:00Z',
+ action: 'add',
+ follower: {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ },
+ }
+ expect(isAddAction(event)).toBe(true)
+ expect(isRemoveAction(event)).toBe(false)
+ expect(isAggregateAction(event)).toBe(false)
+ })
+
+ it('should identify remove actions correctly', () => {
+ const event: RemoveEvent = {
+ type: 'follow',
+ id: '7',
+ created_at: '2025-12-15T10:00:00Z',
+ action: 'remove',
+ follower_id: 'user1',
+ follower_name: 'User One',
+ follower_avatar_url: null,
+ followed_id: 'me',
+ }
+ expect(isRemoveAction(event)).toBe(true)
+ expect(isAddAction(event)).toBe(false)
+ expect(isAggregateAction(event)).toBe(false)
+ })
+
+ it('should identify aggregate actions correctly', () => {
+ const event: AggregateEvent = {
+ type: 'follow',
+ id: '8',
+ created_at: '2025-12-15T10:00:00Z',
+ action: 'aggregate',
+ followers: [
+ {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ },
+ ],
+ old_notification: {
+ type: 'follow',
+ id: '7',
+ created_at: '2025-12-15T09:00:00Z',
+ follower_id: ['user1'],
+ },
+ }
+ expect(isAggregateAction(event)).toBe(true)
+ expect(isAddAction(event)).toBe(false)
+ expect(isRemoveAction(event)).toBe(false)
+ })
+
+ it('should have correct SOCKET_EVENTS constants', () => {
+ expect(SOCKET_EVENTS.FOLLOW).toBe('follow')
+ expect(SOCKET_EVENTS.LIKE).toBe('like')
+ expect(SOCKET_EVENTS.REPLY).toBe('reply')
+ expect(SOCKET_EVENTS.REPOST).toBe('repost')
+ expect(SOCKET_EVENTS.QUOTE).toBe('quote')
+ expect(SOCKET_EVENTS.MENTION).toBe('mention')
+ expect(SOCKET_EVENTS.NEWEST_COUNT).toBe('newest_count')
+ expect(SOCKET_EVENTS.MESSAGE).toBe('message')
+ expect(SOCKET_EVENTS.MARK_SEEN).toBe('mark_seen')
+ })
+
+ it('should work with notification event union type', () => {
+ const followEvent: NotificationEvent = {
+ type: 'follow',
+ id: '1',
+ created_at: '2025-12-15T10:00:00Z',
+ action: 'add',
+ follower: {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ },
+ }
+
+ expect(isFollowEvent(followEvent)).toBe(true)
+ expect(isAddAction(followEvent)).toBe(true)
+ })
+
+ it('should handle multiple action types with same type', () => {
+ const followAddEvent: NotificationEvent = {
+ type: 'follow',
+ id: '1',
+ created_at: '2025-12-15T10:00:00Z',
+ action: 'add',
+ follower: {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ },
+ }
+
+ const followRemoveEvent: NotificationEvent = {
+ type: 'follow',
+ id: '2',
+ created_at: '2025-12-15T11:00:00Z',
+ action: 'remove',
+ follower_id: 'user1',
+ follower_name: 'User One',
+ follower_avatar_url: null,
+ followed_id: 'me',
+ }
+
+ expect(isFollowEvent(followAddEvent)).toBe(true)
+ expect(isAddAction(followAddEvent)).toBe(true)
+ expect(isFollowEvent(followRemoveEvent)).toBe(true)
+ expect(isRemoveAction(followRemoveEvent)).toBe(true)
+ })
+
+ it('should handle like aggregate events', () => {
+ const event: AggregateEvent = {
+ type: 'like',
+ id: '9',
+ created_at: '2025-12-15T16:00:00Z',
+ action: 'aggregate',
+ likers: [
+ {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ },
+ ],
+ tweets: [
+ {
+ tweet_id: 'tweet1',
+ type: 'tweet',
+ content: 'Hello',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ },
+ ],
+ old_notification: {
+ type: 'like',
+ id: '8',
+ created_at: '2025-12-15T15:00:00Z',
+ tweet_id: ['tweet1'],
+ liked_by: ['user1'],
+ },
+ }
+
+ expect(isLikeEvent(event)).toBe(true)
+ expect(isAggregateAction(event)).toBe(true)
+ })
+
+ it('should handle repost aggregate events', () => {
+ const event: AggregateEvent = {
+ type: 'repost',
+ id: '10',
+ created_at: '2025-12-15T17:00:00Z',
+ action: 'aggregate',
+ reposters: [
+ {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: null,
+ },
+ ],
+ tweets: [
+ {
+ tweet_id: 'tweet1',
+ type: 'tweet',
+ content: 'Hello',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ },
+ ],
+ old_notification: {
+ type: 'repost',
+ id: '9',
+ created_at: '2025-12-15T16:00:00Z',
+ tweet_id: ['tweet1'],
+ reposted_by: ['user1'],
+ },
+ }
+
+ expect(isRepostEvent(event)).toBe(true)
+ expect(isAggregateAction(event)).toBe(true)
+ })
+})
diff --git a/app/modules/notifications/test/unit/notificationsTypes.spec.ts b/app/modules/notifications/test/unit/notificationsTypes.spec.ts
new file mode 100644
index 00000000..86d08a87
--- /dev/null
+++ b/app/modules/notifications/test/unit/notificationsTypes.spec.ts
@@ -0,0 +1,362 @@
+import { describe, it, expect } from 'vitest'
+import type {
+ FollowNotification,
+ LikeNotification,
+ ReplyNotification,
+ RepostNotification,
+ QuoteNotification,
+ MentionNotification,
+ MessageNotification,
+ ApiNotification,
+ NotificationsApiResponse,
+ NotificationsApiData,
+ ApiMentions,
+ MentionsApiData,
+ MentionsApiResponse,
+} from '~/modules/notifications/types/notifications'
+
+describe('Notification Types', () => {
+ it('should create FollowNotification type correctly', () => {
+ const notification: FollowNotification = {
+ id: '1',
+ type: 'follow',
+ created_at: '2025-12-15T10:00:00Z',
+ followers: [
+ {
+ id: 'user1',
+ name: 'User One',
+ username: 'user1',
+ avatar_url: 'https://example.com/avatar1.jpg',
+ },
+ ],
+ }
+ expect(notification.type).toBe('follow')
+ expect(notification.followers).toHaveLength(1)
+ })
+
+ it('should create LikeNotification type correctly', () => {
+ const notification: LikeNotification = {
+ id: '2',
+ type: 'like',
+ created_at: '2025-12-15T11:00:00Z',
+ likers: [
+ {
+ id: 'user2',
+ name: 'User Two',
+ username: 'user2',
+ avatar_url: 'https://example.com/avatar2.jpg',
+ },
+ ],
+ tweets: [
+ {
+ tweet_id: 'tweet1',
+ type: 'tweet',
+ content: 'Hello world',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ },
+ ],
+ }
+ expect(notification.type).toBe('like')
+ expect(notification.likers).toHaveLength(1)
+ expect(notification.tweets).toHaveLength(1)
+ })
+
+ it('should create ReplyNotification type correctly', () => {
+ const notification: ReplyNotification = {
+ id: '3',
+ type: 'reply',
+ created_at: '2025-12-15T12:00:00Z',
+ replier: {
+ id: 'user3',
+ name: 'User Three',
+ username: 'user3',
+ avatar_url: 'https://example.com/avatar3.jpg',
+ },
+ reply_tweet: {
+ tweet_id: 'reply1',
+ type: 'reply',
+ content: 'Great post',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T11:00:00Z',
+ updated_at: '2025-12-15T11:00:00Z',
+ },
+ original_tweet: {
+ tweet_id: 'tweet2',
+ type: 'tweet',
+ content: 'Hello everyone',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ },
+ conversation_id: 'conv1',
+ }
+ expect(notification.type).toBe('reply')
+ expect(notification.replier.id).toBe('user3')
+ })
+
+ it('should create RepostNotification type correctly', () => {
+ const notification: RepostNotification = {
+ id: '4',
+ type: 'repost',
+ created_at: '2025-12-15T13:00:00Z',
+ reposters: [
+ {
+ id: 'user4',
+ name: 'User Four',
+ username: 'user4',
+ avatar_url: 'https://example.com/avatar4.jpg',
+ },
+ ],
+ tweets: [
+ {
+ tweet_id: 'tweet3',
+ type: 'tweet',
+ content: 'Shared content',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T12:00:00Z',
+ updated_at: '2025-12-15T12:00:00Z',
+ },
+ ],
+ }
+ expect(notification.type).toBe('repost')
+ expect(notification.reposters).toHaveLength(1)
+ })
+
+ it('should create QuoteNotification type correctly', () => {
+ const notification: QuoteNotification = {
+ id: '5',
+ type: 'quote',
+ created_at: '2025-12-15T14:00:00Z',
+ quoter: {
+ id: 'user5',
+ name: 'User Five',
+ username: 'user5',
+ avatar_url: 'https://example.com/avatar5.jpg',
+ },
+ quote_tweet: {
+ tweet_id: 'quote1',
+ type: 'quote',
+ content: 'My thoughts on this',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T13:00:00Z',
+ updated_at: '2025-12-15T13:00:00Z',
+ },
+ }
+ expect(notification.type).toBe('quote')
+ expect(notification.quoter.username).toBe('user5')
+ })
+
+ it('should create MentionNotification type correctly', () => {
+ const notification: MentionNotification = {
+ id: '6',
+ type: 'mention',
+ created_at: '2025-12-15T15:00:00Z',
+ mentioner: {
+ id: 'user6',
+ name: 'User Six',
+ username: 'user6',
+ avatar_url: 'https://example.com/avatar6.jpg',
+ },
+ tweet: {
+ tweet_id: 'tweet4',
+ type: 'tweet',
+ content: '@me check this out',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T14:00:00Z',
+ updated_at: '2025-12-15T14:00:00Z',
+ },
+ tweet_type: 'tweet',
+ }
+ expect(notification.type).toBe('mention')
+ expect(notification.tweet_type).toBe('tweet')
+ })
+
+ it('should create MessageNotification type correctly', () => {
+ const notification: MessageNotification = {
+ id: '7',
+ type: 'message',
+ created_at: '2025-12-15T16:00:00Z',
+ sender: {
+ id: 'user7',
+ name: 'User Seven',
+ username: 'user7',
+ avatar_url: 'https://example.com/avatar7.jpg',
+ },
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ }
+ expect(notification.type).toBe('message')
+ expect(notification.chat_id).toBe('chat1')
+ })
+
+ it('should create ApiNotification union type correctly', () => {
+ const notifications: ApiNotification[] = [
+ {
+ id: '1',
+ type: 'follow',
+ created_at: '2025-12-15T10:00:00Z',
+ followers: [],
+ },
+ {
+ id: '2',
+ type: 'like',
+ created_at: '2025-12-15T11:00:00Z',
+ likers: [],
+ tweets: [],
+ },
+ {
+ id: '3',
+ type: 'message',
+ created_at: '2025-12-15T16:00:00Z',
+ sender: {
+ id: 'user7',
+ name: 'User Seven',
+ username: 'user7',
+ avatar_url: null,
+ },
+ message_id: 'msg1',
+ chat_id: 'chat1',
+ },
+ ]
+ expect(notifications).toHaveLength(3)
+ expect(notifications[0]!.type).toBe('follow')
+ expect(notifications[2]!.type).toBe('message')
+ })
+
+ it('should create NotificationsApiResponse type correctly', () => {
+ const response: NotificationsApiResponse = {
+ data: {
+ notifications: [],
+ page: 1,
+ page_size: 20,
+ total: 0,
+ total_pages: 0,
+ has_next: false,
+ has_previous: false,
+ },
+ count: 0,
+ message: 'Success',
+ }
+ expect(response.data.page).toBe(1)
+ expect(response.data.notifications).toHaveLength(0)
+ })
+
+ it('should create NotificationsApiData type correctly', () => {
+ const data: NotificationsApiData = {
+ notifications: [
+ {
+ id: '1',
+ type: 'follow',
+ created_at: '2025-12-15T10:00:00Z',
+ followers: [],
+ },
+ ],
+ page: 1,
+ page_size: 20,
+ total: 1,
+ total_pages: 1,
+ has_next: false,
+ has_previous: false,
+ }
+ expect(data.notifications).toHaveLength(1)
+ expect(data.total).toBe(1)
+ })
+
+ it('should create ApiMentions type correctly', () => {
+ const mentions: ApiMentions[] = [
+ {
+ id: '1',
+ type: 'mention',
+ created_at: '2025-12-15T15:00:00Z',
+ mentioner: {
+ id: 'user6',
+ name: 'User Six',
+ username: 'user6',
+ avatar_url: null,
+ },
+ tweet: {
+ tweet_id: 'tweet4',
+ type: 'tweet',
+ content: '@me check this out',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T14:00:00Z',
+ updated_at: '2025-12-15T14:00:00Z',
+ },
+ tweet_type: 'tweet',
+ },
+ {
+ id: '2',
+ type: 'reply',
+ created_at: '2025-12-15T12:00:00Z',
+ replier: {
+ id: 'user3',
+ name: 'User Three',
+ username: 'user3',
+ avatar_url: null,
+ },
+ reply_tweet: {
+ tweet_id: 'reply1',
+ type: 'reply',
+ content: 'Great post',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T11:00:00Z',
+ updated_at: '2025-12-15T11:00:00Z',
+ },
+ original_tweet: {
+ tweet_id: 'tweet2',
+ type: 'tweet',
+ content: 'Hello everyone',
+ images: [],
+ videos: [],
+ created_at: '2025-12-15T10:00:00Z',
+ updated_at: '2025-12-15T10:00:00Z',
+ },
+ },
+ ]
+ expect(mentions).toHaveLength(2)
+ expect(mentions[0]!.type).toBe('mention')
+ expect(mentions[1]!.type).toBe('reply')
+ })
+
+ it('should create MentionsApiData type correctly', () => {
+ const data: MentionsApiData = {
+ notifications: [],
+ page: 1,
+ page_size: 20,
+ total: 0,
+ total_pages: 0,
+ has_next: false,
+ has_previous: false,
+ }
+ expect(data.page).toBe(1)
+ expect(data.notifications).toHaveLength(0)
+ })
+
+ it('should create MentionsApiResponse type correctly', () => {
+ const response: MentionsApiResponse = {
+ data: {
+ notifications: [],
+ page: 1,
+ page_size: 20,
+ total: 0,
+ total_pages: 0,
+ has_next: false,
+ has_previous: false,
+ },
+ count: 0,
+ message: 'Success',
+ }
+ expect(response.data).toBeDefined()
+ expect(response.count).toBe(0)
+ })
+})
diff --git a/app/modules/notifications/test/unit/useGetMentionsQuery.spec.ts b/app/modules/notifications/test/unit/useGetMentionsQuery.spec.ts
index c37ac63d..928211af 100644
--- a/app/modules/notifications/test/unit/useGetMentionsQuery.spec.ts
+++ b/app/modules/notifications/test/unit/useGetMentionsQuery.spec.ts
@@ -77,4 +77,33 @@ describe('useGetMentionsQuery', () => {
expect(typeof result.fetchPreviousMentions).toBe('function')
expect(typeof result.refetchMentions).toBe('function')
})
+
+ it('should call getNextPageParam with correct logic', () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ useGetMentionsQuery()
+ const call = (useInfiniteQuery as any).mock.calls[0][0]
+ const getNextPageParam = call.getNextPageParam
+
+ const pageWithNext = { has_next: true, page: 1, notifications: [] }
+ expect(getNextPageParam(pageWithNext)).toBe(2)
+
+ const pageWithoutNext = { has_next: false, page: 2, notifications: [] }
+ expect(getNextPageParam(pageWithoutNext)).toBeUndefined()
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should handle multiple pages correctly', () => {
+ const multiPageData = ref({
+ pages: [
+ { notifications: [{ id: '1' }] },
+ { notifications: [{ id: '2' }] },
+ { notifications: [{ id: '3' }] },
+ ],
+ })
+ mockQuery.data.value = multiPageData.value
+ const { mentions } = useGetMentionsQuery()
+ expect(mentions.value).toHaveLength(3)
+ expect(mentions.value).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }])
+ })
})
diff --git a/app/modules/notifications/test/unit/useGetNotificationsQuery.spec.ts b/app/modules/notifications/test/unit/useGetNotificationsQuery.spec.ts
index 89b7f7a0..04ea401e 100644
--- a/app/modules/notifications/test/unit/useGetNotificationsQuery.spec.ts
+++ b/app/modules/notifications/test/unit/useGetNotificationsQuery.spec.ts
@@ -131,18 +131,20 @@ describe('useGetNotificationsQuery', () => {
expect(notifications.value).toEqual([])
})
- it('should call queryFn with correct pageParam when fetching', () => {
- useGetNotificationsQuery()
-
- expect(useInfiniteQuery).toHaveBeenCalledWith(
- expect.objectContaining({
- queryKey: ['notifications'],
- queryFn: expect.any(Function),
- getNextPageParam: expect.any(Function),
- initialPageParam: 1,
- }),
- )
+ it('should log notifications data when computing', () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const { notifications } = useGetNotificationsQuery()
+ expect(notifications.value).toHaveLength(4)
+ expect(consoleSpy).toHaveBeenCalledWith('notifications', mockQueryData.value?.pages)
+ consoleSpy.mockRestore()
+ })
- expect(mockGetNotifications).toBeDefined()
+ it('should handle single page correctly', () => {
+ mockQueryData.value = {
+ pages: [mockNotificationsPage1],
+ }
+ const { notifications } = useGetNotificationsQuery()
+ expect(notifications.value).toHaveLength(2)
+ expect(notifications.value).toEqual(mockNotificationsPage1.notifications)
})
})
diff --git a/app/modules/settings/test/unit/AccountInformations.spec.ts b/app/modules/settings/test/unit/AccountInformations.spec.ts
index 449e522e..d16802a1 100644
--- a/app/modules/settings/test/unit/AccountInformations.spec.ts
+++ b/app/modules/settings/test/unit/AccountInformations.spec.ts
@@ -13,10 +13,12 @@ const mockUser = {
created_at: '2020-01-15T10:30:00Z',
}
+const userRef = ref(mockUser)
+
vi.mock('../../utils/calculations', () => ({
- formatFullDateTime: (_date: string) => 'January 15, 2020 at 10:30 AM',
- formatDate: (_date: string) => 'May 15, 1990',
- calculateAge: (_date: string) => 34,
+ formatFullDateTime: (date: string) => date ? 'January 15, 2020 at 10:30 AM' : '',
+ formatDate: (date: string) => date ? 'May 15, 1990' : '',
+ calculateAge: (date: string) => date ? 34 : 0,
}))
vi.mock('vue-i18n', () => ({
@@ -28,7 +30,7 @@ vi.mock('vue-i18n', () => ({
vi.mock('~/modules/auth/stores/userStore', () => {
return {
useUserStore: () => ({
- user: ref(mockUser),
+ user: userRef,
}),
}
})
@@ -44,30 +46,28 @@ interface AccountInformationInstance {
describe('AccountInformation Component', () => {
let wrapper: ReturnType
- beforeEach(() => {
- wrapper = mount(AccountInformation, {
- global: {
- stubs: {
- DetailedPanel: {
- props: ['title'],
- template:
- '',
- },
- DetailedRow: {
- props: ['category'],
- template: `
-
-
-
- `,
- },
- ChevronRight: true,
+ const createWrapper = () => mount(AccountInformation, {
+ global: {
+ stubs: {
+ DetailedPanel: {
+ props: ['title'],
+ template: '',
},
- mocks: {
- $t: (key: string) => key,
+ DetailedRow: {
+ props: ['category'],
+ template: `
`,
},
+ ChevronRight: true,
},
- })
+ mocks: {
+ $t: (key: string) => key,
+ },
+ },
+ })
+
+ beforeEach(() => {
+ userRef.value = mockUser
+ wrapper = createWrapper()
})
it('renders all user information with correct data and navigation', () => {
@@ -77,36 +77,12 @@ describe('AccountInformation Component', () => {
expect(rows.length).toBe(6)
const expectedRows = [
- {
- label: 'settings.accountInfo.username',
- content: 'hagar',
- href: '/settings/screen_name',
- },
- {
- label: 'settings.accountInfo.email',
- content: 'hagar@gmail.com',
- href: '/settings/email',
- },
- {
- label: 'settings.accountInfo.country',
- content: 'countries.Egypt',
- href: '/settings/country',
- },
- {
- label: 'settings.accountInfo.languages',
- content: 'English, Arabic',
- href: '/settings/languages',
- },
- {
- label: 'settings.accountInfo.birthDate',
- content: 'May 15, 1990',
- href: '/hagar/settings/profile',
- },
- {
- label: 'settings.accountInfo.age',
- content: '34',
- href: '/settings/your_yapper_data/age',
- },
+ { label: 'settings.accountInfo.username', content: 'hagar', href: '/settings/screen_name' },
+ { label: 'settings.accountInfo.email', content: 'hagar@gmail.com', href: '/settings/email' },
+ { label: 'settings.accountInfo.country', content: 'countries.Egypt', href: '/settings/country' },
+ { label: 'settings.accountInfo.languages', content: 'English, Arabic', href: '/settings/languages' },
+ { label: 'settings.accountInfo.birthDate', content: 'May 15, 1990', href: '/hagar/settings/profile' },
+ { label: 'settings.accountInfo.age', content: '34', href: '/settings/your_yapper_data/age' },
]
expectedRows.forEach((expected, index) => {
@@ -115,14 +91,10 @@ describe('AccountInformation Component', () => {
expect(rows[index]?.attributes('data-href')).toBe(expected.href)
})
- // Check account creation section separately
const accountSection = wrapper.find('.block.relative.px-5')
expect(accountSection.exists()).toBe(true)
expect(accountSection.text()).toContain('settings.accountInfo.accountCreation')
expect(accountSection.text()).toContain('January 15, 2020 at 10:30 AM')
-
- const countryRow = rows[2]
- expect(countryRow?.attributes('data-content')).toBe('countries.Egypt')
})
it('computes categories with correct formatting and hrefs', () => {
@@ -157,4 +129,39 @@ describe('AccountInformation Component', () => {
expect(accountSection.findComponent({ name: 'ChevronRight' }).exists()).toBe(true)
})
+ it('uses fallback values when user data is null or undefined', async () => {
+ userRef.value = null as any
+ wrapper = createWrapper()
+
+ const component = wrapper.vm as unknown as AccountInformationInstance
+ const categories = component.categories
+
+ expect(categories[0]?.content).toBeUndefined()
+ expect(categories[1]?.content).toBeUndefined()
+ expect(categories[4]?.content).toBe('')
+ expect(categories[5]?.content).toBe('0')
+ expect(categories[6]?.content).toBe('')
+
+ const rows = wrapper.findAll('.row')
+ rows.forEach((row) => {
+ expect(row.attributes('data-label')).toBeDefined()
+ })
+
+ const accountSection = wrapper.find('.block.relative.px-5')
+ expect(accountSection.exists()).toBe(true)
+ })
+
+ it('handles partial user data with missing fields', async () => {
+ userRef.value = { username: 'partial_user' } as any
+ wrapper = createWrapper()
+
+ const component = wrapper.vm as unknown as AccountInformationInstance
+ const categories = component.categories
+
+ expect(categories[0]?.content).toBe('partial_user')
+ expect(categories[1]?.content).toBeUndefined()
+ expect(categories[4]?.href).toBe('/partial_user/settings/profile')
+ expect(categories[4]?.content).toBe('')
+ expect(categories[5]?.content).toBe('0')
+ })
})
diff --git a/app/modules/settings/test/unit/BlockedAccounts.spec.ts b/app/modules/settings/test/unit/BlockedAccounts.spec.ts
index 4e0bf013..83ec9c85 100644
--- a/app/modules/settings/test/unit/BlockedAccounts.spec.ts
+++ b/app/modules/settings/test/unit/BlockedAccounts.spec.ts
@@ -37,8 +37,10 @@ const UserAccountItemStub = {
const SettingsBlockedButtonStub = { template: '' }
const fetchNextPageMock = vi.fn()
+const dataRef = ref(null)
+
const myBlockedUsersQuery = {
- data: ref(null),
+ data: dataRef,
isLoading: ref(false),
isSuccess: ref(false),
hasNextPage: ref(false),
@@ -74,7 +76,7 @@ describe('BlockedAccounts.vue', () => {
invalidateQueriesMock.mockClear()
observeMock.mockClear()
disconnectMock.mockClear()
- myBlockedUsersQuery.data.value = null
+ dataRef.value = null
myBlockedUsersQuery.isLoading.value = false
myBlockedUsersQuery.isSuccess.value = false
myBlockedUsersQuery.hasNextPage.value = false
@@ -82,9 +84,7 @@ describe('BlockedAccounts.vue', () => {
})
afterEach(() => {
- if (wrapper) {
- wrapper.unmount()
- }
+ if (wrapper) wrapper.unmount()
})
it('renders loading spinner when query is loading', async () => {
@@ -96,7 +96,7 @@ describe('BlockedAccounts.vue', () => {
it('renders blocked users when query has data', async () => {
wrapper = factory({
isSuccess: ref(true),
- data: ref({ pages: [{ data: { data: [{ user_id: 1 }, { user_id: 2 }] } }] }),
+ data: ref({ pages: [{ data: { data: [{ user_id: 1, is_blocked: true }, { user_id: 2, is_blocked: true }] } }] }),
})
await nextTick()
const users = wrapper.findAll('.user-item')
@@ -124,7 +124,7 @@ describe('BlockedAccounts.vue', () => {
expect(fetchNextPageMock).toHaveBeenCalled()
})
- it('does not call fetchNextPage if already fetching next page', async () => {
+ it('does not call fetchNextPage if already fetching or not intersecting', async () => {
wrapper = factory({
isSuccess: ref(true),
data: ref({ pages: [{ data: { data: [] } }] }),
@@ -134,6 +134,11 @@ describe('BlockedAccounts.vue', () => {
await nextTick()
observerCallback([{ isIntersecting: true }])
expect(fetchNextPageMock).not.toHaveBeenCalled()
+
+ fetchNextPageMock.mockClear()
+ myBlockedUsersQuery.isFetchingNextPage.value = false
+ observerCallback([{ isIntersecting: false }])
+ expect(fetchNextPageMock).not.toHaveBeenCalled()
})
it('computes users from all pages correctly', async () => {
@@ -150,4 +155,10 @@ describe('BlockedAccounts.vue', () => {
expect(wrapper.vm.users).toHaveLength(3)
})
+ it('disconnects observer on unmount', async () => {
+ wrapper = factory()
+ await nextTick()
+ wrapper.unmount()
+ expect(disconnectMock).toHaveBeenCalled()
+ })
})
diff --git a/app/modules/settings/test/unit/ChangeEmailForm.spec.ts b/app/modules/settings/test/unit/ChangeEmailForm.spec.ts
index c7fa4c28..5a223e65 100644
--- a/app/modules/settings/test/unit/ChangeEmailForm.spec.ts
+++ b/app/modules/settings/test/unit/ChangeEmailForm.spec.ts
@@ -1,10 +1,11 @@
import { mount, flushPromises } from '@vue/test-utils'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ChangeEmailForm from '~/modules/settings/components/AccountInformations/SubComponents/ChangeEmailForm.vue'
-import { ref } from 'vue'
+import { ref, nextTick } from 'vue'
export const mockMutateAsync = vi.fn()
+const errorRef = ref(null)
vi.mock('~/modules/settings/queries/userSettingsQueries', () => ({
userSettingsQueries: () => ({
@@ -12,7 +13,7 @@ vi.mock('~/modules/settings/queries/userSettingsQueries', () => ({
mutateAsync: mockMutateAsync,
isPending: ref(false),
isError: ref(false),
- error: ref(null),
+ error: errorRef,
},
}),
}))
@@ -25,7 +26,6 @@ vi.mock('~/modules/auth/stores/userStore', () => ({
}),
}))
-// Mock router
const routerBack = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({
@@ -37,8 +37,10 @@ describe('ChangeEmailForm', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
+ errorRef.value = null
})
+
const factory = () =>
mount(ChangeEmailForm, {
global: {
@@ -46,13 +48,20 @@ describe('ChangeEmailForm', () => {
$t: (key: string) => key,
},
stubs: {
- Popup: { template: '
' },
+ Popup: {
+ template: '
',
+ props: ['isOpen', 'hasCloseButton'],
+ emits: ['close'],
+ },
Logo: true,
Button: {
template: ``,
+ props: ['isLoading', 'buttonText', 'buttonClass'],
},
VerifyEmailOTP: {
template: '',
+ props: ['isOpen', 'newEmail'],
+ emits: ['close', 'verified'],
},
},
},
@@ -60,17 +69,14 @@ describe('ChangeEmailForm', () => {
it('renders title and description correctly', () => {
const wrapper = factory()
-
expect(wrapper.text()).toContain('settings.accountInfo.change_email')
expect(wrapper.text()).toContain('settings.accountInfo.change_email_description')
})
it('shows validation error for invalid email', async () => {
const wrapper = factory()
-
const input = wrapper.find('#input-new-email')
await input.setValue('invalid-email')
-
expect(wrapper.text()).toContain('Please enter a valid email.')
expect(wrapper.find('#cancel-email-form-button').exists()).toBe(true)
expect(wrapper.find('#next-email-form-button').exists()).toBe(false)
@@ -78,51 +84,109 @@ describe('ChangeEmailForm', () => {
it('shows next button when email is valid', async () => {
const wrapper = factory()
-
await wrapper.find('#input-new-email').setValue('valid@email.com')
-
expect(wrapper.find('#next-email-form-button').exists()).toBe(true)
expect(wrapper.find('#cancel-email-form-button').exists()).toBe(false)
})
it('opens OTP modal after successful submit', async () => {
mockMutateAsync.mockResolvedValueOnce({})
-
const wrapper = factory()
await wrapper.find('#input-new-email').setValue('valid@email.com')
await wrapper.find('form').trigger('submit.prevent')
-
await flushPromises()
-
- expect(mockMutateAsync).toHaveBeenCalledWith({
- newEmail: 'valid@email.com',
- })
-
+ expect(mockMutateAsync).toHaveBeenCalledWith({ newEmail: 'valid@email.com' })
expect(wrapper.find('[data-test="otp-modal"]').exists()).toBe(true)
})
- it('shows error message when mutation fails', async () => {
+ it('shows error message when mutation fails with axios-like error', async () => {
mockMutateAsync.mockRejectedValueOnce({
- response: {
- data: { message: 'Email already exists' },
- },
+ response: { data: { message: 'Email already exists' } },
})
-
const wrapper = factory()
await wrapper.find('#input-new-email').setValue('valid@email.com')
await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+ expect(wrapper.text()).toContain('Email already exists')
+ })
+ it('shows error message when mutation fails with Error instance', async () => {
+ mockMutateAsync.mockRejectedValueOnce(new Error('Network error'))
+ const wrapper = factory()
+ await wrapper.find('#input-new-email').setValue('valid@email.com')
+ await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
+ expect(wrapper.text()).toContain('Network error')
+ })
- expect(wrapper.text()).toContain('Email already exists')
+ it('shows generic error when mutation fails with unknown error type', async () => {
+ mockMutateAsync.mockRejectedValueOnce('unknown error')
+ const wrapper = factory()
+ await wrapper.find('#input-new-email').setValue('valid@email.com')
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+ expect(wrapper.text()).toContain('An error occurred. Please try again.')
})
it('resets email and navigates back on close', async () => {
const wrapper = factory()
-
await wrapper.find('#input-new-email').setValue('invalid-email')
await wrapper.find('#cancel-email-form-button').trigger('click')
-
expect(routerBack).toHaveBeenCalled()
})
+
+ it('clears error message when email input changes', async () => {
+ mockMutateAsync.mockRejectedValueOnce(new Error('Some error'))
+ const wrapper = factory()
+ await wrapper.find('#input-new-email').setValue('valid@email.com')
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+ expect(wrapper.text()).toContain('Some error')
+
+ await wrapper.find('#input-new-email').setValue('new@email.com')
+ await nextTick()
+ expect(wrapper.vm.errorMessage).toBe('')
+ })
+
+ it('handles OTP modal close event', async () => {
+ mockMutateAsync.mockResolvedValueOnce({})
+ const wrapper = factory()
+ await wrapper.find('#input-new-email').setValue('valid@email.com')
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(wrapper.vm.showOTPModal).toBe(true)
+ wrapper.vm.handleCloseOTP()
+ expect(wrapper.vm.showOTPModal).toBe(false)
+ })
+
+ it('handles email verified by closing OTP modal', async () => {
+ mockMutateAsync.mockResolvedValueOnce({})
+ const wrapper = factory()
+ await wrapper.find('#input-new-email').setValue('valid@email.com')
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(wrapper.vm.showOTPModal).toBe(true)
+ wrapper.vm.handleEmailVerified()
+ expect(wrapper.vm.showOTPModal).toBe(false)
+ })
+
+ it('updates error message when mutation error ref changes', async () => {
+ const wrapper = factory()
+ await nextTick()
+
+ errorRef.value = new Error('Mutation error from watcher')
+ await nextTick()
+
+ expect(wrapper.vm.errorMessage).toBe('Mutation error from watcher')
+ })
+
+ it('does not submit when email is invalid', async () => {
+ const wrapper = factory()
+ await wrapper.find('#input-new-email').setValue('invalid')
+ await wrapper.find('form').trigger('submit.prevent')
+ await flushPromises()
+ expect(mockMutateAsync).not.toHaveBeenCalled()
+ })
})
diff --git a/app/modules/settings/test/unit/ChangeUsername.spec.ts b/app/modules/settings/test/unit/ChangeUsername.spec.ts
index 537719a1..1b3f6afd 100644
--- a/app/modules/settings/test/unit/ChangeUsername.spec.ts
+++ b/app/modules/settings/test/unit/ChangeUsername.spec.ts
@@ -1,15 +1,15 @@
-import { describe, it, expect, vi } from 'vitest'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
-import { ref } from 'vue'
+import { ref, nextTick } from 'vue'
import ChangeUsername from '~/modules/settings/components/AccountInformations/ChangeUsername.vue'
const DetailedPanelStub = { name: 'DetailedPanel', template: '
' }
const ButtonStub = {
name: 'Button',
- props: ['isLoading', 'buttonText'],
- template: '',
+ props: ['isLoading', 'buttonText', 'disabled'],
+ template: '',
}
-const LoadingSpinnerStub = { name: 'LoadingSpinner', template: '' }
+const LoadingSpinnerStub = { name: 'LoadingSpinner', template: '' }
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
@@ -20,14 +20,17 @@ vi.mock('~/modules/auth/stores/userStore', () => ({
useUserStore: () => ({ user: userRef }),
}))
-const mutateAsyncMock = vi.fn().mockResolvedValue({})
+const mutateAsyncMock = vi.fn()
const resetMock = vi.fn()
+const isSuccessRef = ref(false)
+const isErrorRef = ref(false)
+
vi.mock('~/modules/settings/queries/userSettingsQueries', () => ({
userSettingsQueries: () => ({
updateUsernameMutation: {
isPending: ref(false),
- isError: ref(false),
- isSuccess: ref(false),
+ isError: isErrorRef,
+ isSuccess: isSuccessRef,
isPaused: ref(false),
error: ref(null),
mutateAsync: mutateAsyncMock,
@@ -54,13 +57,24 @@ describe('ChangeUsername.vue', () => {
},
})
+ beforeEach(() => {
+ vi.clearAllMocks()
+ isSuccessRef.value = false
+ isErrorRef.value = false
+ userRef.value = { username: 'oldUsername' }
+ })
+
+ afterEach(() => {
+ if (wrapper) wrapper.unmount()
+ })
+
it('renders current username in input', () => {
wrapper = factory()
const input = wrapper.find('input#username-input')
expect(input.element.value).toBe('oldUsername')
})
- it('validates username input format', async () => {
+ it('validates username input format and removes invalid characters', async () => {
wrapper = factory()
const input = wrapper.find('input#username-input')
await input.setValue('invalid*user!')
@@ -69,41 +83,83 @@ describe('ChangeUsername.vue', () => {
it('shows username suggestions and allows selection', async () => {
wrapper = factory()
- const suggestion = wrapper.findAll('div.text-accent')[0]
- expect(suggestion?.text()).toBe('newUser1')
- await suggestion?.trigger('click')
+ const suggestions = wrapper.findAll('div.text-accent')
+ expect(suggestions[0]?.text()).toBe('newUser1')
+ await suggestions[0]?.trigger('click')
const input = wrapper.find('input#username-input')
expect(input.element.value).toBe('newUser1')
})
- it('disables submit if username invalid or unchanged', () => {
+ it('disables submit if username invalid, unchanged, or too short', () => {
wrapper = factory()
const comp = wrapper.vm as any
+
comp.newUsername = 'oldUsername'
- expect(comp.canSubmit).toBe(false)
- comp.newUsername = 'ab'
- expect(comp.canSubmit).toBe(false)
+ expect(comp.canSubmit).toBeFalsy()
+
+ comp.newUsername = 'ab'
+ expect(comp.canSubmit).toBeFalsy()
+
+ comp.newUsername = ''
+ expect(comp.canSubmit).toBeFalsy()
+
+ comp.newUsername = 'validNewUsername'
+ expect(comp.canSubmit).toBeTruthy()
})
- it('submits new username if valid', async () => {
+ it('submits new username and handles success with reset after timeout', async () => {
+ mutateAsyncMock.mockResolvedValue({})
wrapper = factory()
const comp = wrapper.vm as any
comp.newUsername = 'validUsername'
+
+ vi.useFakeTimers()
await comp.handleSubmit()
+
expect(mutateAsyncMock).toHaveBeenCalledWith({ username: 'validUsername' })
+
+ vi.advanceTimersByTime(2000)
+ expect(resetMock).toHaveBeenCalled()
+ vi.useRealTimers()
})
- it('resets mutation state after submission', async () => {
+ it('handles submit error and logs to console', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mutateAsyncMock.mockRejectedValue(new Error('Network error'))
+
wrapper = factory()
const comp = wrapper.vm as any
comp.newUsername = 'validUsername'
-
- vi.useFakeTimers()
+
await comp.handleSubmit()
+
+ expect(consoleError).toHaveBeenCalledWith('Failed to update username:', expect.any(Error))
+ consoleError.mockRestore()
+ })
- vi.advanceTimersByTime(2000)
-
+ it('resets mutation when username changes and mutation was successful or had error', async () => {
+ wrapper = factory()
+ const comp = wrapper.vm as any
+
+ isSuccessRef.value = true
+ comp.newUsername = 'changedUsername'
+ await nextTick()
expect(resetMock).toHaveBeenCalled()
- vi.useRealTimers()
+
+ resetMock.mockClear()
+ isSuccessRef.value = false
+ isErrorRef.value = true
+ comp.newUsername = 'anotherChange'
+ await nextTick()
+ expect(resetMock).toHaveBeenCalled()
+ })
+
+ it('does not submit when canSubmit is false', async () => {
+ wrapper = factory()
+ const comp = wrapper.vm as any
+ comp.newUsername = 'oldUsername' // same as current
+
+ await comp.handleSubmit()
+ expect(mutateAsyncMock).not.toHaveBeenCalled()
})
})
diff --git a/app/modules/settings/test/unit/MuteAndBlock.spec.ts b/app/modules/settings/test/unit/MuteAndBlock.spec.ts
index 9d448ef3..4fc21095 100644
--- a/app/modules/settings/test/unit/MuteAndBlock.spec.ts
+++ b/app/modules/settings/test/unit/MuteAndBlock.spec.ts
@@ -1,21 +1,25 @@
-import { describe, it, expect } from 'vitest'
-import { mount } from '@vue/test-utils'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { mount, shallowMount } from '@vue/test-utils'
import MuteAndBlock from '~/modules/settings/components/MuteAndBlock/MuteAndBlock.vue'
-const DetailedPanelStub = { template: '
', props: ['title'] }
+const DetailedPanelStub = {
+ template: '
',
+ props: ['title']
+}
+
const DetailedRowStub = {
- template: '{{ category.label }}
',
+ template: '{{ category?.label }} - {{ category?.href }}
',
props: ['category'],
}
describe('MuteAndBlock.vue', () => {
let wrapper: ReturnType
- const factory = () =>
- mount(MuteAndBlock, {
+ const factory = (tMock?: any) => {
+ return mount(MuteAndBlock, {
global: {
mocks: {
- t: (key: string) => key,
+ t: tMock || ((key: string) => key),
},
stubs: {
DetailedPanel: DetailedPanelStub,
@@ -23,18 +27,78 @@ describe('MuteAndBlock.vue', () => {
},
},
})
+ }
- it('renders DetailedPanel with description', () => {
+ beforeEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ it('renders DetailedPanel with correct title and description text', () => {
wrapper = factory()
+
+ expect(wrapper.find('.detailed-panel').exists()).toBe(true)
expect(wrapper.html()).toContain('settings.muteAndBlock')
expect(wrapper.html()).toContain('settings.muteAndBlock_desc')
+ expect(wrapper.find('p.text-muted').text()).toBe('settings.muteAndBlock_desc')
+ })
+
+ it('renders two DetailedRow components with correct category props', () => {
+ wrapper = factory()
+
+ const rows = wrapper.findAllComponents(DetailedRowStub)
+ expect(rows).toHaveLength(2)
+
+ expect(rows[0]!.props('category')).toEqual({
+ label: 'settings.blockedAccounts',
+ href: '/settings/blocked/all',
+ })
+ expect(rows[1]!.props('category')).toEqual({
+ label: 'settings.mutedAccounts',
+ href: '/settings/muted/all',
+ })
+
+ expect(rows[0]!.text()).toContain('settings.blockedAccounts')
+ expect(rows[0]!.text()).toContain('/settings/blocked/all')
+ expect(rows[1]!.text()).toContain('settings.mutedAccounts')
+ expect(rows[1]!.text()).toContain('/settings/muted/all')
+ })
+
+ it('uses i18n translation function and verifies all text content', () => {
+ wrapper = factory()
+
+ const html = wrapper.html()
+ expect(html).toContain('settings.muteAndBlock')
+ expect(html).toContain('settings.muteAndBlock_desc')
+ expect(html).toContain('settings.blockedAccounts')
+ expect(html).toContain('settings.mutedAccounts')
})
- it('renders two DetailedRow components with correct categories', () => {
+ it('provides valid category objects with required properties', () => {
wrapper = factory()
+
const rows = wrapper.findAllComponents(DetailedRowStub)
expect(rows).toHaveLength(2)
- expect(rows[0].text()).toBe('settings.blockedAccounts')
- expect(rows[1].text()).toBe('settings.mutedAccounts')
+
+ rows.forEach((row) => {
+ const category = row.props('category')
+ expect(category).toBeDefined()
+ expect(category).toHaveProperty('label')
+ expect(category).toHaveProperty('href')
+ expect(typeof category.label).toBe('string')
+ expect(typeof category.href).toBe('string')
+ expect(category.label.length).toBeGreaterThan(0)
+ expect(category.href.startsWith('/settings/')).toBe(true)
+ })
+ })
+
+ it('renders with correct structure and CSS classes', () => {
+ wrapper = factory()
+
+ expect(wrapper.find('.detailed-panel').exists()).toBe(true)
+ expect(wrapper.find('div.relative.w-full.px-5.py-2').exists()).toBe(true)
+ expect(wrapper.find('p.text-muted.text-\\[13px\\]').exists()).toBe(true)
+ expect(wrapper.findAll('.detailed-row')).toHaveLength(2)
})
})
diff --git a/app/modules/settings/test/unit/UserListSettings.spec.ts b/app/modules/settings/test/unit/UserListSettings.spec.ts
index 1ba3ee4a..c5f096ce 100644
--- a/app/modules/settings/test/unit/UserListSettings.spec.ts
+++ b/app/modules/settings/test/unit/UserListSettings.spec.ts
@@ -3,7 +3,6 @@ import { mount } from '@vue/test-utils'
import { ref, nextTick } from 'vue'
import UserListSettings from '~/modules/settings/components/MuteAndBlock/SubComponents/UserListSettings.vue'
-
let observerCallback: Function
const observeMock = vi.fn()
const disconnectMock = vi.fn()
@@ -45,7 +44,7 @@ const createQuery = (overrides = {}) => ({
...overrides,
})
-describe('MutedOrBlockedList.vue', () => {
+describe('UserListSettings.vue', () => {
let wrapper: any
let query: any
@@ -82,9 +81,7 @@ describe('MutedOrBlockedList.vue', () => {
it('renders loading spinner when loading', async () => {
wrapper = factory({ isLoading: ref(true) })
-
await nextTick()
-
expect(wrapper.find('.animate-spin').exists()).toBe(true)
})
@@ -92,12 +89,10 @@ describe('MutedOrBlockedList.vue', () => {
wrapper = factory({
isSuccess: ref(true),
data: ref({
- pages: [{ data: { data: [{ user_id: 1 }, { user_id: 2 }] } }],
+ pages: [{ data: { data: [{ user_id: 1, is_blocked: true }, { user_id: 2, is_blocked: false }] } }],
}),
})
-
await nextTick()
-
const users = wrapper.findAll('.user-item')
expect(users).toHaveLength(2)
})
@@ -109,9 +104,7 @@ describe('MutedOrBlockedList.vue', () => {
pages: [{ data: { data: [] } }],
}),
})
-
await nextTick()
-
expect(wrapper.text()).toContain('Empty title')
expect(wrapper.text()).toContain('Empty description')
})
@@ -126,9 +119,7 @@ describe('MutedOrBlockedList.vue', () => {
],
}),
})
-
await nextTick()
-
expect(wrapper.vm.users).toHaveLength(3)
})
@@ -140,15 +131,12 @@ describe('MutedOrBlockedList.vue', () => {
pages: [{ data: { data: [] } }],
}),
})
-
await nextTick()
-
observerCallback([{ isIntersecting: true }])
-
expect(fetchNextPageMock).toHaveBeenCalled()
})
- it('does not call fetchNextPage when already fetching', async () => {
+ it('does not call fetchNextPage when already fetching or not intersecting', async () => {
wrapper = factory({
isSuccess: ref(true),
hasNextPage: ref(true),
@@ -157,20 +145,39 @@ describe('MutedOrBlockedList.vue', () => {
pages: [{ data: { data: [] } }],
}),
})
-
await nextTick()
-
observerCallback([{ isIntersecting: true }])
+ expect(fetchNextPageMock).not.toHaveBeenCalled()
+ fetchNextPageMock.mockClear()
+ query.isFetchingNextPage.value = false
+ observerCallback([{ isIntersecting: false }])
expect(fetchNextPageMock).not.toHaveBeenCalled()
})
+ it('triggers watchers and logs data when query data changes with pages', async () => {
+ const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const dataRef = ref(null)
+
+ wrapper = factory({
+ isSuccess: ref(true),
+ data: dataRef,
+ })
+ await nextTick()
+
+ dataRef.value = { pages: [{ data: { data: [{ user_id: 1 }] } }] }
+ await nextTick()
+
+ expect(consoleLog).toHaveBeenCalledWith('Blocked users response:', expect.anything())
+ expect(consoleLog).toHaveBeenCalledWith('Total pages loaded:', 1)
+ expect(consoleLog).toHaveBeenCalledWith('All pages:', expect.any(Array))
+ consoleLog.mockRestore()
+ })
+
it('disconnects observer on unmount', async () => {
wrapper = factory()
-
await nextTick()
wrapper.unmount()
-
expect(disconnectMock).toHaveBeenCalled()
})
})
diff --git a/app/modules/settings/test/unit/settingsService.spec.ts b/app/modules/settings/test/unit/settingsService.spec.ts
index e6830793..706f4179 100644
--- a/app/modules/settings/test/unit/settingsService.spec.ts
+++ b/app/modules/settings/test/unit/settingsService.spec.ts
@@ -24,251 +24,244 @@ describe('settingsService', () => {
vi.clearAllMocks()
})
- it('should successfully change password', async () => {
- const mockResponse = {
- data: {
- message: 'Password changed successfully',
- success: true,
- },
+ it('should handle getMuted and getBlocked operations with success and all error scenarios', async () => {
+ const mutedResponse = {
+ data: { data: { data: [{ id: '1', username: 'user1' }] } },
}
+ mockAxios.get.mockResolvedValueOnce(mutedResponse)
+ const mutedResult = await settingsService.getMuted()
+ expect(mockAxios.get).toHaveBeenCalledWith('/users/me/muted', { params: {} })
+ expect(mutedResult).toEqual(mutedResponse.data)
- mockAxios.post.mockResolvedValueOnce(mockResponse)
- const result = await settingsService.changePassword('oldPass123', 'newPass456')
+ mockAxios.get.mockResolvedValueOnce(mutedResponse)
+ await settingsService.getMuted('cursor123')
+ expect(mockAxios.get).toHaveBeenCalledWith('/users/me/muted', { params: { cursor: 'cursor123' } })
- expect(mockAxios.post).toHaveBeenCalledWith('/auth/change-password', {
- old_password: 'oldPass123',
- new_password: 'newPass456',
- })
- expect(result).toEqual(mockResponse.data)
- })
- it('should throw error on wrong password (401) and invalid format (400)', async () => {
const error401 = new Error('Unauthorized') as any
- error401.response = {
- status: 401,
- data: { message: 'Wrong password' },
- }
+ error401.response = { status: 401 }
+ mockAxios.get.mockRejectedValueOnce(error401)
+ await expect(settingsService.getMuted()).rejects.toThrow('Invalid or expired token')
- mockAxios.post.mockRejectedValueOnce(error401)
- await expect(settingsService.changePassword('oldPass', 'newPass')).rejects.toThrow(
- 'Wrong password',
- )
+ const error404 = new Error('Not Found') as any
+ error404.response = { status: 404 }
+ mockAxios.get.mockRejectedValueOnce(error404)
+ await expect(settingsService.getMuted()).rejects.toThrow('Muted users not found')
- const error400 = new Error('Bad Request') as any
- error400.response = {
- status: 400,
- data: { message: 'Invalid password format' },
- }
-
- mockAxios.post.mockRejectedValueOnce(error400)
- await expect(settingsService.changePassword('old', 'new')).rejects.toThrow(
- 'Invalid password format',
- )
- })
+ mockAxios.get.mockRejectedValueOnce(new Error('Network error'))
+ await expect(settingsService.getMuted()).rejects.toThrow('Something went wrong')
- it('should successfully update username and handle duplicate/invalid errors', async () => {
- const mockResponse = {
- data: {
- data: { username: 'newusername' },
- message: 'Username updated successfully',
- },
+ const blockedResponse = {
+ data: { data: [{ id: '1', username: 'blocked1' }] },
}
+ mockAxios.get.mockResolvedValueOnce(blockedResponse)
+ const blockedResult = await settingsService.getBlocked()
+ expect(blockedResult).toEqual(blockedResponse.data)
- mockAxios.post.mockResolvedValueOnce(mockResponse)
- const result = await settingsService.updateUsername('newusername')
-
- expect(mockAxios.post).toHaveBeenCalledWith('/auth/update-username', {
- username: 'newusername',
- })
- expect(result).toEqual(mockResponse.data)
-
- const error409 = new Error('Conflict') as any
- error409.response = { status: 409 }
+ mockAxios.get.mockResolvedValueOnce(blockedResponse)
+ await settingsService.getBlocked('cursor456')
+ expect(mockAxios.get).toHaveBeenCalledWith('/users/me/blocked', { params: { cursor: 'cursor456' } })
- mockAxios.post.mockRejectedValueOnce(error409)
- await expect(settingsService.updateUsername('taken')).rejects.toThrow(
- 'Username is already taken',
- )
+ mockAxios.get.mockRejectedValueOnce(error401)
+ await expect(settingsService.getBlocked()).rejects.toThrow('Invalid or expired token')
- const error400 = new Error('Bad Request') as any
- error400.response = {
- status: 400,
- data: { message: 'Username too short' },
- }
+ mockAxios.get.mockRejectedValueOnce(error404)
+ await expect(settingsService.getBlocked()).rejects.toThrow('Blocked users not found')
- mockAxios.post.mockRejectedValueOnce(error400)
- await expect(settingsService.updateUsername('ab')).rejects.toThrow('Username too short')
+ mockAxios.get.mockRejectedValueOnce(new Error('Network error'))
+ await expect(settingsService.getBlocked()).rejects.toThrow('Something went wrong')
})
- it('should handle email OTP operations and verify email with various error codes', async () => {
- const sendResponse = {
- data: {
- message: 'OTP sent to email',
- },
- }
+ it('should handle changePassword with success and all error codes (401, 400, 404, generic)', async () => {
+ const successResponse = { data: { message: 'Password changed', success: true } }
+ mockAxios.post.mockResolvedValueOnce(successResponse)
+ const result = await settingsService.changePassword('oldPass', 'newPass')
+ expect(result).toEqual(successResponse.data)
- mockAxios.post.mockResolvedValueOnce(sendResponse)
- const sendResult = await settingsService.sendEmailOTP('newemail@example.com')
-
- expect(mockAxios.post).toHaveBeenCalledWith('/auth/update-email', {
- new_email: 'newemail@example.com',
- })
- expect(sendResult).toEqual(sendResponse.data)
+ const error401 = new Error('Unauthorized') as any
+ error401.response = { status: 401, data: { message: 'Wrong password' } }
+ mockAxios.post.mockRejectedValueOnce(error401)
+ await expect(settingsService.changePassword('old', 'new')).rejects.toThrow('Wrong password')
- const verifyResponse = {
- data: {
- message: 'Email verified successfully',
- },
- }
+ const error400 = new Error('Bad Request') as any
+ error400.response = { status: 400, data: { message: 'Invalid format' } }
+ mockAxios.post.mockRejectedValueOnce(error400)
+ await expect(settingsService.changePassword('old', 'new')).rejects.toThrow('Invalid format')
- mockAxios.post.mockResolvedValueOnce(verifyResponse)
- const verifyResult = await settingsService.verifyEmailOTP('newemail@example.com', '123456')
-
- expect(mockAxios.post).toHaveBeenCalledWith('/auth/update-email/verify', {
- new_email: 'newemail@example.com',
- otp: '123456',
- })
- expect(verifyResult).toEqual(verifyResponse.data)
-
- const errorBackend = new Error('Custom error') as any
- errorBackend.response = {
- status: 400,
- data: { error: 'OTP expired' },
- }
+ const error404 = new Error('Not Found') as any
+ error404.response = { status: 404 }
+ mockAxios.post.mockRejectedValueOnce(error404)
+ await expect(settingsService.changePassword('old', 'new')).rejects.toThrow('User not found')
- mockAxios.post.mockRejectedValueOnce(errorBackend)
- await expect(
- settingsService.verifyEmailOTP('email@example.com', '123'),
- ).rejects.toThrow('OTP expired')
+ mockAxios.post.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.changePassword('old', 'new')).rejects.toThrow('Failed to change password')
})
- it('should handle language change and account operations with various status codes', async () => {
- const langResponse = {
- data: {
- message: 'Language changed to Arabic',
- },
- }
-
- mockAxios.patch.mockResolvedValueOnce(langResponse)
- const langResult = await settingsService.changeLanguage('ar')
-
- expect(mockAxios.patch).toHaveBeenCalledWith('/users/me/change-language', {
- language: 'ar',
- })
- expect(langResult).toBe(langResponse.data.message)
-
- const deleteResponse = {
- data: {
- message: 'Account deleted successfully',
- },
- }
-
+ it('should handle deleteAccount and confirmPassword with all error scenarios', async () => {
+ const deleteResponse = { data: { message: 'Deleted' } }
mockAxios.delete.mockResolvedValueOnce(deleteResponse)
const deleteResult = await settingsService.deleteAccount()
-
- expect(mockAxios.delete).toHaveBeenCalledWith('/users/me/delete-account')
expect(deleteResult).toEqual(deleteResponse.data)
-
const error401 = new Error('Unauthorized') as any
error401.response = { status: 401 }
+ mockAxios.delete.mockRejectedValueOnce(error401)
+ await expect(settingsService.deleteAccount()).rejects.toThrow('Invalid or expired token')
- mockAxios.patch.mockRejectedValueOnce(error401)
- await expect(settingsService.changeLanguage('en')).rejects.toThrow(
- 'Invalid or expired token',
- )
- })
-
- it('should handle confirm password with all error scenarios and fetch user lists', async () => {
+ const error404 = new Error('Not Found') as any
+ error404.response = { status: 404 }
+ mockAxios.delete.mockRejectedValueOnce(error404)
+ await expect(settingsService.deleteAccount()).rejects.toThrow('User not found')
- const confirmResponse = {
- data: {
- confirmed: true,
- },
- }
+ mockAxios.delete.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.deleteAccount()).rejects.toThrow('Failed to delete account')
+ const confirmResponse = { data: { confirmed: true } }
mockAxios.post.mockResolvedValueOnce(confirmResponse)
- const confirmResult = await settingsService.confirmPassword('password123')
-
- expect(mockAxios.post).toHaveBeenCalledWith('/auth/confirm-password', {
- password: 'password123',
- })
+ const confirmResult = await settingsService.confirmPassword('password')
expect(confirmResult).toEqual(confirmResponse.data)
const error403 = new Error('Forbidden') as any
error403.response = { status: 403 }
-
mockAxios.post.mockRejectedValueOnce(error403)
await expect(settingsService.confirmPassword('wrong')).rejects.toThrow('WRONG_PASSWORD')
-
const error409 = new Error('Conflict') as any
error409.response = { status: 409 }
-
mockAxios.post.mockRejectedValueOnce(error409)
await expect(settingsService.confirmPassword('pass')).rejects.toThrow('NO_PASSWORD_SET')
+ mockAxios.post.mockRejectedValueOnce(error404)
+ await expect(settingsService.confirmPassword('pass')).rejects.toThrow('USER_NOT_FOUND')
- const mutedResponse = {
- data: {
- data: {
- data: [
- { id: '1', username: 'user1' },
- { id: '2', username: 'user2' },
- ],
- },
- },
- }
+ mockAxios.post.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.confirmPassword('pass')).rejects.toThrow('UNKNOWN')
+ })
- mockAxios.get.mockResolvedValueOnce(mutedResponse)
- const mutedResult = await settingsService.getMuted()
+ it('should handle updateUsername and getUsernameRecommendations with all errors', async () => {
+ const usernameResponse = { data: { data: { username: 'newname' } } }
+ mockAxios.post.mockResolvedValueOnce(usernameResponse)
+ const result = await settingsService.updateUsername('newname')
+ expect(result).toEqual(usernameResponse.data)
- expect(mockAxios.get).toHaveBeenCalledWith('/users/me/muted', { params: {} })
- expect(mutedResult).toEqual(mutedResponse.data)
+ const error401 = new Error('Unauthorized') as any
+ error401.response = { status: 401 }
+ mockAxios.post.mockRejectedValueOnce(error401)
+ await expect(settingsService.updateUsername('name')).rejects.toThrow('Invalid or expired token')
- const mutedCursorResponse = {
- data: {
- data: {
- data: [{ id: '3', username: 'user3' }],
- },
- },
- }
+ const error404 = new Error('Not Found') as any
+ error404.response = { status: 404 }
+ mockAxios.post.mockRejectedValueOnce(error404)
+ await expect(settingsService.updateUsername('name')).rejects.toThrow('User not found')
- mockAxios.get.mockResolvedValueOnce(mutedCursorResponse)
- const mutedCursorResult = await settingsService.getMuted('cursor123')
+ const error409 = new Error('Conflict') as any
+ error409.response = { status: 409 }
+ mockAxios.post.mockRejectedValueOnce(error409)
+ await expect(settingsService.updateUsername('taken')).rejects.toThrow('Username is already taken')
- expect(mockAxios.get).toHaveBeenCalledWith('/users/me/muted', {
- params: { cursor: 'cursor123' },
- })
- expect(mutedCursorResult).toEqual(mutedCursorResponse.data)
+ const error400 = new Error('Bad Request') as any
+ error400.response = { status: 400, data: { message: 'Too short' } }
+ mockAxios.post.mockRejectedValueOnce(error400)
+ await expect(settingsService.updateUsername('ab')).rejects.toThrow('Too short')
+ mockAxios.post.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.updateUsername('name')).rejects.toThrow('Failed to update username')
- const blockedResponse = {
- data: {
- data: [
- { id: '1', username: 'blocked1' },
- { id: '2', username: 'blocked2' },
- ],
- },
- }
+ const recsResponse = { data: { recommendations: ['user1', 'user2'] } }
+ mockAxios.get.mockResolvedValueOnce(recsResponse)
+ const recsResult = await settingsService.getUsernameRecommendations()
+ expect(recsResult).toEqual(recsResponse.data)
- mockAxios.get.mockResolvedValueOnce(blockedResponse)
- const blockedResult = await settingsService.getBlocked()
+ mockAxios.get.mockRejectedValueOnce(error401)
+ await expect(settingsService.getUsernameRecommendations()).rejects.toThrow('Invalid or expired token')
- expect(mockAxios.get).toHaveBeenCalledWith('/users/me/blocked', { params: {} })
- expect(blockedResult).toEqual(blockedResponse.data)
+ mockAxios.get.mockRejectedValueOnce(error404)
+ await expect(settingsService.getUsernameRecommendations()).rejects.toThrow('User not found')
+ mockAxios.get.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.getUsernameRecommendations()).rejects.toThrow('Failed to get username recommendations')
+ })
- const recsResponse = {
- data: {
- recommendations: ['user_123', 'user_456', 'user_789'],
- },
- }
+ it('should handle sendEmailOTP with all error scenarios', async () => {
+ const sendResponse = { data: { message: 'OTP sent' } }
+ mockAxios.post.mockResolvedValueOnce(sendResponse)
+ const result = await settingsService.sendEmailOTP('email@test.com')
+ expect(result).toEqual(sendResponse.data)
- mockAxios.get.mockResolvedValueOnce(recsResponse)
- const recsResult = await settingsService.getUsernameRecommendations()
+ const errorBackend = new Error('Custom') as any
+ errorBackend.response = { status: 400, data: { message: 'Email exists' } }
+ mockAxios.post.mockRejectedValueOnce(errorBackend)
+ await expect(settingsService.sendEmailOTP('email@test.com')).rejects.toThrow('Email exists')
- expect(mockAxios.get).toHaveBeenCalledWith('/users/me/username-recommendations')
- expect(recsResult).toEqual(recsResponse.data)
+ const errorField = new Error('Custom') as any
+ errorField.response = { status: 400, data: { error: 'Invalid format' } }
+ mockAxios.post.mockRejectedValueOnce(errorField)
+ await expect(settingsService.sendEmailOTP('email@test.com')).rejects.toThrow('Invalid format')
+
+ const error401 = new Error('Unauthorized') as any
+ error401.response = { status: 401, data: {} }
+ mockAxios.post.mockRejectedValueOnce(error401)
+ await expect(settingsService.sendEmailOTP('email@test.com')).rejects.toThrow('Invalid or expired token')
+
+ const error404 = new Error('Not Found') as any
+ error404.response = { status: 404, data: {} }
+ mockAxios.post.mockRejectedValueOnce(error404)
+ await expect(settingsService.sendEmailOTP('email@test.com')).rejects.toThrow('User not found')
+
+ // 400 error without message
+ const error400 = new Error('Bad Request') as any
+ error400.response = { status: 400, data: {} }
+ mockAxios.post.mockRejectedValueOnce(error400)
+ await expect(settingsService.sendEmailOTP('email@test.com')).rejects.toThrow('Email already exists')
+
+ // 500 error
+ const error500 = new Error('Server Error') as any
+ error500.response = { status: 500, data: {} }
+ mockAxios.post.mockRejectedValueOnce(error500)
+ await expect(settingsService.sendEmailOTP('email@test.com')).rejects.toThrow('Failed to send OTP email')
+
+ mockAxios.post.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.sendEmailOTP('email@test.com')).rejects.toThrow('Failed to send verification code')
+ })
+
+ it('should handle verifyEmailOTP and changeLanguage with all error scenarios', async () => {
+ const verifyResponse = { data: { message: 'Verified' } }
+ mockAxios.post.mockResolvedValueOnce(verifyResponse)
+ const result = await settingsService.verifyEmailOTP('email@test.com', '123456')
+ expect(result).toEqual(verifyResponse.data)
+
+ const errorBackend = new Error('Custom') as any
+ errorBackend.response = { status: 400, data: { message: 'OTP expired' } }
+ mockAxios.post.mockRejectedValueOnce(errorBackend)
+ await expect(settingsService.verifyEmailOTP('email@test.com', '123')).rejects.toThrow('OTP expired')
+
+ // verifyEmailOTP 400 without message
+ const error400 = new Error('Bad Request') as any
+ error400.response = { status: 400, data: {} }
+ mockAxios.post.mockRejectedValueOnce(error400)
+ await expect(settingsService.verifyEmailOTP('email@test.com', '123')).rejects.toThrow('Invalid or expired OTP')
+
+ // 401
+ const error401 = new Error('Unauthorized') as any
+ error401.response = { status: 401, data: {} }
+ mockAxios.post.mockRejectedValueOnce(error401)
+ await expect(settingsService.verifyEmailOTP('email@test.com', '123')).rejects.toThrow('Invalid or expired token')
+
+ const error404 = new Error('Not Found') as any
+ error404.response = { status: 404, data: {} }
+ mockAxios.post.mockRejectedValueOnce(error404)
+ await expect(settingsService.verifyEmailOTP('email@test.com', '123')).rejects.toThrow('User not found')
+
+ mockAxios.post.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.verifyEmailOTP('email@test.com', '123')).rejects.toThrow('Failed to verify email')
+
+ const langResponse = { data: { message: 'Language changed' } }
+ mockAxios.patch.mockResolvedValueOnce(langResponse)
+ const langResult = await settingsService.changeLanguage('ar')
+ expect(langResult).toBe('Language changed')
+ mockAxios.patch.mockRejectedValueOnce(error401)
+ await expect(settingsService.changeLanguage('en')).rejects.toThrow('Invalid or expired token')
+ mockAxios.patch.mockRejectedValueOnce(new Error('Network'))
+ await expect(settingsService.changeLanguage('en')).rejects.toThrow('Something went wrong')
})
})
diff --git a/app/modules/settings/test/unit/usePasswordProtection.spec.ts b/app/modules/settings/test/unit/usePasswordProtection.spec.ts
new file mode 100644
index 00000000..422f4424
--- /dev/null
+++ b/app/modules/settings/test/unit/usePasswordProtection.spec.ts
@@ -0,0 +1,111 @@
+import { describe, it, vi, beforeEach, expect } from 'vitest'
+import { usePasswordProtection } from '~/modules/settings/composables/usePasswordProtection'
+import { usePasswordConfirmationStore } from '~/modules/settings/stores/usePasswordConfirmationStore'
+import { userSettingsQueries } from '~/modules/settings/queries/userSettingsQueries'
+
+vi.mock('~/modules/settings/stores/usePasswordConfirmationStore')
+vi.mock('~/modules/settings/queries/userSettingsQueries')
+
+describe('usePasswordProtection', () => {
+ let mockStore: any
+ let mockMutation: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockMutation = {
+ mutateAsync: vi.fn(),
+ isPending: ref(false),
+ }
+
+ let sessionValid = true
+ mockStore = {
+ checkSession: vi.fn(),
+ confirmPassword: vi.fn(),
+ requireReconfirmation: vi.fn(),
+ get isSessionValid() {
+ return sessionValid
+ },
+ set isSessionValid(value: boolean) {
+ sessionValid = value
+ },
+ }
+
+ vi.mocked(usePasswordConfirmationStore).mockReturnValue(mockStore)
+ vi.mocked(userSettingsQueries).mockReturnValue({
+ useConfirmPassword: mockMutation,
+ } as any)
+ })
+
+ it('should check password confirmation with valid session and return true', () => {
+ mockStore.checkSession.mockReturnValue(true)
+ const { checkPasswordConfirmation, isProtectedContentVisible, showPasswordConfirmation } =
+ usePasswordProtection()
+
+ const result = checkPasswordConfirmation()
+
+ expect(mockStore.checkSession).toHaveBeenCalled()
+ expect(result).toBe(true)
+ expect(isProtectedContentVisible.value).toBe(true)
+ expect(showPasswordConfirmation.value).toBe(false)
+ })
+
+ it('should check password confirmation with invalid session, show confirmation dialog, and return false', () => {
+ mockStore.isSessionValid = false
+ mockStore.checkSession.mockReturnValue(false)
+
+ const { checkPasswordConfirmation, isProtectedContentVisible, showPasswordConfirmation } =
+ usePasswordProtection()
+
+ const result = checkPasswordConfirmation()
+
+ expect(mockStore.checkSession).toHaveBeenCalled()
+ expect(result).toBe(false)
+ expect(isProtectedContentVisible.value).toBe(false)
+ expect(showPasswordConfirmation.value).toBe(true)
+ })
+
+ it('should handle password confirmation success and error scenarios', async () => {
+ mockStore.isSessionValid = false
+ mockStore.checkSession.mockReturnValue(false)
+
+ mockStore.confirmPassword.mockImplementation(() => {
+ mockStore.isSessionValid = true
+ mockStore.checkSession.mockReturnValue(true)
+ })
+
+ mockMutation.mutateAsync.mockResolvedValue({})
+
+ const {
+ handlePasswordConfirmation,
+ isProtectedContentVisible,
+ showPasswordConfirmation,
+ } = usePasswordProtection()
+
+ expect(isProtectedContentVisible.value).toBe(false)
+
+ const result = await handlePasswordConfirmation('correct-password')
+
+ expect(result).toBe(true)
+ expect(mockMutation.mutateAsync).toHaveBeenCalledWith({ password: 'correct-password' })
+ expect(mockStore.confirmPassword).toHaveBeenCalled()
+ expect(isProtectedContentVisible.value).toBe(true)
+ expect(showPasswordConfirmation.value).toBe(false)
+
+ const error = new Error('Invalid password')
+ mockMutation.mutateAsync.mockRejectedValue(error)
+
+ await expect(handlePasswordConfirmation('wrong-password')).rejects.toThrow('Invalid password')
+ expect(mockMutation.mutateAsync).toHaveBeenCalledWith({ password: 'wrong-password' })
+ })
+
+ it('should invalidate session and hide protected content on password change', () => {
+ const { invalidateOnPasswordChange, isProtectedContentVisible } = usePasswordProtection()
+
+ isProtectedContentVisible.value = true
+ invalidateOnPasswordChange()
+
+ expect(mockStore.requireReconfirmation).toHaveBeenCalled()
+ expect(isProtectedContentVisible.value).toBe(false)
+ })
+})
diff --git a/app/modules/settings/test/unit/userSettingsQueries.spec.ts b/app/modules/settings/test/unit/userSettingsQueries.spec.ts
index df232911..31bb6fee 100644
--- a/app/modules/settings/test/unit/userSettingsQueries.spec.ts
+++ b/app/modules/settings/test/unit/userSettingsQueries.spec.ts
@@ -24,6 +24,8 @@ const mockUserStore = {
updateUser: vi.fn(),
}
+const mockLocale = { value: 'en' }
+
vi.mock('nuxt/app', () => ({
useNuxtApp: () => ({
$settingsService: mockSettingsService,
@@ -33,7 +35,7 @@ vi.mock('nuxt/app', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
- locale: { value: 'en' },
+ locale: mockLocale,
}),
}))
@@ -41,6 +43,12 @@ vi.mock('~/modules/auth/stores/userStore', () => ({
useUserStore: () => mockUserStore,
}))
+vi.mock('~/modules/Common/queries/cacheInvalidation', () => ({
+ cacheInvalidation: {
+ onUsernameChange: vi.fn(),
+ },
+}))
+
vi.mock('@tanstack/vue-query', async () => {
const actual = await vi.importActual('@tanstack/vue-query')
return {
@@ -54,82 +62,158 @@ vi.mock('@tanstack/vue-query', async () => {
describe('userSettingsQueries', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockLocale.value = 'en'
+ mockUserStore.user = { username: 'testuser' }
})
- it('should initialize infinite queries with correct configuration', () => {
+ it('should initialize infinite queries with correct configuration and test getNextPageParam', () => {
userSettingsQueries()
expect(useInfiniteQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['muted-users'],
initialPageParam: undefined,
- }),
+ })
)
expect(useInfiniteQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['blocked-users'],
initialPageParam: undefined,
- }),
+ })
)
+
+ const mutedCall = (useInfiniteQuery as any).mock.calls[0][0]
+ expect(mutedCall.getNextPageParam({ data: { pagination: { has_more: true, next_cursor: 'abc' } } })).toBe('abc')
+ expect(mutedCall.getNextPageParam({ data: { pagination: { has_more: false } } })).toBeUndefined()
+
+ const blockedCall = (useInfiniteQuery as any).mock.calls[1][0]
+ expect(blockedCall.getNextPageParam({ data: { pagination: { has_more: true, next_cursor: 'xyz' } } })).toBe('xyz')
+ expect(blockedCall.getNextPageParam({ data: { pagination: { has_more: false } } })).toBeUndefined()
})
- it('should handle muted users pagination with getNextPageParam', async () => {
+ it('should initialize changeLanguage and changePassword mutations with callbacks', () => {
userSettingsQueries()
- const call = (useInfiniteQuery as any).mock.calls[0][0]
- const getNextPageParam = call.getNextPageParam
+ const mutationCalls = (useMutation as any).mock.calls
+ const languageCall = mutationCalls.find((call: any[]) =>
+ call[0].onSuccess?.toString?.().includes('locale') &&
+ call[0].onSuccess?.toString?.().includes('invalidateQueries')
+ )
+ expect(languageCall).toBeDefined()
+ expect(languageCall[0].onSuccess).toBeDefined()
+ expect(languageCall[0].onError).toBeDefined()
+
+ languageCall[0].onSuccess({ message: 'changed' }, { language: 'ar' })
+ expect(mockLocale.value).toBe('ar')
+ expect(languageCall[0].onSuccess.toString()).toContain('invalidateQueries')
- const resultWithMore = {
- data: { pagination: { has_more: true, next_cursor: 'cursor123' } },
- }
- expect(getNextPageParam(resultWithMore)).toBe('cursor123')
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ languageCall[0].onError(new Error('Failed'))
+ expect(consoleError).toHaveBeenCalled()
+ consoleError.mockRestore()
- const resultNoMore = { data: { pagination: { has_more: false } } }
- expect(getNextPageParam(resultNoMore)).toBeUndefined()
+ const passwordCall = mutationCalls.find((call: any[]) =>
+ call[0].onSuccess?.toString?.().includes('Password changed successfully')
+ )
+ expect(passwordCall).toBeDefined()
+ const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
+ passwordCall[0].onSuccess({ message: 'changed' })
+ expect(consoleLog).toHaveBeenCalled()
+ consoleLog.mockRestore()
})
- it('should initialize all mutations with correct configuration', () => {
+ it('should initialize updateUsername mutation with proper onSuccess and onError handling', () => {
userSettingsQueries()
const mutationCalls = (useMutation as any).mock.calls
+ const usernameCall = mutationCalls.find((call: any[]) =>
+ call[0].onSuccess?.toString?.().includes('updateUser')
+ )
+ expect(usernameCall).toBeDefined()
+
+ const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
+ usernameCall[0].onSuccess({ data: { username: 'newname' } })
+ expect(mockUserStore.updateUser).toHaveBeenCalledWith({ username: 'newname' })
+ expect(usernameCall[0].onSuccess.toString()).toContain('onUsernameChange')
+ consoleLog.mockRestore()
+
+ mockUserStore.user = null as any
+ vi.clearAllMocks()
+ usernameCall[0].onSuccess({ data: { username: 'another' } })
+ expect(mockUserStore.updateUser).toHaveBeenCalledWith({ username: 'another' })
+
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ usernameCall[0].onError(new Error('Username is already taken'))
+ expect(consoleError).not.toHaveBeenCalled()
+
+ usernameCall[0].onError(new Error('Invalid username format'))
+ expect(consoleError).not.toHaveBeenCalled()
- const mutationFns = mutationCalls.map((call: any[]) => call[0].mutationFn?.toString?.())
- expect(mutationFns.length).toBeGreaterThanOrEqual(5)
+ usernameCall[0].onError(new Error('Network error'))
+ expect(consoleError).toHaveBeenCalled()
+ consoleError.mockRestore()
})
- it('should initialize useQuery for username recommendations', () => {
+ it('should initialize sendEmailOTP mutation with onSuccess and onError handlers', () => {
userSettingsQueries()
- expect(useQuery).toHaveBeenCalledWith(
- expect.objectContaining({
- queryKey: ['username-recommendation'],
- }),
+ const mutationCalls = (useMutation as any).mock.calls
+ const emailCall = mutationCalls.find((call: any[]) =>
+ call[0].onSuccess?.toString?.().includes('Email OTP sent successfully')
)
+ expect(emailCall).toBeDefined()
+
+ const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
+ emailCall[0].onSuccess({ message: 'OTP sent' })
+ expect(consoleLog).toHaveBeenCalled()
+
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ emailCall[0].onError(new Error('Failed to send'))
+ expect(consoleError).toHaveBeenCalled()
+ consoleError.mockRestore()
+ consoleLog.mockRestore()
})
- it('should execute changeLanguage mutation with proper callbacks', () => {
+ it('should initialize confirmPassword and deleteAccount mutations', () => {
userSettingsQueries()
+ const mutationCalls = (useMutation as any).mock.calls
- const languageMutationCall = (useMutation as any).mock.calls.find(
- (call: any[]) => call[0].mutationFn?.length === 1,
+ const confirmCall = mutationCalls.find((call: any[]) =>
+ call[0].onSuccess?.toString?.().includes('Password confirmed')
+ )
+ expect(confirmCall).toBeDefined()
+ const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
+ confirmCall[0].onSuccess({ confirmed: true })
+ expect(consoleLog).toHaveBeenCalled()
+ consoleLog.mockRestore()
+
+ const deleteCall = mutationCalls.find((call: any[]) =>
+ call[0].onSuccess?.toString?.().includes('Account deleted successfully')
)
+ expect(deleteCall).toBeDefined()
+ expect(deleteCall[0].onError).toBeDefined()
- expect(languageMutationCall).toBeDefined()
- expect(languageMutationCall[0].onSuccess).toBeDefined()
- expect(languageMutationCall[0].onError).toBeDefined()
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ deleteCall[0].onError(new Error('Failed'))
+ expect(consoleError).toHaveBeenCalled()
+ consoleError.mockRestore()
})
- it('should execute deleteAccount mutation with onSuccess callback', () => {
+ it('should initialize verifyEmailOTP mutation and username recommendation query', () => {
userSettingsQueries()
- const deleteAccountCall = (useMutation as any).mock.calls.find(
- (call: any[]) => call[0].mutationFn?.length === 0,
- )
+ const mutationCalls = (useMutation as any).mock.calls
- expect(deleteAccountCall).toBeDefined()
- expect(deleteAccountCall[0].onSuccess).toBeDefined()
- expect(deleteAccountCall[0].onError).toBeDefined()
+ const verifyCall = mutationCalls.find((call: any[]) =>
+ !call[0].onSuccess && !call[0].onError
+ )
+ expect(verifyCall).toBeDefined()
+ expect(useQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ['username-recommendation'],
+ })
+ )
})
})
diff --git a/app/modules/tweets/components/EditTweetModal/EditTweetModal.vue b/app/modules/tweets/components/EditTweetModal/EditTweetModal.vue
index 035dc6fd..bfcec31c 100644
--- a/app/modules/tweets/components/EditTweetModal/EditTweetModal.vue
+++ b/app/modules/tweets/components/EditTweetModal/EditTweetModal.vue
@@ -42,6 +42,7 @@
v-model="editedContent"
:placeholder="$t('timeline.postTweet.placeholder')"
:inlineborder="false"
+ :mentions="mentions"
/>
@@ -65,6 +66,7 @@ const props = defineProps<{
tweetId: string
initialContent: string
isLoading?: boolean
+ mentions?: string[]
}>()
const emit = defineEmits<{
@@ -101,13 +103,21 @@ const handleSave = () => {
emit('save', editedContent.value)
}
}
-
+const paresMentions = (content: string) => {
+ if (props.mentions && props.mentions.length > 0) {
+ const mentionRegex = /\$\((\d+)\)/gu
+ return content.replace(mentionRegex, (match, p1) => {
+ const index = parseInt(p1, 10)
+ return props.mentions && props.mentions[index] ? '@' + props.mentions[index] : match
+ })
+ } else return content
+}
// Initialize content when modal opens
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
- editedContent.value = props.initialContent
+ editedContent.value = paresMentions(props.initialContent)
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
diff --git a/app/modules/tweets/components/Tweet/Tweet.vue b/app/modules/tweets/components/Tweet/Tweet.vue
index 6c64ac75..c780df0b 100644
--- a/app/modules/tweets/components/Tweet/Tweet.vue
+++ b/app/modules/tweets/components/Tweet/Tweet.vue
@@ -307,6 +307,7 @@
:tweet-id="tweet.tweet_id"
:initial-content="tweet.content"
:is-loading="isUpdateLoading"
+ :mentions="props.tweet.mentions"
@close="handleCloseEditModal"
@save="handleSaveEdit"
/>
diff --git a/app/modules/tweets/components/TweetDetails/Reply/Reply.vue b/app/modules/tweets/components/TweetDetails/Reply/Reply.vue
index 3e0b1e2e..d6746443 100644
--- a/app/modules/tweets/components/TweetDetails/Reply/Reply.vue
+++ b/app/modules/tweets/components/TweetDetails/Reply/Reply.vue
@@ -94,6 +94,7 @@
:tweet-id="reply.tweet_id"
:initial-content="reply.content"
:is-loading="isUpdateLoading"
+ :mentions="reply.mentions"
@close="handleCloseEditModal"
@save="handleSaveEdit"
/>
@@ -191,6 +192,7 @@ const content = computed(() => ({
images: props.reply.images || [],
videos: props.reply.videos || [],
gifs: props.reply.gifs || [],
+ mentions: props.reply.mentions,
}))
// Transform user with avatar fallback
diff --git a/app/modules/tweets/components/TweetDetails/TweetDetails.vue b/app/modules/tweets/components/TweetDetails/TweetDetails.vue
index 5014405b..b0b0a2c8 100644
--- a/app/modules/tweets/components/TweetDetails/TweetDetails.vue
+++ b/app/modules/tweets/components/TweetDetails/TweetDetails.vue
@@ -102,6 +102,7 @@
:tweet-id="tweetDetails.tweet_id"
:initial-content="tweetDetails.content"
:is-loading="isUpdateLoading"
+ :mentions="tweetDetails.mentions"
@close="handleCloseEditModal"
@save="handleSaveEdit"
/>
diff --git a/app/plugins/axios.ts b/app/plugins/axios.ts
index f920c575..7e7ced04 100644
--- a/app/plugins/axios.ts
+++ b/app/plugins/axios.ts
@@ -40,7 +40,11 @@ export default defineNuxtPlugin(() => {
async (error) => {
const requestUrl = error.config?.url || ''
const isAuthEndpoint = requestUrl.includes('/auth/')
-
+ if(requestUrl == '/auth/refresh') {
+ userStore.logout() // logout handles both store and cookie
+ navigateTo('/auth/login')
+ return Promise.reject(error)
+ }
if (error.response?.status === 401 && isAuthEndpoint) {
if (process.client) {
userStore.logout() // logout handles both store and cookie
@@ -64,18 +68,14 @@ export default defineNuxtPlugin(() => {
const authService = nuxtApp.$authService
const response = await authService.GetAccessToken()
const access_token = response.data.access_token
- // Update cookie (will sync to store via watch)
const token = useCookie('access_token')
token.value = access_token
- // Also update store directly for immediate availability
userStore.setAccessToken(access_token)
- // Retry the original request with the new token
const originalRequest = error.config
originalRequest.headers['Authorization'] = `Bearer ${access_token}`
return yapperApi(originalRequest)
} catch (refreshError) {
- // Refresh failed, clear access token and redirect
- userStore.logout() // logout handles both store and cookie
+ userStore.logout()
navigateTo('/auth/login')
return Promise.reject(refreshError)
}