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: - '
{{ title }}
', - }, - DetailedRow: { - props: ['category'], - template: ` -
- -
- `, - }, - ChevronRight: true, + const createWrapper = () => mount(AccountInformation, { + global: { + stubs: { + DetailedPanel: { + props: ['title'], + template: '
{{ title }}
', }, - 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: '`, + 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') }}

-
+

{{ errorMessage }} -

+

-
+
- -
+ +
+

{{ selectedInterests.length }} {{ $t('auth.interests.selected')}} @@ -68,6 +69,7 @@ {{ $t('auth.common.next') }}

+
diff --git a/app/modules/auth/components/subComponents/CompleteAccountComponents/WhoToFollow.vue b/app/modules/auth/components/subComponents/CompleteAccountComponents/WhoToFollow.vue index d353942c..8e4f53e6 100644 --- a/app/modules/auth/components/subComponents/CompleteAccountComponents/WhoToFollow.vue +++ b/app/modules/auth/components/subComponents/CompleteAccountComponents/WhoToFollow.vue @@ -33,45 +33,12 @@

{{ $t('common.error') }}

- -
+ - - - - -
-
- {{ user.name }} - - - -
-

@{{ user.username }}

-

{{ user.bio }}

-
- - - -
+ :users="suggestedUsers" + :hide-bio="false" + />
@@ -91,52 +58,45 @@ From ad27d55e7771a0524f62333d1a944232f939f98f Mon Sep 17 00:00:00 2001 From: Safan Date: Mon, 15 Dec 2025 21:24:16 +0200 Subject: [PATCH 6/7] test: testing the whoToFollow edits --- .../auth/test/unit/completeAcc.spec.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/app/modules/auth/test/unit/completeAcc.spec.ts b/app/modules/auth/test/unit/completeAcc.spec.ts index d960892a..dd41e69a 100644 --- a/app/modules/auth/test/unit/completeAcc.spec.ts +++ b/app/modules/auth/test/unit/completeAcc.spec.ts @@ -39,6 +39,11 @@ const mockUserStore = { setAuth: vi.fn(), setUser: vi.fn(), user: null, + getUser: vi.fn(() => ({ + user_id: 'test-user-id', + username: 'testuser', + name: 'Test User', + })), } vi.mock('~/modules/auth/stores/userStore', () => ({ useUserStore: () => mockUserStore, @@ -160,6 +165,13 @@ vi.mock('#app', () => ({ $authService: { getUserData: vi.fn(() => Promise.resolve({ id: 1, name: 'Test User' })), }, + $queryClient: { + invalidateQueries: vi.fn(), + }, + $userInfoService: { + followUser: vi.fn(() => Promise.resolve({ success: true })), + unfollowUser: vi.fn(() => Promise.resolve({ success: true })), + }, runWithContext: (fn: any) => fn(), callHook: vi.fn(), }), @@ -172,6 +184,66 @@ vi.mock('#app', () => ({ useCookie: () => mockCookie, })) +// Mock profile composables for ProfileFollowAction +vi.mock('~/modules/profile/composables/useFollow', () => ({ + useFollow: () => ({ + buttonClass: 'test-class', + buttonText: ref('Follow'), + handleMouseOver: vi.fn(), + handleMouseOut: vi.fn(), + }), +})) + +vi.mock('~/modules/profile/composables/useUserInfo', () => ({ + useUserInfo: () => ({ + isBlocked: ref(false), + isFollowing: ref(false), + id: ref('test-id'), + username: ref('testuser'), + }), +})) + +vi.mock('~/modules/profile/composables/useUserInteractions', () => ({ + useUserInteractions: () => ({ + handleFollowAction: vi.fn(), + handleUnfollowWithConfirmation: vi.fn(), + isFollowLoading: ref(false), + }), +})) + +vi.mock('~/modules/profile/composables/useSnackbar', () => ({ + useSnackbar: () => ({ + showSnackbar: ref(false), + snackbar: ref({}), + handleShowSnackbar: vi.fn(), + }), +})) + +vi.mock('~/modules/profile/composables/useConfirmation', () => ({ + useConfirmation: () => ({ + showConfirmation: ref(false), + confirmData: ref({}), + handleShowConfirmation: vi.fn(), + }), +})) + +// Mock WhoToFollowList to avoid ProfileFollowAction complexity +vi.mock('~/modules/explore/components/common/WhoToFollowList.vue', () => ({ + default: { + name: 'WhoToFollowList', + props: ['users', 'hideBio'], + template: ` +
+
+ {{ user.name }} + @{{ user.username }} + +
+
+ `, + }, +})) + function mountCompleteAccount(props = {}) { const defaultProps = { Recommendations: ['user123', 'user456', 'user789'], From 3afb9e5b7015f701f71d86fb8db3b9e0ee918403 Mon Sep 17 00:00:00 2001 From: Safan Date: Mon, 15 Dec 2025 22:51:41 +0200 Subject: [PATCH 7/7] fix: intereceptor and common unit Test --- app/modules/Common/services/listService.ts | 1 + .../Common/test/unit/MediaGrid.spec.ts | 337 ++++++++++++++ app/modules/Common/test/unit/Popup.spec.ts | 333 ++++++++++++++ app/modules/Common/test/unit/Tabs.spec.ts | 191 ++++++++ app/modules/Common/test/unit/UserCard.spec.ts | 418 ++++++++++++++++++ .../test/unit/cacheInvalidation.spec.ts | 418 ++++++++++++++++++ .../Common/test/unit/constants.spec.ts | 78 ++++ .../Common/test/unit/listService.spec.ts | 247 +++++++++++ .../Common/test/unit/mediaService.spec.ts | 181 ++++++++ .../Common/test/unit/queryKeys.spec.ts | 158 +++++++ app/plugins/axios.ts | 12 +- 11 files changed, 2368 insertions(+), 6 deletions(-) create mode 100644 app/modules/Common/test/unit/MediaGrid.spec.ts create mode 100644 app/modules/Common/test/unit/Popup.spec.ts create mode 100644 app/modules/Common/test/unit/Tabs.spec.ts create mode 100644 app/modules/Common/test/unit/UserCard.spec.ts create mode 100644 app/modules/Common/test/unit/cacheInvalidation.spec.ts create mode 100644 app/modules/Common/test/unit/constants.spec.ts create mode 100644 app/modules/Common/test/unit/listService.spec.ts create mode 100644 app/modules/Common/test/unit/mediaService.spec.ts create mode 100644 app/modules/Common/test/unit/queryKeys.spec.ts diff --git a/app/modules/Common/services/listService.ts b/app/modules/Common/services/listService.ts index ef24e17a..c12f2fb3 100644 --- a/app/modules/Common/services/listService.ts +++ b/app/modules/Common/services/listService.ts @@ -1,3 +1,4 @@ +import { useNuxtApp } from '#app' export const listService = { async fetchList(path: string, nextCursor: string): Promise { diff --git a/app/modules/Common/test/unit/MediaGrid.spec.ts b/app/modules/Common/test/unit/MediaGrid.spec.ts new file mode 100644 index 00000000..a5a8e046 --- /dev/null +++ b/app/modules/Common/test/unit/MediaGrid.spec.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' +import MediaGrid from '../../components/MediaGrid/MediaGrid.vue' + +const i18n = createI18n({ + locale: 'en', + messages: { + en: { + tweets: { + loading: { tweets: 'Loading tweets...' }, + errors: { loadFailed: 'Failed to load', tryAgain: 'Try again' }, + empty: { noTweets: 'No tweets', noTweetsDescription: 'No tweets to display' }, + }, + }, + ar: {}, + }, +}) + +// Mock useGenericInfiniteQuery composable +vi.mock('~/modules/Common/composables/useGenericInfiniteQuery', () => ({ + useGenericInfiniteQuery: vi.fn(() => ({ + items: ref([]), + isFetching: ref(false), + isFetchingNextPage: ref(false), + isPending: ref(false), + error: ref(null), + loadMoreTrigger: ref(null), + refetch: vi.fn(), + })), +})) + +// Mock InfiniteList component +vi.mock('~/modules/Common/components/InfiniteList', () => ({ + InfiniteList: { + name: 'InfiniteList', + props: ['modelValue', 'items', 'isPending', 'isFetching', 'isFetchingNextPage', 'error'], + emits: ['update:modelValue', 'retry'], + template: '
', + }, +})) + +vi.mock('#app', () => ({ + useNuxtApp: () => ({ + $listService: { + fetchList: vi.fn(), + }, + }), +})) + +describe('MediaGrid Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render media grid container', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.find('.infinite-list').exists()).toBe(true) + }) + + it('should handle fetchingSource prop', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'profile', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.props('fetchingSource')).toBe('profile') + }) + + it('should initialize useGenericInfiniteQuery hook', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should have videoDurations reactive object', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should render InfiniteList component', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + const infiniteList = wrapper.findComponent({ name: 'InfiniteList' }) + expect(infiniteList.exists()).toBe(true) + }) + + it('should pass fetchingSource to query', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'timeline', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.props('fetchingSource')).toBe('timeline') + }) + + it('should handle null fetchingSource', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.props('fetchingSource')).toBeNull() + }) + + it('should have formatDuration method', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should handle handleVideoMetadata callback', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should support prop updates', async () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'profile', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + await wrapper.setProps({ fetchingSource: 'search' }) + expect(wrapper.props('fetchingSource')).toBe('search') + }) + + it('should maintain reactive state', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.find('.infinite-list').exists()).toBe(true) + }) + + it('should render without errors', () => { + expect(() => { + mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + }).not.toThrow() + }) + + it('should support slot rendering', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.find('.infinite-list').exists()).toBe(true) + }) + + it('should handle component lifecycle', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + wrapper.unmount() + }) + + it('should use correct grid styling', () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: null, + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + const infiniteList = wrapper.findComponent({ name: 'InfiniteList' }) + expect(infiniteList.exists()).toBe(true) + }) + + it('should handle reactive fetchingSource changes', async () => { + const wrapper = mount(MediaGrid, { + props: { + fetchingSource: 'profile', + }, + global: { + plugins: [i18n], + stubs: { + NuxtImg: true, + NuxtLink: true, + }, + }, + }) + + const before = wrapper.props('fetchingSource') + await wrapper.setProps({ fetchingSource: 'explore' }) + const after = wrapper.props('fetchingSource') + + expect(before).not.toBe(after) + expect(after).toBe('explore') + }) +}) diff --git a/app/modules/Common/test/unit/Popup.spec.ts b/app/modules/Common/test/unit/Popup.spec.ts new file mode 100644 index 00000000..5f8324d2 --- /dev/null +++ b/app/modules/Common/test/unit/Popup.spec.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import Popup from '../../components/Popup/Popup.vue' + +const i18n = createI18n({ + locale: 'en', + messages: { + en: {}, + ar: {}, + }, +}) + +// Mock Teleport component +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + Teleport: { + name: 'Teleport', + props: ['to'], + template: '', + }, + } +}) + +describe('Popup Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render when isOpen is true', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + title: 'Test Title', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + slots: { + default: 'Popup Content', + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should not render when isOpen is false', () => { + const wrapper = mount(Popup, { + props: { + isOpen: false, + title: 'Test Title', + }, + global: { + plugins: [i18n], + }, + }) + + expect(wrapper.html()).not.toContain('popup-content') + }) + + it('should display title when provided', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + title: 'My Title', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const text = wrapper.text() + expect(text).toContain('My Title') + }) + + it('should show close button when hasCloseButton is true', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasCloseButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#close-popup-btn').exists()).toBe(true) + }) + + it('should show back button when hasBackButton is true', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasBackButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#back-popup-btn').exists()).toBe(true) + }) + + it('should emit close event when close button is clicked', async () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasCloseButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const closeBtn = wrapper.find('#close-popup-btn') + if (closeBtn.exists()) { + await closeBtn.trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + } + }) + + it('should emit back event when back button is clicked', async () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasBackButton: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const backBtn = wrapper.find('#back-popup-btn') + if (backBtn.exists()) { + await backBtn.trigger('click') + expect(wrapper.emitted('back')).toBeTruthy() + } + }) + + it('should support different positioning classes', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + xPosition: 'start', + yPosition: 'end', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should accept custom contentClass', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + contentClass: 'custom-content-class', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const content = wrapper.find('#popup-content') + if (content.exists()) { + expect(content.classes()).toContain('custom-content-class') + } + }) + + it('should apply header class when provided', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + headerClass: 'custom-header-class', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + const html = wrapper.html() + expect(html).toContain('custom-header-class') + }) + + it('should render slot content', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + slots: { + default: 'Test Slot Content', + }, + }) + + expect(wrapper.text()).toContain('Test Slot Content') + }) + + it('should support custom background color', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + bgColor: 'bg-custom-color', + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should render with default props', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.vm).toBeDefined() + }) + + it('should handle hasCloseButton default value', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.props('hasCloseButton')).toBe(true) + }) + + it('should handle hasBackButton default value', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.props('hasBackButton')).toBe(false) + }) + + it('should hide close button when hasCloseButton is false', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasCloseButton: false, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#close-popup-btn').exists()).toBe(false) + }) + + it('should hide back button when hasBackButton is false', () => { + const wrapper = mount(Popup, { + props: { + isOpen: true, + hasBackButton: false, + }, + global: { + plugins: [i18n], + stubs: { + Teleport: false, + }, + }, + }) + + expect(wrapper.find('#back-popup-btn').exists()).toBe(false) + }) +}) diff --git a/app/modules/Common/test/unit/Tabs.spec.ts b/app/modules/Common/test/unit/Tabs.spec.ts new file mode 100644 index 00000000..3afc6033 --- /dev/null +++ b/app/modules/Common/test/unit/Tabs.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' +import Tabs from '../../components/Tabs/Tabs.vue' + +describe('Tabs Component', () => { + const defaultTabs = [ + { label: 'Tab 1', value: 'tab1', test_id: 'tab-1' }, + { label: 'Tab 2', value: 'tab2', test_id: 'tab-2' }, + { label: 'Tab 3', value: 'tab3', test_id: 'tab-3' }, + ] + + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render all tabs', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(wrapper.text()).toContain('Tab 1') + expect(wrapper.text()).toContain('Tab 2') + expect(wrapper.text()).toContain('Tab 3') + }) + + it('should highlight active tab', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const activeButton = wrapper.find('[class*="text-primary"]') + expect(activeButton.exists()).toBe(true) + }) + + it('should call onChange when tab is clicked', async () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const tab2 = wrapper.find('#tab-2') + await tab2.trigger('click') + + expect(mockOnChange).toHaveBeenCalledWith('tab2') + }) + + it('should render correct number of tabs', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const tabs = wrapper.findAll('li') + expect(tabs.length).toBe(3) + }) + + it('should handle single tab', () => { + const singleTab = [{ label: 'Only Tab', value: 'only', test_id: 'only-tab' }] + + const wrapper = mount(Tabs, { + props: { + tabs: singleTab, + activeTab: 'only', + onChange: mockOnChange, + }, + }) + + expect(wrapper.text()).toContain('Only Tab') + const tabs = wrapper.findAll('li') + expect(tabs.length).toBe(1) + }) + + it('should display underline on active tab', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab2', + onChange: mockOnChange, + }, + }) + + const activeTab = wrapper.find('#tab-2 button span') + expect(activeTab.exists()).toBe(true) + }) + + it('should update active tab when prop changes', async () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + await wrapper.setProps({ activeTab: 'tab3' }) + + expect(wrapper.emitted()).toBeDefined() + }) + + it('should handle tabs with special characters in labels', () => { + const specialTabs = [ + { label: 'Tab & Test', value: 'tab1', test_id: 'tab-1' }, + { label: 'Tab ', value: 'tab2', test_id: 'tab-2' }, + ] + + const wrapper = mount(Tabs, { + props: { + tabs: specialTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(wrapper.text()).toContain('Tab & Test') + expect(wrapper.text()).toContain('Tab ') + }) + + it('should have correct test_id attributes', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(wrapper.find('#tab-1').exists()).toBe(true) + expect(wrapper.find('#tab-2').exists()).toBe(true) + expect(wrapper.find('#tab-3').exists()).toBe(true) + }) + + it('should not call onChange on initial render', () => { + mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should handle rapid tab switching', async () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + await wrapper.find('#tab-2').trigger('click') + await wrapper.find('#tab-3').trigger('click') + await wrapper.find('#tab-1').trigger('click') + + expect(mockOnChange).toHaveBeenCalledTimes(3) + }) + + it('should maintain tab order', () => { + const wrapper = mount(Tabs, { + props: { + tabs: defaultTabs, + activeTab: 'tab1', + onChange: mockOnChange, + }, + }) + + const labels = wrapper.findAll('button').map(btn => btn.text()) + expect(labels[0]).toContain('Tab 1') + expect(labels[1]).toContain('Tab 2') + expect(labels[2]).toContain('Tab 3') + }) +}) diff --git a/app/modules/Common/test/unit/UserCard.spec.ts b/app/modules/Common/test/unit/UserCard.spec.ts new file mode 100644 index 00000000..f1dbd588 --- /dev/null +++ b/app/modules/Common/test/unit/UserCard.spec.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import UserCard from '../../components/UserCard/UserCard.vue' +import type { FollowUser } from '~/modules/profile/types/user' + +vi.mock('~/modules/profile/components/ProfileHeader/SubComponents/ProfileFollowAction.vue', () => ({ + default: { + name: 'ProfileFollowAction', + template: '', + props: ['userId', 'username'], + }, +})) + +vi.mock('../../components/UserImage/UserImage.vue', () => ({ + default: { + name: 'UserImage', + template: '
Avatar
', + props: ['imageUrl', 'name', 'compact'], + }, +})) + +describe('UserCard Component', () => { + const mockUser: FollowUser = { + user_id: '1', + id: '1', + name: 'John Doe', + username: 'johndoe', + bio: 'Software developer', + avatar_url: 'https://example.com/avatar.jpg', + is_following: false, + is_follower: false, + is_muted: false, + is_blocked: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render user card', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John Doe') + }) + + it('should display user name', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John Doe') + }) + + it('should display user username', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('@johndoe') + }) + + it('should display user bio when hideBio is false', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Software developer') + }) + + it('should hide bio when hideBio is true', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + hideBio: true, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).not.toContain('Software developer') + }) + + it('should show "Follows you" badge when is_follower is true', () => { + const followerUser = { ...mockUser, is_follower: true } + + const wrapper = mount(UserCard, { + props: { + user: followerUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Follows you') + }) + + it('should not show "Follows you" badge when is_follower is false', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).not.toContain('Follows you') + }) + + it('should render follow action button', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + }) + + it('should link to user profile', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + const link = wrapper.find('a') + expect(link.attributes('to')).toBe('/johndoe') + }) + + it('should render user image component', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.user-image').exists()).toBe(true) + }) + + it('should handle user without bio', () => { + const userNoBio = { ...mockUser, bio: '' } + + const wrapper = mount(UserCard, { + props: { + user: userNoBio, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John Doe') + expect(wrapper.text()).toContain('@johndoe') + }) + + it('should handle special characters in user data', () => { + const specialUser: FollowUser = { + ...mockUser, + name: 'John & Jane', + username: 'john_jane_123', + bio: 'Developer @ Company', + } + + const wrapper = mount(UserCard, { + props: { + user: specialUser, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('John & Jane') + expect(wrapper.text()).toContain('@john_jane_123') + }) + + it('should handle user with multiple follower states', async () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + await wrapper.setProps({ + user: { ...mockUser, is_follower: true }, + }) + + expect(wrapper.text()).toContain('Follows you') + }) + + it('should render truncated text for long usernames', () => { + const longUsernameUser: FollowUser = { + ...mockUser, + username: 'very_long_username_that_should_be_truncated', + } + + const wrapper = mount(UserCard, { + props: { + user: longUsernameUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.text()).toContain('very_long_username_that_should_be_truncated') + }) + + it('should apply hover effects', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + const container = wrapper.find('[class*="hover:"]') + expect(container.exists()).toBe(true) + }) + + it('should render with correct component structure', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + expect(wrapper.find('.user-image').exists()).toBe(true) + }) + + it('should pass correct props to UserImage', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.user-image').exists()).toBe(true) + }) + + it('should pass correct props to ProfileFollowAction', () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + }) + + it('should handle bio line clamping', () => { + const userLongBio = { + ...mockUser, + bio: 'This is a very long bio that should be clamped to show only a limited number of lines and then truncated with ellipsis to indicate there is more content', + } + + const wrapper = mount(UserCard, { + props: { + user: userLongBio, + hideBio: false, + }, + global: { + stubs: { + NuxtLink: { + template: '', + }, + }, + }, + }) + + expect(wrapper.find('[class*="line-clamp"]').exists()).toBe(true) + }) + + it('should be fully interactive', async () => { + const wrapper = mount(UserCard, { + props: { + user: mockUser, + }, + global: { + stubs: { + NuxtLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + expect(wrapper.find('a').attributes('to')).toBe('/johndoe') + expect(wrapper.find('.follow-action-btn').exists()).toBe(true) + }) +}) diff --git a/app/modules/Common/test/unit/cacheInvalidation.spec.ts b/app/modules/Common/test/unit/cacheInvalidation.spec.ts new file mode 100644 index 00000000..ed4bcbbc --- /dev/null +++ b/app/modules/Common/test/unit/cacheInvalidation.spec.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { cacheInvalidation } from '../../queries/cacheInvalidation' +import { queryKeys } from '../../queries/queryKeys' + +describe('cacheInvalidation', () => { + let mockQueryClient: any + + beforeEach(() => { + mockQueryClient = { + setQueryData: vi.fn(), + invalidateQueries: vi.fn(), + removeQueries: vi.fn(), + setQueriesData: vi.fn(), + clear: vi.fn(), + } + vi.clearAllMocks() + }) + + describe('toggleBlockedInCache', () => { + it('should update blocked status in cache', () => { + const userId = 'user-123' + const isBlocked = true + + cacheInvalidation.toggleBlockedInCache(mockQueryClient, userId, isBlocked) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledWith( + queryKeys.settings.blockedUsers(), + expect.any(Function), + ) + }) + + it('should not modify cache if data is undefined', () => { + const userId = 'user-123' + const updater = mockQueryClient.setQueryData.mock.calls[0]?.[1] + + cacheInvalidation.toggleBlockedInCache(mockQueryClient, userId, true) + + expect(mockQueryClient.setQueryData).toHaveBeenCalled() + }) + + it('should handle toggle from blocked to unblocked', () => { + cacheInvalidation.toggleBlockedInCache(mockQueryClient, 'user-123', false) + cacheInvalidation.toggleBlockedInCache(mockQueryClient, 'user-123', true) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledTimes(2) + }) + }) + + describe('toggleMutedInCache', () => { + it('should update muted status in cache', () => { + const userId = 'user-456' + const isMuted = true + + cacheInvalidation.toggleMutedInCache(mockQueryClient, userId, isMuted) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledWith( + queryKeys.settings.mutedUsers(), + expect.any(Function), + ) + }) + + it('should handle toggle from muted to unmuted', () => { + cacheInvalidation.toggleMutedInCache(mockQueryClient, 'user-456', true) + cacheInvalidation.toggleMutedInCache(mockQueryClient, 'user-456', false) + + expect(mockQueryClient.setQueryData).toHaveBeenCalledTimes(2) + }) + }) + + describe('Tweet Mutations', () => { + it('onTweetCreate should invalidate user posts cache', () => { + const userId = 'user-123' + cacheInvalidation.onTweetCreate(mockQueryClient, userId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list(`/users/${userId}/posts`), + }) + }) + + it('onTweetDelete should remove tweet details cache', () => { + const tweetId = 'tweet-123' + cacheInvalidation.onTweetDelete(mockQueryClient, tweetId) + + expect(mockQueryClient.removeQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + }) + + it('onReplyCreate should invalidate parent tweet and related caches', () => { + const parentTweetId = 'tweet-456' + const userId = 'user-123' + + cacheInvalidation.onReplyCreate(mockQueryClient, parentTweetId, userId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(parentTweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list(`/users/${userId}/replies`), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/timeline/following'), + }) + }) + + it('onReplyDelete should invalidate parent tweet and timeline caches', () => { + const parentTweetId = 'tweet-456' + + cacheInvalidation.onReplyDelete(mockQueryClient, parentTweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(parentTweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/timeline/following'), + }) + }) + + it('onTweetLikeChange should invalidate tweet details and likes cache', () => { + const tweetId = 'tweet-123' + + cacheInvalidation.onTweetLikeChange(mockQueryClient, tweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/users/me/liked-posts'), + }) + }) + + it('onTweetRepostChange should invalidate tweet details and user timeline caches', () => { + const tweetId = 'tweet-123' + const path = '/timeline/following' + + cacheInvalidation.onTweetRepostChange(mockQueryClient, tweetId, path) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list(path), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.list('/timeline/following'), + }) + }) + + it('onTweetBookmarkChange should invalidate tweet details and bookmarks cache', () => { + const tweetId = 'tweet-123' + + cacheInvalidation.onTweetBookmarkChange(mockQueryClient, tweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.bookmarks.all, + }) + }) + + it('onTweetUpdate should invalidate tweet summary, details and search cache', () => { + const tweetId = 'tweet-123' + + cacheInvalidation.onTweetUpdate(mockQueryClient, tweetId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.summary(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.details(tweetId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.search.all, + }) + }) + }) + + describe('Profile Mutations', () => { + it('onProfileUpdate should invalidate profile and tweets caches', () => { + const username = 'johndoe' + + cacheInvalidation.onProfileUpdate(mockQueryClient, username) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(username), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.tweets.all, + }) + }) + + it('onUsernameChange should remove old profile and invalidate caches', () => { + const oldUsername = 'oldname' + + cacheInvalidation.onUsernameChange(mockQueryClient, oldUsername) + + expect(mockQueryClient.removeQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(oldUsername), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + }) + + it('onAvatarChange should invalidate profile and tweets caches', () => { + const username = 'johndoe' + + cacheInvalidation.onAvatarChange(mockQueryClient, username) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(username), + }) + }) + + it('onCoverPhotoChange should invalidate me and profile caches', () => { + const username = 'johndoe' + + cacheInvalidation.onCoverPhotoChange(mockQueryClient, username) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(username), + }) + }) + }) + + describe('User Action Mutations', () => { + it('onFollowChange should invalidate relevant user and tweet caches', () => { + const targetUserId = 'user-123' + const targetUsername = 'johndoe' + const currentUserId = 'user-456' + const isFollowing = true + + cacheInvalidation.onFollowChange( + mockQueryClient, + targetUserId, + targetUsername, + currentUserId, + isFollowing, + ) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.byId(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.profile(targetUsername), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.followers(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.following(currentUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + }) + + it('onFollowChange should also update tweet caches with follower delta', () => { + const targetUserId = 'user-123' + const targetUsername = 'johndoe' + const currentUserId = 'user-456' + + cacheInvalidation.onFollowChange( + mockQueryClient, + targetUserId, + targetUsername, + currentUserId, + true, + ) + + expect(mockQueryClient.setQueriesData).toHaveBeenCalledWith( + { queryKey: ['tweets'] }, + expect.any(Function), + ) + }) + + it('onBlockChange should invalidate user and notifications caches', () => { + const targetUserId = 'user-123' + + cacheInvalidation.onBlockChange(mockQueryClient, targetUserId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.byId(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.search.all, + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.notifications.all, + }) + }) + + it('onMuteChange should invalidate user and search caches', () => { + const targetUserId = 'user-123' + + cacheInvalidation.onMuteChange(mockQueryClient, targetUserId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.byId(targetUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.search.all, + }) + }) + + it('onRemoveFollower should invalidate follower and me caches', () => { + const currentUserId = 'user-123' + + cacheInvalidation.onRemoveFollower(mockQueryClient, currentUserId) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.followers(currentUserId), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + }) + }) + + describe('Auth Mutations', () => { + it('onLogout should clear all query data', () => { + cacheInvalidation.onLogout(mockQueryClient) + + expect(mockQueryClient.clear).toHaveBeenCalled() + }) + + it('onLogin should invalidate user and auth caches', () => { + cacheInvalidation.onLogin(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.users.me(), + }) + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.auth.user(), + }) + }) + }) + + describe('Chat/Conversation Mutations', () => { + it('onConversationCreate should invalidate conversations cache', () => { + cacheInvalidation.onConversationCreate(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.conversations.all, + }) + }) + + it('onFirstMessageSent should invalidate conversations cache', () => { + cacheInvalidation.onFirstMessageSent(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.conversations.all, + }) + }) + + it('onRemoveNotification should invalidate notifications cache', () => { + cacheInvalidation.onRemoveNotification(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.notifications.all, + }) + }) + + it('onRemoveMention should invalidate mentions cache', () => { + cacheInvalidation.onRemoveMention(mockQueryClient) + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.notifications.mentions, + }) + }) + }) + + describe('cache invalidation methods', () => { + it('should have all expected methods', () => { + const expectedMethods = [ + 'toggleBlockedInCache', + 'toggleMutedInCache', + 'onTweetCreate', + 'onTweetDelete', + 'onReplyCreate', + 'onReplyDelete', + 'onTweetLikeChange', + 'onTweetRepostChange', + 'onTweetBookmarkChange', + 'onTweetUpdate', + 'onProfileUpdate', + 'onUsernameChange', + 'onAvatarChange', + 'onCoverPhotoChange', + 'onFollowChange', + 'onBlockChange', + 'onMuteChange', + 'onRemoveFollower', + 'onLogout', + 'onLogin', + 'onConversationCreate', + 'onFirstMessageSent', + 'onRemoveNotification', + 'onRemoveMention', + ] + + expectedMethods.forEach((method) => { + expect(cacheInvalidation).toHaveProperty(method) + expect(typeof cacheInvalidation[method as keyof typeof cacheInvalidation]).toBe('function') + }) + }) + }) +}) diff --git a/app/modules/Common/test/unit/constants.spec.ts b/app/modules/Common/test/unit/constants.spec.ts new file mode 100644 index 00000000..5915e8c8 --- /dev/null +++ b/app/modules/Common/test/unit/constants.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { LOCALE_COOKIE_KEY } from '../../constants/localStorageConstants' +import { tooltipContentClass } from '../../constants/stylesConstants' + +describe('Constants', () => { + describe('localStorageConstants', () => { + it('should export LOCALE_COOKIE_KEY', () => { + expect(LOCALE_COOKIE_KEY).toBeDefined() + }) + + it('should have correct LOCALE_COOKIE_KEY value', () => { + expect(LOCALE_COOKIE_KEY).toBe('i18n_redirected') + }) + + it('should be a string', () => { + expect(typeof LOCALE_COOKIE_KEY).toBe('string') + }) + + it('should not be empty', () => { + expect(LOCALE_COOKIE_KEY.length).toBeGreaterThan(0) + }) + }) + + describe('stylesConstants', () => { + it('should export tooltipContentClass', () => { + expect(tooltipContentClass).toBeDefined() + }) + + it('should be a string', () => { + expect(typeof tooltipContentClass).toBe('string') + }) + + it('should contain tailwind classes', () => { + expect(tooltipContentClass).toContain('text-white') + expect(tooltipContentClass).toContain('bg-') + expect(tooltipContentClass).toContain('rounded-md') + }) + + it('should have specific styling', () => { + expect(tooltipContentClass).toBe( + 'text-white bg-[#536471] text-[12px] font-medium p-1 rounded-md', + ) + }) + + it('should not be empty', () => { + expect(tooltipContentClass.length).toBeGreaterThan(0) + }) + + it('should include color hex value', () => { + expect(tooltipContentClass).toContain('#536471') + }) + + it('should include padding', () => { + expect(tooltipContentClass).toContain('p-1') + }) + + it('should include font styling', () => { + expect(tooltipContentClass).toContain('font-medium') + }) + + it('should include text size', () => { + expect(tooltipContentClass).toContain('text-[12px]') + }) + }) + + describe('Constants exports', () => { + it('should not have name conflicts', () => { + const locale = LOCALE_COOKIE_KEY + const tooltip = tooltipContentClass + expect(locale).not.toBe(tooltip) + }) + + it('should have distinct purposes', () => { + expect(LOCALE_COOKIE_KEY).toMatch(/i18n|locale/i) + expect(tooltipContentClass).toMatch(/text|bg|rounded/i) + }) + }) +}) diff --git a/app/modules/Common/test/unit/listService.spec.ts b/app/modules/Common/test/unit/listService.spec.ts new file mode 100644 index 00000000..19c09204 --- /dev/null +++ b/app/modules/Common/test/unit/listService.spec.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useNuxtApp } from '#app' +import { listService } from '../../services/listService' + +vi.mock('#app', () => ({ + useNuxtApp: vi.fn(), +})) + +const mockUseNuxtApp = vi.mocked(useNuxtApp) + +describe('listService', () => { + let mockAxios: any + + beforeEach(() => { + vi.clearAllMocks() + + mockAxios = { + get: vi.fn(), + } + + mockUseNuxtApp.mockReturnValue({ + $axios: mockAxios, + } as any) + }) + + describe('fetchList', () => { + it('should fetch data without cursor', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1, name: 'Item 1' }], + pagination: { + next_cursor: 'cursor-2', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets') + expect(result).toEqual({ + data: [{ id: 1, name: 'Item 1' }], + nextCursor: 'cursor-2', + hasMore: true, + }) + }) + + it('should fetch data with cursor parameter', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 2, name: 'Item 2' }], + pagination: { + next_cursor: 'cursor-3', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', 'cursor-2') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?cursor=cursor-2') + expect(result).toEqual({ + data: [{ id: 2, name: 'Item 2' }], + nextCursor: 'cursor-3', + hasMore: true, + }) + }) + + it('should handle paths with existing query parameters', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1 }], + pagination: { + next_cursor: null, + has_more: false, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + await listService.fetchList('/tweets?filter=latest', 'cursor-1') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?filter=latest&cursor=cursor-1') + }) + + it('should handle last page (no next cursor)', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 3 }], + pagination: { + next_cursor: null, + has_more: false, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', 'last-cursor') + + expect(result).toEqual({ + data: [{ id: 3 }], + nextCursor: undefined, + hasMore: false, + }) + }) + + it('should handle alternative pagination format (next_cursor at root level)', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1 }], + next_cursor: 'cursor-2', + has_more: true, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(result.nextCursor).toBe('cursor-2') + expect(result.hasMore).toBe(true) + }) + + it('should handle multiple items in response', async () => { + const mockResponse = { + data: { + data: { + data: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + ], + pagination: { + next_cursor: 'cursor-next', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/users', '') + + expect(result.data.length).toBe(3) + expect(result.data[0].id).toBe(1) + expect(result.data[2].id).toBe(3) + }) + + it('should handle empty data array', async () => { + const mockResponse = { + data: { + data: { + data: [], + pagination: { + next_cursor: null, + has_more: false, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(result.data).toEqual([]) + expect(result.hasMore).toBe(false) + }) + + it('should throw error on API failure', async () => { + const error = new Error('Network error') + mockAxios.get.mockRejectedValue(error) + + await expect(listService.fetchList('/tweets', '')).rejects.toThrow('Network error') + }) + + it('should use correct separator for URL construction', async () => { + const mockResponse = { + data: { + data: { + data: [], + pagination: { next_cursor: null, has_more: false }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + // Path without query params should use ? + await listService.fetchList('/tweets', 'cursor-1') + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?cursor=cursor-1') + + mockAxios.get.mockClear() + + // Path with query params should use & + await listService.fetchList('/tweets?sort=desc', 'cursor-2') + expect(mockAxios.get).toHaveBeenCalledWith('/tweets?sort=desc&cursor=cursor-2') + }) + + it('should extract data correctly from nested response structure', async () => { + const mockResponse = { + data: { + data: { + data: [{ id: 1, title: 'Test' }], + pagination: { + next_cursor: 'next', + has_more: true, + }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + const result = await listService.fetchList('/tweets', '') + + expect(result.data).toEqual([{ id: 1, title: 'Test' }]) + expect(result.nextCursor).toBe('next') + expect(result.hasMore).toBe(true) + }) + + it('should handle null cursor correctly', async () => { + const mockResponse = { + data: { + data: { + data: [], + pagination: { next_cursor: null, has_more: false }, + }, + }, + } + mockAxios.get.mockResolvedValue(mockResponse) + + await listService.fetchList('/tweets', '') + + expect(mockAxios.get).toHaveBeenCalledWith('/tweets') + }) + }) +}) + diff --git a/app/modules/Common/test/unit/mediaService.spec.ts b/app/modules/Common/test/unit/mediaService.spec.ts new file mode 100644 index 00000000..28d289e9 --- /dev/null +++ b/app/modules/Common/test/unit/mediaService.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createMediaService } from '../../services/mediaService' +import { useNuxtApp } from '#app' + +vi.mock('#app', () => ({ + useNuxtApp: vi.fn(), +})) + +const mockUseNuxtApp = vi.mocked(useNuxtApp) + +describe('createMediaService', () => { + let mockAxios: any + let mockFormData: any + + beforeEach(() => { + vi.clearAllMocks() + + mockAxios = { + post: vi.fn(), + } + + mockUseNuxtApp.mockReturnValue({ + $axios: mockAxios, + } as any) + + // Mock FormData + mockFormData = { + append: vi.fn(), + } + global.FormData = vi.fn(() => mockFormData) as any + }) + describe('uploadMedia', () => { + it('should upload image successfully', async () => { + const mockFile = new File(['image content'], 'test.jpg', { type: 'image/jpeg' }) + const mockResponse = { + data: { + url: 'https://cdn.example.com/images/abc123.jpg', + id: 'img-123', + }, + } + mockAxios.post.mockResolvedValue(mockResponse) + + const result = await createMediaService.uploadMedia(mockFile, 'image') + + expect(mockFormData.append).toHaveBeenCalledWith('file', mockFile) + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/image', + mockFormData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 60000, + }, + ) + expect(result).toEqual(mockResponse.data) + }) + + it('should upload video successfully', async () => { + const mockFile = new File(['video content'], 'test.mp4', { type: 'video/mp4' }) + const mockResponse = { + data: { + url: 'https://cdn.example.com/videos/abc123.mp4', + id: 'vid-123', + }, + } + mockAxios.post.mockResolvedValue(mockResponse) + + const result = await createMediaService.uploadMedia(mockFile, 'video') + + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/video', + mockFormData, + expect.objectContaining({ + timeout: 60000, + }), + ) + expect(result).toEqual(mockResponse.data) + }) + + it('should use correct endpoint for image upload', async () => { + const mockFile = new File([''], 'test.jpg') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'image') + + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/image', + expect.any(Object), + expect.any(Object), + ) + }) + + it('should use correct endpoint for video upload', async () => { + const mockFile = new File([''], 'test.mp4') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'video') + + expect(mockAxios.post).toHaveBeenCalledWith( + '/tweets/upload/video', + expect.any(Object), + expect.any(Object), + ) + }) + + it('should set multipart form-data headers', async () => { + const mockFile = new File([''], 'test.jpg') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'image') + + const callArgs = mockAxios.post.mock.calls[0] + expect(callArgs[2]).toEqual({ + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 60000, + }) + }) + + it('should append file to FormData', async () => { + const mockFile = new File(['content'], 'test.jpg') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'image') + + expect(mockFormData.append).toHaveBeenCalledWith('file', mockFile) + }) + + it('should set 60 second timeout for uploads', async () => { + const mockFile = new File([''], 'test.mp4') + mockAxios.post.mockResolvedValue({ data: {} }) + + await createMediaService.uploadMedia(mockFile, 'video') + + const callArgs = mockAxios.post.mock.calls[0] + expect(callArgs[2].timeout).toBe(60000) + }) + + it('should return response data correctly', async () => { + const mockFile = new File([''], 'test.jpg') + const mockResponse = { + data: { + url: 'https://example.com/image.jpg', + id: 'media-id', + width: 1920, + height: 1080, + }, + } + mockAxios.post.mockResolvedValue(mockResponse) + + const result = await createMediaService.uploadMedia(mockFile, 'image') + + expect(result).toEqual(mockResponse.data) + expect(result.url).toBe('https://example.com/image.jpg') + expect(result.width).toBe(1920) + }) + + it('should throw error on upload failure', async () => { + const mockFile = new File([''], 'test.jpg') + const error = new Error('Upload failed') + mockAxios.post.mockRejectedValue(error) + + await expect(createMediaService.uploadMedia(mockFile, 'image')).rejects.toThrow( + 'Upload failed', + ) + }) + + it('should handle network errors', async () => { + const mockFile = new File([''], 'test.jpg') + const error = new Error('Network error') + mockAxios.post.mockRejectedValue(error) + + await expect(createMediaService.uploadMedia(mockFile, 'image')).rejects.toThrow( + 'Network error', + ) + }) + }) +}) + diff --git a/app/modules/Common/test/unit/queryKeys.spec.ts b/app/modules/Common/test/unit/queryKeys.spec.ts new file mode 100644 index 00000000..58c93f5b --- /dev/null +++ b/app/modules/Common/test/unit/queryKeys.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { queryKeys } from '../../queries/queryKeys' + +describe('queryKeys', () => { + describe('tweets', () => { + it('should have tweets.all key', () => { + expect(queryKeys.tweets.all).toEqual(['tweets']) + }) + + it('should generate tweets.list key with path', () => { + const key = queryKeys.tweets.list('/timeline') + expect(key).toEqual(['tweets', '/timeline']) + }) + + it('should generate tweets.details key with tweetId', () => { + const key = queryKeys.tweets.details('123') + expect(key).toEqual(['tweetDetails', '123']) + }) + + it('should generate tweets.summary key with tweetId', () => { + const key = queryKeys.tweets.summary('456') + expect(key).toEqual(['tweetSummary', '456']) + }) + + it('should handle different paths in list keys', () => { + const paths = ['/timeline/following', '/users/me/liked-posts', '/search'] + paths.forEach((path) => { + const key = queryKeys.tweets.list(path) + expect(key).toEqual(['tweets', path]) + expect(key[1]).toBe(path) + }) + }) + }) + + describe('users', () => { + it('should have users.all key', () => { + expect(queryKeys.users.all).toEqual(['user']) + }) + + it('should generate users.profile key with username', () => { + const key = queryKeys.users.profile('johndoe') + expect(key).toEqual(['user', 'johndoe']) + }) + + it('should generate users.byId key with userId', () => { + const key = queryKeys.users.byId('user-123') + expect(key).toEqual(['user', 'user-123']) + }) + + it('should generate users.me key', () => { + const key = queryKeys.users.me() + expect(key).toEqual(['me']) + }) + + it('should generate users.followers key', () => { + const key = queryKeys.users.followers('user-123') + expect(key).toEqual(['followers', 'user-123']) + }) + + it('should generate users.following key', () => { + const key = queryKeys.users.following('user-456') + expect(key).toEqual(['following', 'user-456']) + }) + }) + + describe('settings', () => { + it('should generate settings.mutedUsers key', () => { + const key = queryKeys.settings.mutedUsers() + expect(key).toEqual(['muted-users']) + }) + + it('should generate settings.blockedUsers key', () => { + const key = queryKeys.settings.blockedUsers() + expect(key).toEqual(['blocked-users']) + }) + + it('should generate settings.usernameRecommendation key', () => { + const key = queryKeys.settings.usernameRecommendation() + expect(key).toEqual(['username-recommendation']) + }) + }) + + describe('auth', () => { + it('should generate auth.user key', () => { + const key = queryKeys.auth.user() + expect(key).toEqual(['getUser']) + }) + }) + + describe('conversations', () => { + it('should have conversations.all key', () => { + expect(queryKeys.conversations.all).toEqual(['conversations']) + }) + }) + + describe('notifications', () => { + it('should have notifications.all key', () => { + expect(queryKeys.notifications.all).toEqual(['notifications']) + }) + + it('should have notifications.mentions key', () => { + expect(queryKeys.notifications.mentions).toEqual(['mentions']) + }) + }) + + describe('search', () => { + it('should have search.all key', () => { + expect(queryKeys.search.all).toEqual(['tweets', '/search']) + }) + }) + + describe('bookmarks', () => { + it('should have bookmarks.all key', () => { + expect(queryKeys.bookmarks.all).toEqual(['tweets', 'tweets/bookmarks']) + }) + }) + + describe('query key consistency', () => { + it('should have proper key structure for nested queries', () => { + expect(Array.isArray(queryKeys.tweets.all)).toBe(true) + expect(Array.isArray(queryKeys.users.all)).toBe(true) + expect(Array.isArray(queryKeys.conversations.all)).toBe(true) + }) + + it('should have proper key structure for functions', () => { + const tweetKey = queryKeys.tweets.details('123') + const userKey = queryKeys.users.profile('user') + expect(Array.isArray(tweetKey)).toBe(true) + expect(Array.isArray(userKey)).toBe(true) + expect(tweetKey.length).toBe(2) + expect(userKey.length).toBe(2) + }) + + it('should not have duplicate top-level keys', () => { + const topKeys = Object.keys(queryKeys) + const uniqueKeys = new Set(topKeys) + expect(topKeys.length).toBe(uniqueKeys.size) + }) + }) + + describe('key isolation', () => { + it('tweets and search keys should be distinct', () => { + expect(queryKeys.tweets.all).not.toEqual(queryKeys.search.all) + }) + + it('different user keys should be distinct', () => { + const profile1 = queryKeys.users.profile('user1') + const profile2 = queryKeys.users.profile('user2') + expect(profile1).not.toEqual(profile2) + }) + + it('different tweet detail keys should be distinct', () => { + const tweet1 = queryKeys.tweets.details('123') + const tweet2 = queryKeys.tweets.details('456') + expect(tweet1).not.toEqual(tweet2) + }) + }) +}) diff --git a/app/plugins/axios.ts b/app/plugins/axios.ts index f920c575..7e7ced04 100644 --- a/app/plugins/axios.ts +++ b/app/plugins/axios.ts @@ -40,7 +40,11 @@ export default defineNuxtPlugin(() => { async (error) => { const requestUrl = error.config?.url || '' const isAuthEndpoint = requestUrl.includes('/auth/') - + if(requestUrl == '/auth/refresh') { + userStore.logout() // logout handles both store and cookie + navigateTo('/auth/login') + return Promise.reject(error) + } if (error.response?.status === 401 && isAuthEndpoint) { if (process.client) { userStore.logout() // logout handles both store and cookie @@ -64,18 +68,14 @@ export default defineNuxtPlugin(() => { const authService = nuxtApp.$authService const response = await authService.GetAccessToken() const access_token = response.data.access_token - // Update cookie (will sync to store via watch) const token = useCookie('access_token') token.value = access_token - // Also update store directly for immediate availability userStore.setAccessToken(access_token) - // Retry the original request with the new token const originalRequest = error.config originalRequest.headers['Authorization'] = `Bearer ${access_token}` return yapperApi(originalRequest) } catch (refreshError) { - // Refresh failed, clear access token and redirect - userStore.logout() // logout handles both store and cookie + userStore.logout() navigateTo('/auth/login') return Promise.reject(refreshError) }