From 5bba63089eed9a2b133958b7e2c8bb86a55c90e4 Mon Sep 17 00:00:00 2001
From: hagar3bdelsalam <149162265+hagar3bdelsalam@users.noreply.github.com>
Date: Mon, 15 Dec 2025 21:13:12 +0200
Subject: [PATCH 1/7] test(notifications): add some tests
---
.../test/unit/NotificationItem.spec.ts | 204 +++++++--
.../unit/NotificationsSocketService.spec.ts | 329 +++++++++++++--
.../notificationsSocketEventsTypes.spec.ts | 387 ++++++++++++++++++
.../test/unit/notificationsTypes.spec.ts | 362 ++++++++++++++++
.../test/unit/useGetMentionsQuery.spec.ts | 29 ++
.../unit/useGetNotificationsQuery.spec.ts | 26 +-
6 files changed, 1253 insertions(+), 84 deletions(-)
create mode 100644 app/modules/notifications/test/unit/notificationsSocketEventsTypes.spec.ts
create mode 100644 app/modules/notifications/test/unit/notificationsTypes.spec.ts
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)
})
})
From 6afbaa4c3162ec60fa2f54598109da96a06108d1 Mon Sep 17 00:00:00 2001
From: hagar3bdelsalam <149162265+hagar3bdelsalam@users.noreply.github.com>
Date: Mon, 15 Dec 2025 22:25:46 +0200
Subject: [PATCH 2/7] test(settings): improve tests
---
.../settings/test/unit/MuteAndBlock.spec.ts | 77 +++-
.../test/unit/settingsService.spec.ts | 371 +++++++++---------
.../test/unit/usePasswordProtection.spec.ts | 111 ++++++
.../test/unit/userSettingsQueries.spec.ts | 152 +++++--
4 files changed, 479 insertions(+), 232 deletions(-)
create mode 100644 app/modules/settings/test/unit/usePasswordProtection.spec.ts
diff --git a/app/modules/settings/test/unit/MuteAndBlock.spec.ts b/app/modules/settings/test/unit/MuteAndBlock.spec.ts
index 9d448ef3..35e8a9ce 100644
--- a/app/modules/settings/test/unit/MuteAndBlock.spec.ts
+++ b/app/modules/settings/test/unit/MuteAndBlock.spec.ts
@@ -1,21 +1,27 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, beforeEach } from 'vitest'
import { mount } 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
+ let mockT: any
- const factory = () =>
- mount(MuteAndBlock, {
+ const factory = (tMock?: any) => {
+ mockT = tMock || ((key: string) => key)
+ return mount(MuteAndBlock, {
global: {
mocks: {
- t: (key: string) => key,
+ t: mockT,
},
stubs: {
DetailedPanel: DetailedPanelStub,
@@ -23,18 +29,71 @@ describe('MuteAndBlock.vue', () => {
},
},
})
+ }
- it('renders DetailedPanel with description', () => {
+ beforeEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ it('renders DetailedPanel with correct title and description', () => {
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 categories', () => {
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')
+ 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',
+ })
+ })
+
+ it('categories computed property returns correct structure with labels and hrefs', () => {
+ wrapper = factory()
+
+ const rows = wrapper.findAllComponents(DetailedRowStub)
+
+ 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 for 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('provides fallback empty objects for categories when accessed by index', () => {
+ wrapper = factory()
+
+ const rows = wrapper.findAllComponents(DetailedRowStub)
+ expect(rows).toHaveLength(2)
+
+ rows.forEach((row) => {
+ const category = row.props('category')
+ expect(category).toBeDefined()
+ expect(category).toHaveProperty('label')
+ expect(category).toHaveProperty('href')
+ })
})
})
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'],
+ })
+ )
})
})
From cfbc859d8d8105ff89b0181201f8134f4e49cfed Mon Sep 17 00:00:00 2001
From: Abdelrahman Medhat Saber
<136710727+bedosaber77@users.noreply.github.com>
Date: Mon, 15 Dec 2025 22:47:53 +0200
Subject: [PATCH 3/7] feat: add mentions support to tweet editing and replies
---
.../components/EditTweetModal/EditTweetModal.vue | 14 ++++++++++++--
app/modules/tweets/components/Tweet/Tweet.vue | 1 +
.../tweets/components/TweetDetails/Reply/Reply.vue | 2 ++
.../components/TweetDetails/TweetDetails.vue | 1 +
4 files changed, 16 insertions(+), 2 deletions(-)
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"
/>
From ff1a03b7ac27ca9c5164f1fcf23c63a783219dec Mon Sep 17 00:00:00 2001
From: hagar3bdelsalam <149162265+hagar3bdelsalam@users.noreply.github.com>
Date: Mon, 15 Dec 2025 23:06:44 +0200
Subject: [PATCH 4/7] test: add unit tests for various settings components
---
.../test/unit/AccountInformations.spec.ts | 125 +++++++++---------
.../test/unit/BlockedAccounts.spec.ts | 25 +++-
.../test/unit/ChangeEmailForm.spec.ts | 114 ++++++++++++----
.../settings/test/unit/ChangeUsername.spec.ts | 102 ++++++++++----
.../settings/test/unit/MuteAndBlock.spec.ts | 51 +++----
.../test/unit/UserListSettings.spec.ts | 45 ++++---
6 files changed, 306 insertions(+), 156 deletions(-)
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 35e8a9ce..4fc21095 100644
--- a/app/modules/settings/test/unit/MuteAndBlock.spec.ts
+++ b/app/modules/settings/test/unit/MuteAndBlock.spec.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect, beforeEach } 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 = {
@@ -8,20 +8,18 @@ const DetailedPanelStub = {
}
const DetailedRowStub = {
- template: '{{ category.label }} - {{ category.href }}
',
+ template: '{{ category?.label }} - {{ category?.href }}
',
props: ['category'],
}
describe('MuteAndBlock.vue', () => {
let wrapper: ReturnType
- let mockT: any
const factory = (tMock?: any) => {
- mockT = tMock || ((key: string) => key)
return mount(MuteAndBlock, {
global: {
mocks: {
- t: mockT,
+ t: tMock || ((key: string) => key),
},
stubs: {
DetailedPanel: DetailedPanelStub,
@@ -37,7 +35,7 @@ describe('MuteAndBlock.vue', () => {
}
})
- it('renders DetailedPanel with correct title and description', () => {
+ it('renders DetailedPanel with correct title and description text', () => {
wrapper = factory()
expect(wrapper.find('.detailed-panel').exists()).toBe(true)
@@ -46,44 +44,38 @@ describe('MuteAndBlock.vue', () => {
expect(wrapper.find('p.text-muted').text()).toBe('settings.muteAndBlock_desc')
})
- it('renders two DetailedRow components with correct categories', () => {
+ 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({
+
+ expect(rows[0]!.props('category')).toEqual({
label: 'settings.blockedAccounts',
href: '/settings/blocked/all',
})
- expect(rows[1].props('category')).toEqual({
+ expect(rows[1]!.props('category')).toEqual({
label: 'settings.mutedAccounts',
href: '/settings/muted/all',
})
- })
- it('categories computed property returns correct structure with labels and hrefs', () => {
- wrapper = factory()
-
- const rows = wrapper.findAllComponents(DetailedRowStub)
-
- 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')
+ 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 for all text content', () => {
+ 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('provides fallback empty objects for categories when accessed by index', () => {
+ it('provides valid category objects with required properties', () => {
wrapper = factory()
const rows = wrapper.findAllComponents(DetailedRowStub)
@@ -94,6 +86,19 @@ describe('MuteAndBlock.vue', () => {
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()
})
})
From 251722cf50cbc2509805a651d52ea4dd90bc40cd Mon Sep 17 00:00:00 2001
From: Safan
Date: Mon, 15 Dec 2025 21:18:43 +0200
Subject: [PATCH 5/7] fix: whoToFollow follow action
---
.../TimeLine/test/unit/useUploadMedia.spec.ts | 156 ++++++++++++++++++
.../auth/components/CompleteAccount.vue | 2 +-
.../CompleteAccountComponents/Interests.vue | 14 +-
.../CompleteAccountComponents/WhoToFollow.vue | 80 +++------
4 files changed, 185 insertions(+), 67 deletions(-)
create mode 100644 app/modules/TimeLine/test/unit/useUploadMedia.spec.ts
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') }}
-
+
-