diff --git a/src/features/authentication/hooks/useEmailValidation.ts b/src/features/authentication/hooks/useEmailValidation.ts index 04db2f7c..0f3c6ab2 100644 --- a/src/features/authentication/hooks/useEmailValidation.ts +++ b/src/features/authentication/hooks/useEmailValidation.ts @@ -19,11 +19,21 @@ const getFriendlyErrorMessage = (backendMessage: string): string => { const message = backendMessage.toLowerCase(); if (message.includes('email must be an email')) { - return 'Please enter a valid email'; + return 'Please enter a valid email.'; } + if (message.includes('email has already been taken')) { + return 'Email has already been taken.'; + } + if (message.includes('invalid email format')) { + return 'Invalid email format.'; + } + if (message.includes('email is required')) { + return 'Email is required.'; + } + // Add more mappings as needed // Default fallback for other validation errors - return 'Please enter a valid email'; + return 'An unknown error occurred. Please check your email.'; }; export function useEmailValidation({ diff --git a/src/features/explore/components/SearchTweets.tsx b/src/features/explore/components/SearchTweets.tsx index 659ac93b..307a8a46 100644 --- a/src/features/explore/components/SearchTweets.tsx +++ b/src/features/explore/components/SearchTweets.tsx @@ -18,7 +18,6 @@ export default function SearchTweets() { hasNextPage, } = useExploreSearchFeed(); - console.log(data); const pages = data?.pages.flat(); const renderTweets = pages?.map((group, i) => ( diff --git a/src/features/explore/components/Trendings.tsx b/src/features/explore/components/Trendings.tsx index e6bab81d..e4a04ab7 100644 --- a/src/features/explore/components/Trendings.tsx +++ b/src/features/explore/components/Trendings.tsx @@ -10,7 +10,6 @@ import { useRouter } from 'next/navigation'; export default function Trendings() { const { data, error, isError, isLoading } = useTrendingFeed(); const router = useRouter(); - console.log(data?.metadata.HashtagsCount); const renderTrends = data?.data.trending.map((trend, ind) => ( { const selectedTab = useSelectedSearchTab(); const search = useSearch(); - console.log(search.length); const searchDate = useSearchDate(); const isHash = search.trimStart().startsWith('#') && !search.trimStart().includes(' ') && !search.trimStart().slice(1).includes('#'); const type = isHash ? 'hashtag' : 'searchQuery'; - console.log(isHash); const queryKey: | ReturnType | ReturnType = @@ -107,7 +105,6 @@ export const useTrendingFeed = () => { const selectedTab = useSelectedTab(); const path = usePathname(); const valid = path?.startsWith('/explore'); - console.log(path, valid); let queryKey; let limit; switch (selectedTab) { @@ -132,9 +129,11 @@ export const useTrendingFeed = () => { case FOR_YOU_TAB: queryKey = EXPLORE_QUERY_KEYS.EXPLORE_TRENDS_FOR_YOU; limit = 5; + break; default: queryKey = EXPLORE_QUERY_KEYS.EXPLORE_TRENDS_FOR_YOU; limit = 5; + break; } return useQuery< diff --git a/src/features/explore/services/exploreApi.ts b/src/features/explore/services/exploreApi.ts index b8c3f7b5..d84ecf1f 100644 --- a/src/features/explore/services/exploreApi.ts +++ b/src/features/explore/services/exploreApi.ts @@ -46,7 +46,6 @@ async function handleResponse(response: Response): Promise { throw new ApiError(errorMessage, statusCode); } const data = await response.json(); - console.log(data); return data; } export const exploreApi = { @@ -149,7 +148,6 @@ export const exploreApi = { limit: `${limit}`, sortBy: `latest`, }); - console.log(tab); const response = await fetch( `${API_CONFIG.BASE_URL}${EXPLORE_ENDPOINTS.EXPLORE_FEED_INTEREST}?` + params, diff --git a/src/features/explore/tests/Explore.test.tsx b/src/features/explore/tests/Explore.test.tsx new file mode 100644 index 00000000..b42062fd --- /dev/null +++ b/src/features/explore/tests/Explore.test.tsx @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useSelectedTab: vi.fn(() => 'personalized'), +})); + +// Mock the hooks +vi.mock('../hooks/exploreQueries', () => ({ + useTrendingFeed: vi.fn(() => ({ + data: { + data: { trending: [{ tag: '#test', totalPosts: 100 }] }, + metadata: { HashtagsCount: 1, category: 'general' }, + }, + error: null, + isError: false, + isLoading: false, + })), + useExplorePosts: vi.fn(() => ({ + data: { + data: { + Technology: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Test tweet', + }, + ], + }, + }, + error: null, + isError: false, + isLoading: false, + })), +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/explore', +})); + +vi.mock('@/components/generic/Loader', () => ({ + default: () =>
Loading...
, +})); + +vi.mock('../constants/tabs', () => ({ + FOR_YOU_TAB: 'personalized', + TRENDING_TAB: 'general', + TOP_TAB: 'top', + NEWS_TAB: 'news', + SPORTS_TAB: 'sports', + ENTERTAINMENT_TAB: 'entertainment', +})); + +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: { data: { text: string } }) => ( +
{data.text}
+ ), +})); + +vi.mock('@/components/ui/home/Icon', () => ({ + default: () =>
Icon
, +})); + +import Explore from '../components/Explore'; +import { useSelectedTab } from '../store/useExploreStore'; +import { useTrendingFeed } from '../hooks/exploreQueries'; + +describe('Explore', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useSelectedTab).mockReturnValue('personalized'); + vi.mocked(useTrendingFeed).mockReturnValue({ + data: { + data: { trending: [{ tag: '#test', totalPosts: 100 }] }, + metadata: { HashtagsCount: 1, category: 'general' }, + }, + error: null, + isError: false, + isLoading: false, + } as ReturnType); + }); + + it('should render ForYou content when FOR_YOU_TAB is selected', async () => { + vi.mocked(useSelectedTab).mockReturnValue('personalized'); + render(); + // ForYou renders TweetsList and Trendings + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + expect( + screen.getByTestId('explore-feed-render-tweet-list') + ).toBeInTheDocument(); + }); + + it('should render Trendings content when a different tab is selected', async () => { + vi.mocked(useSelectedTab).mockReturnValue('general'); + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + }); + + it('should render component without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render Trendings when TRENDING_TAB is selected', async () => { + vi.mocked(useSelectedTab).mockReturnValue('general'); + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + }); + + it('should render Trendings when NEWS_TAB is selected', async () => { + vi.mocked(useSelectedTab).mockReturnValue('news'); + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + }); + + it('should render Trendings when SPORTS_TAB is selected', async () => { + vi.mocked(useSelectedTab).mockReturnValue('sports'); + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + }); + + it('should render Trendings when ENTERTAINMENT_TAB is selected', async () => { + vi.mocked(useSelectedTab).mockReturnValue('entertainment'); + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/explore/tests/ForYou.test.tsx b/src/features/explore/tests/ForYou.test.tsx new file mode 100644 index 00000000..f84f3a2d --- /dev/null +++ b/src/features/explore/tests/ForYou.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +// Mock the hooks +vi.mock('../hooks/exploreQueries', () => ({ + useTrendingFeed: vi.fn(() => ({ + data: { + data: { trending: [{ tag: '#test', totalPosts: 100 }] }, + metadata: { HashtagsCount: 1, category: 'general' }, + }, + error: null, + isError: false, + isLoading: false, + })), + useExplorePosts: vi.fn(() => ({ + data: { + data: { + Technology: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Test tweet', + }, + ], + }, + }, + error: null, + isError: false, + isLoading: false, + })), +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/explore', +})); + +vi.mock('../constants/tabs', () => ({ + FOR_YOU_TAB: 'personalized', + TRENDING_TAB: 'general', + TOP_TAB: 'top', + NEWS_TAB: 'news', + SPORTS_TAB: 'sports', + ENTERTAINMENT_TAB: 'entertainment', +})); + +vi.mock('../store/useExploreStore', () => ({ + useSelectedTab: vi.fn(() => 'personalized'), +})); + +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: { data: { text: string } }) => ( +
{data.text}
+ ), +})); + +vi.mock('@/components/ui/home/Icon', () => ({ + default: () =>
Icon
, +})); + +import ForYou from '../components/ForYou'; + +describe('ForYou', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the ForYou component', () => { + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + expect( + screen.getByTestId('explore-feed-render-tweet-list') + ).toBeInTheDocument(); + }); + + it('should render Trendings content', () => { + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + }); + + it('should render TweetsList content', () => { + render(); + expect( + screen.getByTestId('explore-feed-render-tweet-list') + ).toBeInTheDocument(); + }); + + it('should have correct flex container structure', () => { + const { container } = render(); + const flexContainer = container.querySelector('.flex.flex-col.flex-1'); + expect(flexContainer).toBeInTheDocument(); + }); + + it('should render both sections in order', () => { + const { container } = render(); + const children = container.firstChild?.childNodes; + expect(children).toHaveLength(2); + }); +}); diff --git a/src/features/explore/tests/Header.test.tsx b/src/features/explore/tests/Header.test.tsx new file mode 100644 index 00000000..06c357ee --- /dev/null +++ b/src/features/explore/tests/Header.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockPush = vi.fn(); +const mockSetSelectedTab = vi.fn(); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useActions: () => ({ + selectTab: mockSetSelectedTab, + }), + useSelectedTab: vi.fn(() => 'personalized'), +})); + +// Mock Tabs component +vi.mock('@/components/generic/Tabs', () => ({ + default: ({ + tabs, + selectedValue, + onClick, + 'data-testid': testId, + }: { + tabs: { title: string; value: string }[]; + selectedValue: string; + onClick: (value: string) => void; + 'data-testid'?: string; + }) => ( +
+ {tabs.map((tab) => ( + + ))} +
+ ), +})); + +// Mock SearchProfile +vi.mock('@/features/timeline/components/SearchProfile', () => ({ + default: () =>
Search Profile
, +})); + +// Mock constants +vi.mock('../constants/tabs', () => ({ + exploreTabs: [ + { title: 'For You', value: 'personalized' }, + { title: 'Trending', value: 'general' }, + { title: 'News', value: 'news' }, + { title: 'Sports', value: 'sports' }, + { title: 'Entertainment', value: 'entertainment' }, + ], +})); + +import Header from '../components/Header'; + +describe('Header', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the header component', () => { + render(
); + expect(screen.getByTestId('timeline-explore-header')).toBeInTheDocument(); + }); + + it('should render SearchProfile component', () => { + render(
); + expect(screen.getByTestId('search-profile')).toBeInTheDocument(); + }); + + it('should render Tabs component', () => { + render(
); + expect(screen.getByTestId('timeline-explore-tabs')).toBeInTheDocument(); + }); + + it('should call router.push and setSelectedTab when tab is clicked', () => { + render(
); + const generalTab = screen.getByTestId('tab-general'); + fireEvent.click(generalTab); + + expect(mockPush).toHaveBeenCalledWith('/explore/tabs/general'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('general'); + }); + + it('should navigate to For You tab', () => { + render(
); + const forYouTab = screen.getByTestId('tab-personalized'); + fireEvent.click(forYouTab); + + expect(mockPush).toHaveBeenCalledWith('/explore/tabs/personalized'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('personalized'); + }); + + it('should navigate to News tab', () => { + render(
); + const newsTab = screen.getByTestId('tab-news'); + fireEvent.click(newsTab); + + expect(mockPush).toHaveBeenCalledWith('/explore/tabs/news'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('news'); + }); + + it('should navigate to Sports tab', () => { + render(
); + const sportsTab = screen.getByTestId('tab-sports'); + fireEvent.click(sportsTab); + + expect(mockPush).toHaveBeenCalledWith('/explore/tabs/sports'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('sports'); + }); + + it('should navigate to Entertainment tab', () => { + render(
); + const entertainmentTab = screen.getByTestId('tab-entertainment'); + fireEvent.click(entertainmentTab); + + expect(mockPush).toHaveBeenCalledWith('/explore/tabs/entertainment'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('entertainment'); + }); + + it('should have sticky positioning', () => { + render(
); + const header = screen.getByTestId('timeline-explore-header'); + expect(header).toHaveClass('sticky'); + expect(header).toHaveClass('top-0'); + }); + + it('should have backdrop blur effect', () => { + render(
); + const header = screen.getByTestId('timeline-explore-header'); + expect(header).toHaveClass('backdrop-blur-md'); + }); +}); diff --git a/src/features/explore/tests/Interest.test.tsx b/src/features/explore/tests/Interest.test.tsx new file mode 100644 index 00000000..d5945801 --- /dev/null +++ b/src/features/explore/tests/Interest.test.tsx @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockFetchNextPage = vi.fn(); + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useInterest: vi.fn(() => 'Technology'), +})); + +// Mock the hook +vi.mock('../hooks/exploreQueries', () => ({ + useExploreInterest: vi.fn(() => ({ + data: { + pages: [ + { + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + username: 'testuser', + name: 'Test User', + text: 'Test tweet', + avatar: 'avatar.jpg', + likesCount: 10, + retweetsCount: 5, + commentsCount: 2, + isLikedByMe: false, + isFollowedByMe: false, + isRepostedByMe: false, + verified: false, + mentions: [], + media: [], + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: true, + })), +})); + +// Mock components +vi.mock('@/components/generic', () => ({ + Loader: () =>
Loading...
, +})); + +vi.mock('@/components/ui/home/InfiniteScroll', () => ({ + default: ({ + children, + 'data-testid': testId, + }: { + children: React.ReactNode; + 'data-testid'?: string; + }) =>
{children}
, +})); + +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn((message: string) => ( +
{message}
+ )), +})); + +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: { data: { text: string } }) => ( +
{data.text}
+ ), +})); + +import Interest from '../components/Interest'; +import { useExploreInterest } from '../hooks/exploreQueries'; + +describe('Interest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the Interest component with tweets', () => { + render(); + expect(screen.getByTestId('tweet-interest-list')).toBeInTheDocument(); + }); + + it('should render tweets when data is available', () => { + render(); + expect(screen.getByTestId('tweet-component')).toBeInTheDocument(); + expect(screen.getByText('Test tweet')).toBeInTheDocument(); + }); + + it('should render tweet list container', () => { + render(); + expect( + screen.getByTestId('render-tweet-interest-list') + ).toBeInTheDocument(); + }); + + it('should show loader when loading', () => { + vi.mocked(useExploreInterest).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as ReturnType); + + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should show loading state with proper test id', () => { + vi.mocked(useExploreInterest).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as ReturnType); + + render(); + expect( + screen.getByTestId('tweet-list-interest-loading') + ).toBeInTheDocument(); + }); + + it('should show error message when error occurs', () => { + vi.mocked(useExploreInterest).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Test tweet', + }, + ], + }, + }, + ], + }, + error: new Error('Failed to fetch'), + isError: true, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as unknown as ReturnType); + + render(); + expect(screen.getByTestId('toaster-message')).toBeInTheDocument(); + }); + + it('should show no interest message when no data available', () => { + vi.mocked(useExploreInterest).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as unknown as ReturnType); + + render(); + expect( + screen.getByText('No such Interest available Right Now') + ).toBeInTheDocument(); + }); + + it('should render multiple tweets', () => { + vi.mocked(useExploreInterest).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'First tweet', + }, + { + userId: 2, + postId: 2, + date: '2024-01-02', + text: 'Second tweet', + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: true, + } as unknown as ReturnType); + + render(); + expect(screen.getByText('First tweet')).toBeInTheDocument(); + expect(screen.getByText('Second tweet')).toBeInTheDocument(); + }); + + it('should handle multiple pages of data', () => { + vi.mocked(useExploreInterest).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Page 1 tweet', + }, + ], + }, + }, + { + data: { + posts: [ + { + userId: 2, + postId: 2, + date: '2024-01-02', + text: 'Page 2 tweet', + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: true, + } as unknown as ReturnType); + + render(); + expect(screen.getByText('Page 1 tweet')).toBeInTheDocument(); + expect(screen.getByText('Page 2 tweet')).toBeInTheDocument(); + }); +}); diff --git a/src/features/explore/tests/InterestHeader.test.tsx b/src/features/explore/tests/InterestHeader.test.tsx new file mode 100644 index 00000000..c00b8119 --- /dev/null +++ b/src/features/explore/tests/InterestHeader.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockPush = vi.fn(); +const mockBack = vi.fn(); +const mockSetSelectedTab = vi.fn(); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack, + }), +})); + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useActions: () => ({ + selectInterestTab: mockSetSelectedTab, + }), + useSelectedInterestTab: vi.fn(() => 'top'), + useInterest: vi.fn(() => 'Technology'), +})); + +// Mock Tabs component +vi.mock('@/components/generic/Tabs', () => ({ + default: ({ + tabs, + selectedValue, + onClick, + 'data-testid': testId, + }: { + tabs: { title: string; value: string }[]; + selectedValue: string; + onClick: (value: string) => void; + 'data-testid'?: string; + }) => ( +
+ {tabs.map((tab) => ( + + ))} +
+ ), +})); + +// Mock Icon component +vi.mock('@/components/ui/home/Icon', () => ({ + default: ({ onClick }: { onClick?: () => void }) => ( + + ), +})); + +// Mock constants +vi.mock('../constants/tabs', () => ({ + InterestTabs: [ + { title: 'Top', value: 'top' }, + { title: 'Latest', value: 'latest' }, + ], +})); + +import InterestHeader from '../components/InterestHeader'; +import { useInterest } from '../store/useExploreStore'; + +describe('InterestHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the InterestHeader component', () => { + render(); + expect(screen.getByTestId('timeline-explore-header')).toBeInTheDocument(); + }); + + it('should render Interest title', () => { + render(); + expect(screen.getByText('Interest')).toBeInTheDocument(); + }); + + it('should render the interest name', () => { + render(); + expect(screen.getByText('Technology')).toBeInTheDocument(); + }); + + it('should render back button', () => { + render(); + expect(screen.getByTestId('back-icon')).toBeInTheDocument(); + }); + + it('should call router.back when back button is clicked', () => { + render(); + const backButton = screen.getByTestId('back-icon'); + fireEvent.click(backButton); + expect(mockBack).toHaveBeenCalled(); + }); + + it('should render Tabs component', () => { + render(); + expect(screen.getByTestId('timeline-explore-tabs')).toBeInTheDocument(); + }); + + it('should call router.push and setSelectedTab when tab is clicked', () => { + render(); + const latestTab = screen.getByTestId('tab-latest'); + fireEvent.click(latestTab); + + expect(mockPush).toHaveBeenCalledWith('/interests/Technology/latest'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('latest'); + }); + + it('should navigate to Top tab', () => { + render(); + const topTab = screen.getByTestId('tab-top'); + fireEvent.click(topTab); + + expect(mockPush).toHaveBeenCalledWith('/interests/Technology/top'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('top'); + }); + + it('should render info text about interests', () => { + render(); + expect( + screen.getByText( + 'Posts about the Interests you follow show up in your Home Timeline' + ) + ).toBeInTheDocument(); + }); + + it('should display different interest names', () => { + vi.mocked(useInterest).mockReturnValue('Sports'); + render(); + expect(screen.getByText('Sports')).toBeInTheDocument(); + }); + + it('should navigate correctly with different interest', () => { + vi.mocked(useInterest).mockReturnValue('Music'); + render(); + const latestTab = screen.getByTestId('tab-latest'); + fireEvent.click(latestTab); + + expect(mockPush).toHaveBeenCalledWith('/interests/Music/latest'); + }); + + it('should have sticky header with proper styling', () => { + render(); + const header = screen.getByTestId('timeline-explore-header'); + expect(header).toHaveClass('sticky'); + expect(header).toHaveClass('top-0'); + expect(header).toHaveClass('backdrop-blur-md'); + }); +}); diff --git a/src/features/explore/tests/SearchBar.test.tsx b/src/features/explore/tests/SearchBar.test.tsx new file mode 100644 index 00000000..02fd5b8b --- /dev/null +++ b/src/features/explore/tests/SearchBar.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockBack = vi.fn(); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + back: mockBack, + }), +})); + +// Mock Icon component +vi.mock('@/components/ui/home/Icon', () => ({ + default: ({ onClick }: { onClick?: () => void }) => ( + + ), +})); + +// Mock SearchProfile component +vi.mock('@/features/timeline/components/SearchProfile', () => ({ + default: () =>
Search Profile
, +})); + +import SearchBar from '../components/SearchBar'; + +describe('SearchBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the SearchBar component', () => { + render(); + expect(screen.getByTestId('back-icon')).toBeInTheDocument(); + expect(screen.getByTestId('search-profile')).toBeInTheDocument(); + }); + + it('should render back button', () => { + render(); + expect(screen.getByTestId('back-icon')).toBeInTheDocument(); + }); + + it('should call router.back when back button is clicked', () => { + render(); + const backButton = screen.getByTestId('back-icon'); + fireEvent.click(backButton); + expect(mockBack).toHaveBeenCalled(); + }); + + it('should render SearchProfile component', () => { + render(); + expect(screen.getByTestId('search-profile')).toBeInTheDocument(); + }); + + it('should have flex container layout', () => { + const { container } = render(); + const flexContainer = container.querySelector('.flex.items-center'); + expect(flexContainer).toBeInTheDocument(); + }); + + it('should have gap between elements', () => { + const { container } = render(); + const flexContainer = container.querySelector('.gap-1'); + expect(flexContainer).toBeInTheDocument(); + }); + + it('should have proper padding', () => { + const { container } = render(); + const flexContainer = container.querySelector('.px-3'); + expect(flexContainer).toBeInTheDocument(); + }); + + it('should render with full width', () => { + const { container } = render(); + const flexContainer = container.querySelector('.w-full'); + expect(flexContainer).toBeInTheDocument(); + }); +}); diff --git a/src/features/explore/tests/SearchHeader.test.tsx b/src/features/explore/tests/SearchHeader.test.tsx new file mode 100644 index 00000000..71981343 --- /dev/null +++ b/src/features/explore/tests/SearchHeader.test.tsx @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockPush = vi.fn(); +const mockBack = vi.fn(); +const mockSetSelectedTab = vi.fn(); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack, + }), + usePathname: () => '/search', +})); + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useActions: () => ({ + selectSearchTab: mockSetSelectedTab, + }), + useSelectedSearchTab: vi.fn(() => 'top'), + useSearch: vi.fn(() => 'test search'), +})); + +// Mock timeline store +vi.mock('@/features/timeline/store/useTimelineStore', () => ({ + useSearch: vi.fn(() => 'test'), + useSearchAction: vi.fn(() => ({ + setSearch: vi.fn(), + setIsOpen: vi.fn(), + })), + useSearchIsopen: vi.fn(() => false), +})); + +// Mock timeline queries +vi.mock('@/features/timeline/hooks/timelineQueries', () => ({ + useSearchProfile: vi.fn(() => ({ + data: null, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + })), + useSearchHashtag: vi.fn(() => ({ + data: null, + isLoading: false, + })), +})); + +// Mock useDebounce +vi.mock('@/features/timeline/hooks/useDebounce', () => ({ + default: vi.fn((value: string) => value), +})); + +// Mock Tabs component +vi.mock('@/components/generic/Tabs', () => ({ + default: ({ + tabs, + selectedValue, + onClick, + 'data-testid': testId, + }: { + tabs: { title: string; value: string }[]; + selectedValue: string; + onClick: (value: string) => void; + 'data-testid'?: string; + }) => ( +
+ {tabs.map((tab) => ( + + ))} +
+ ), +})); + +// Mock SearchBar component +vi.mock('./SearchBar', () => ({ + default: () =>
Search Bar
, +})); + +// Mock constants +vi.mock('../constants/tabs', () => ({ + searchTabs: [ + { title: 'Top', value: 'top' }, + { title: 'Latest', value: 'latest' }, + ], + TOP_TAB: 'top', +})); + +import SearchHeader from '../components/SearchHeader'; +import { useSearch } from '../store/useExploreStore'; + +describe('SearchHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the SearchHeader component', () => { + render(); + expect( + screen.getByTestId('timeline-explore-search-header') + ).toBeInTheDocument(); + }); + + it('should render SearchBar component', () => { + render(); + // SearchBar renders the back icon and search profile + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); + + it('should render Tabs component', () => { + render(); + expect( + screen.getByTestId('timeline-explore-search-tabs') + ).toBeInTheDocument(); + }); + + it('should navigate to Top tab with correct params', () => { + render(); + const topTab = screen.getByTestId('tab-top'); + fireEvent.click(topTab); + + expect(mockPush).toHaveBeenCalledWith('/search?q=test+search'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('top'); + }); + + it('should navigate to Latest tab with correct params', () => { + render(); + const latestTab = screen.getByTestId('tab-latest'); + fireEvent.click(latestTab); + + expect(mockPush).toHaveBeenCalledWith('/search?q=test+search&f=live'); + expect(mockSetSelectedTab).toHaveBeenCalledWith('latest'); + }); + + it('should have sticky positioning', () => { + render(); + const header = screen.getByTestId('timeline-explore-search-header'); + expect(header).toHaveClass('sticky'); + expect(header).toHaveClass('top-0'); + }); + + it('should have backdrop blur effect', () => { + render(); + const header = screen.getByTestId('timeline-explore-search-header'); + expect(header).toHaveClass('backdrop-blur-md'); + }); + + it('should use different search query', () => { + vi.mocked(useSearch).mockReturnValue('different search'); + render(); + const topTab = screen.getByTestId('tab-top'); + fireEvent.click(topTab); + + expect(mockPush).toHaveBeenCalledWith('/search?q=different+search'); + }); + + it('should handle empty search query', () => { + vi.mocked(useSearch).mockReturnValue(''); + render(); + const topTab = screen.getByTestId('tab-top'); + fireEvent.click(topTab); + + expect(mockPush).toHaveBeenCalledWith('/search?q='); + }); + + it('should handle special characters in search', () => { + vi.mocked(useSearch).mockReturnValue('#hashtag'); + render(); + const topTab = screen.getByTestId('tab-top'); + fireEvent.click(topTab); + + expect(mockPush).toHaveBeenCalledWith('/search?q=%23hashtag'); + }); +}); diff --git a/src/features/explore/tests/SearchTweets.test.tsx b/src/features/explore/tests/SearchTweets.test.tsx new file mode 100644 index 00000000..5579d9f5 --- /dev/null +++ b/src/features/explore/tests/SearchTweets.test.tsx @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockFetchNextPage = vi.fn(); + +// Mock the hook +vi.mock('../hooks/exploreQueries', () => ({ + useExploreSearchFeed: vi.fn(() => ({ + data: { + pages: [ + { + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + username: 'testuser', + name: 'Test User', + text: 'Test tweet', + avatar: 'avatar.jpg', + likesCount: 10, + retweetsCount: 5, + commentsCount: 2, + isLikedByMe: false, + isFollowedByMe: false, + isRepostedByMe: false, + verified: false, + mentions: [], + media: [], + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: true, + })), +})); + +// Mock components +vi.mock('@/components/generic/Loader', () => ({ + default: () =>
Loading...
, +})); + +vi.mock('@/components/ui/home/InfiniteScroll', () => ({ + default: ({ + children, + 'data-testid': testId, + }: { + children: React.ReactNode; + 'data-testid'?: string; + }) =>
{children}
, +})); + +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn((message: string) => ( +
{message}
+ )), +})); + +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: { data: { text: string } }) => ( +
{data.text}
+ ), +})); + +import SearchTweets from '../components/SearchTweets'; +import { useExploreSearchFeed } from '../hooks/exploreQueries'; + +describe('SearchTweets', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the SearchTweets component with tweets', () => { + render(); + expect(screen.getByTestId('tweet-search-list')).toBeInTheDocument(); + }); + + it('should render tweets when data is available', () => { + render(); + expect(screen.getByTestId('tweet-component')).toBeInTheDocument(); + expect(screen.getByText('Test tweet')).toBeInTheDocument(); + }); + + it('should render tweet list container', () => { + render(); + expect( + screen.getByTestId('explore-search-render-tweet-list') + ).toBeInTheDocument(); + }); + + it('should show loader when loading', () => { + vi.mocked(useExploreSearchFeed).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as ReturnType); + + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should show loading state with proper test id', () => { + vi.mocked(useExploreSearchFeed).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as ReturnType); + + render(); + expect( + screen.getByTestId('explore-search-tweet-list-loading') + ).toBeInTheDocument(); + }); + + it('should show error message when error occurs', () => { + vi.mocked(useExploreSearchFeed).mockReturnValue({ + data: undefined, + error: new Error('Failed to fetch'), + isError: true, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as ReturnType); + + render(); + expect(screen.getByTestId('toaster-message')).toBeInTheDocument(); + }); + + it('should render multiple tweets', () => { + vi.mocked(useExploreSearchFeed).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'First tweet', + }, + { + userId: 2, + postId: 2, + date: '2024-01-02', + text: 'Second tweet', + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: true, + } as unknown as ReturnType); + + render(); + expect(screen.getByText('First tweet')).toBeInTheDocument(); + expect(screen.getByText('Second tweet')).toBeInTheDocument(); + }); + + it('should handle multiple pages of data', () => { + vi.mocked(useExploreSearchFeed).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Page 1 tweet', + }, + ], + }, + }, + { + data: { + posts: [ + { + userId: 2, + postId: 2, + date: '2024-01-02', + text: 'Page 2 tweet', + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: true, + } as unknown as ReturnType); + + render(); + expect(screen.getByText('Page 1 tweet')).toBeInTheDocument(); + expect(screen.getByText('Page 2 tweet')).toBeInTheDocument(); + }); + + it('should handle empty results', () => { + vi.mocked(useExploreSearchFeed).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + } as unknown as ReturnType); + + render(); + expect(screen.getByTestId('tweet-search-list')).toBeInTheDocument(); + }); +}); diff --git a/src/features/explore/tests/Trend.test.tsx b/src/features/explore/tests/Trend.test.tsx new file mode 100644 index 00000000..aca5834a --- /dev/null +++ b/src/features/explore/tests/Trend.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +// Mock constants +vi.mock('../constants/tabs', () => ({ + TRENDING_TAB: 'general', +})); + +import Trend from '../components/Trend'; + +describe('Trend', () => { + const mockOnClick = vi.fn(); + const defaultProps = { + data: { + tag: '#trending', + totalPosts: 1500, + }, + indx: 1, + category: 'general', + onClick: mockOnClick, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the Trend component', () => { + render(); + expect(screen.getByText('#trending')).toBeInTheDocument(); + }); + + it('should display the trend tag', () => { + render(); + expect(screen.getByText('#trending')).toBeInTheDocument(); + }); + + it('should display the index', () => { + render(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('should display post count', () => { + render(); + expect(screen.getByText('1,500 posts')).toBeInTheDocument(); + }); + + it('should call onClick when clicked', () => { + render(); + const trendElement = screen.getByText('#trending').closest('div'); + fireEvent.click(trendElement!); + expect(mockOnClick).toHaveBeenCalled(); + }); + + it('should not display category when TRENDING_TAB', () => { + render(); + // When category is TRENDING_TAB, it should not show the category name + expect(screen.queryByText(' · general · Trending')).not.toBeInTheDocument(); + }); + + it('should display category when not TRENDING_TAB', () => { + render(); + // The text is split across elements, check for sports text + expect(screen.getByText(/sports/)).toBeInTheDocument(); + expect(screen.getByText(/Trending/)).toBeInTheDocument(); + }); + + it('should format numbers over 10,000 with K suffix', () => { + render( + + ); + expect(screen.getByText('5.0K posts')).toBeInTheDocument(); + }); + + it('should format numbers under 10,000 with commas', () => { + render( + + ); + expect(screen.getByText('5,000 posts')).toBeInTheDocument(); + }); + + it('should display different tags correctly', () => { + render( + + ); + expect(screen.getByText('#javascript')).toBeInTheDocument(); + expect(screen.getByText('100 posts')).toBeInTheDocument(); + }); + + it('should have hover styling', () => { + const { container } = render(); + const trendDiv = container.querySelector('.hover\\:bg-input-bg-hover\\/40'); + expect(trendDiv).toBeInTheDocument(); + }); + + it('should have cursor pointer', () => { + const { container } = render(); + const trendDiv = container.querySelector('.cursor-pointer'); + expect(trendDiv).toBeInTheDocument(); + }); + + it('should render with news category', () => { + render(); + expect(screen.getByText(/news/)).toBeInTheDocument(); + }); + + it('should render with entertainment category', () => { + render(); + expect(screen.getByText(/entertainment/)).toBeInTheDocument(); + }); + + it('should format large numbers correctly', () => { + render( + + ); + expect(screen.getByText('15.0K posts')).toBeInTheDocument(); + }); + + it('should render multiple indexes correctly', () => { + const { rerender } = render(); + expect(screen.getByText('1')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('5')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('10')).toBeInTheDocument(); + }); +}); diff --git a/src/features/explore/tests/Trendings.test.tsx b/src/features/explore/tests/Trendings.test.tsx new file mode 100644 index 00000000..e7abb77b --- /dev/null +++ b/src/features/explore/tests/Trendings.test.tsx @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockPush = vi.fn(); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), + usePathname: () => '/explore', +})); + +// Mock constants +vi.mock('../constants/tabs', () => ({ + FOR_YOU_TAB: 'personalized', + TRENDING_TAB: 'general', + TOP_TAB: 'top', + NEWS_TAB: 'news', + SPORTS_TAB: 'sports', + ENTERTAINMENT_TAB: 'entertainment', +})); + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useSelectedTab: vi.fn(() => 'general'), +})); + +// Mock the hook +vi.mock('../hooks/exploreQueries', () => ({ + useTrendingFeed: vi.fn(() => ({ + data: { + data: { + trending: [ + { tag: '#trending1', totalPosts: 1000 }, + { tag: '#trending2', totalPosts: 500 }, + ], + }, + metadata: { + HashtagsCount: 2, + category: 'general', + }, + }, + error: null, + isError: false, + isLoading: false, + })), +})); + +// Mock components +vi.mock('@/components/generic', () => ({ + Loader: () =>
Loading...
, +})); + +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn((message: string) => ( +
{message}
+ )), +})); + +import Trendings from '../components/Trendings'; +import { useTrendingFeed } from '../hooks/exploreQueries'; + +describe('Trendings', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useTrendingFeed).mockReturnValue({ + data: { + data: { + trending: [ + { tag: '#trending1', totalPosts: 1000 }, + { tag: '#trending2', totalPosts: 500 }, + ], + }, + metadata: { + HashtagsCount: 2, + category: 'general', + }, + }, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + }); + + it('should render the Trendings component with trends', () => { + render(); + expect( + screen.getByTestId('explore-feed-render-trending-list') + ).toBeInTheDocument(); + }); + + it('should render trend items when data is available', () => { + render(); + expect(screen.getByText('#trending1')).toBeInTheDocument(); + expect(screen.getByText('#trending2')).toBeInTheDocument(); + }); + + it('should display trend tags', () => { + render(); + expect(screen.getByText('#trending1')).toBeInTheDocument(); + expect(screen.getByText('#trending2')).toBeInTheDocument(); + }); + + it('should show loader when loading', () => { + vi.mocked(useTrendingFeed).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + } as ReturnType); + + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should show loading state with proper test id', () => { + vi.mocked(useTrendingFeed).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + } as ReturnType); + + render(); + expect( + screen.getByTestId('explore-feed-trending-list-loading') + ).toBeInTheDocument(); + }); + + it('should show error message when error occurs', () => { + vi.mocked(useTrendingFeed).mockReturnValue({ + data: undefined, + error: new Error('Failed to fetch'), + isError: true, + isLoading: false, + } as ReturnType); + + render(); + expect(screen.getByTestId('toaster-message')).toBeInTheDocument(); + }); + + it('should show no trends message when HashtagsCount is 0', () => { + vi.mocked(useTrendingFeed).mockReturnValue({ + data: { + data: { + trending: [], + }, + metadata: { + HashtagsCount: 0, + category: 'general', + }, + }, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + + render(); + expect( + screen.getByText('Trends are not available yet') + ).toBeInTheDocument(); + }); + + it('should navigate to search when trend is clicked', () => { + render(); + const trend = screen + .getByText('#trending1') + .closest('div[class*="cursor-pointer"]'); + if (trend) fireEvent.click(trend); + expect(mockPush).toHaveBeenCalledWith('/search?q=%23trending1'); + }); + + it('should navigate with different trend tags', () => { + render(); + const trend = screen + .getByText('#trending2') + .closest('div[class*="cursor-pointer"]'); + if (trend) fireEvent.click(trend); + expect(mockPush).toHaveBeenCalledWith('/search?q=%23trending2'); + }); + + it('should render many trends', () => { + vi.mocked(useTrendingFeed).mockReturnValue({ + data: { + data: { + trending: [ + { tag: '#trend1', totalPosts: 100 }, + { tag: '#trend2', totalPosts: 200 }, + { tag: '#trend3', totalPosts: 300 }, + { tag: '#trend4', totalPosts: 400 }, + { tag: '#trend5', totalPosts: 500 }, + ], + }, + metadata: { + HashtagsCount: 5, + category: 'general', + }, + }, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + + render(); + expect(screen.getByText('#trend1')).toBeInTheDocument(); + expect(screen.getByText('#trend5')).toBeInTheDocument(); + }); + + it('should handle undefined data gracefully', () => { + vi.mocked(useTrendingFeed).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + + render(); + // Should not crash when data is undefined + expect(screen.queryByText('#trending1')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/explore/tests/TweetsList.test.tsx b/src/features/explore/tests/TweetsList.test.tsx new file mode 100644 index 00000000..cd260613 --- /dev/null +++ b/src/features/explore/tests/TweetsList.test.tsx @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const mockPush = vi.fn(); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), + usePathname: () => '/explore', +})); + +// Mock constants +vi.mock('../constants/tabs', () => ({ + FOR_YOU_TAB: 'personalized', + TRENDING_TAB: 'general', + TOP_TAB: 'top', + NEWS_TAB: 'news', + SPORTS_TAB: 'sports', + ENTERTAINMENT_TAB: 'entertainment', +})); + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useSelectedTab: vi.fn(() => 'personalized'), +})); + +// Mock the hook +vi.mock('../hooks/exploreQueries', () => ({ + useExplorePosts: vi.fn(() => ({ + data: { + data: { + Technology: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + username: 'techuser', + name: 'Tech User', + text: 'Tech tweet', + }, + ], + Sports: [ + { + userId: 2, + postId: 2, + date: '2024-01-02', + username: 'sportuser', + name: 'Sport User', + text: 'Sports tweet', + }, + ], + }, + }, + error: null, + isError: false, + isLoading: false, + })), +})); + +// Mock components +vi.mock('@/components/generic/Loader', () => ({ + default: () =>
Loading...
, +})); + +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn((message: string) => ( +
{message}
+ )), +})); + +vi.mock('@/components/ui/home/Icon', () => ({ + default: () =>
Icon
, +})); + +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: { data: { text: string } }) => ( +
{data.text}
+ ), +})); + +import TweetsList from '../components/TweetsList'; +import { useExplorePosts } from '../hooks/exploreQueries'; + +describe('TweetsList', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useExplorePosts).mockReturnValue({ + data: { + data: { + Technology: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + username: 'techuser', + name: 'Tech User', + text: 'Tech tweet', + }, + ], + Sports: [ + { + userId: 2, + postId: 2, + date: '2024-01-02', + username: 'sportuser', + name: 'Sport User', + text: 'Sports tweet', + }, + ], + }, + }, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + }); + + it('should render the TweetsList component with tweets', () => { + render(); + expect( + screen.getByTestId('explore-feed-render-tweet-list') + ).toBeInTheDocument(); + }); + + it('should render category headers', () => { + render(); + expect(screen.getByText('Technology')).toBeInTheDocument(); + expect(screen.getByText('Sports')).toBeInTheDocument(); + }); + + it('should render tweets for each category', () => { + render(); + expect(screen.getByText('Tech tweet')).toBeInTheDocument(); + expect(screen.getByText('Sports tweet')).toBeInTheDocument(); + }); + + it('should show loader when loading', () => { + vi.mocked(useExplorePosts).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + } as unknown as ReturnType); + + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should show loading state with proper test id', () => { + vi.mocked(useExplorePosts).mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + } as unknown as ReturnType); + + render(); + expect( + screen.getByTestId('explore-feed-tweet-list-loading') + ).toBeInTheDocument(); + }); + + it('should show error message when error occurs', () => { + vi.mocked(useExplorePosts).mockReturnValue({ + data: { + data: { + Technology: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Tech tweet', + }, + ], + }, + }, + error: new Error('Failed to fetch'), + isError: true, + isLoading: false, + } as unknown as ReturnType); + + render(); + expect(screen.getByTestId('toaster-message')).toBeInTheDocument(); + }); + + it('should show no posts message when data is null', () => { + vi.mocked(useExplorePosts).mockReturnValue({ + data: null, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + + render(); + expect( + screen.getByText('No Posts available Right Now') + ).toBeInTheDocument(); + }); + + it('should navigate to interest page when category is clicked', () => { + render(); + const techCategory = screen + .getByText('Technology') + .closest('div[class*="cursor-pointer"]'); + if (techCategory) fireEvent.click(techCategory); + expect(mockPush).toHaveBeenCalledWith('/interests/Technology'); + }); + + it('should navigate to different interest pages', () => { + render(); + const sportsCategory = screen + .getByText('Sports') + .closest('div[class*="cursor-pointer"]'); + if (sportsCategory) fireEvent.click(sportsCategory); + expect(mockPush).toHaveBeenCalledWith('/interests/Sports'); + }); + + it('should render multiple tweets per category', () => { + vi.mocked(useExplorePosts).mockReturnValue({ + data: { + data: { + Technology: [ + { userId: 1, postId: 1, date: '2024-01-01', text: 'Tech tweet 1' }, + { userId: 2, postId: 2, date: '2024-01-02', text: 'Tech tweet 2' }, + { userId: 3, postId: 3, date: '2024-01-03', text: 'Tech tweet 3' }, + ], + }, + }, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + + render(); + expect(screen.getByText('Tech tweet 1')).toBeInTheDocument(); + expect(screen.getByText('Tech tweet 2')).toBeInTheDocument(); + expect(screen.getByText('Tech tweet 3')).toBeInTheDocument(); + }); + + it('should render icon in category header', () => { + render(); + expect(screen.getAllByTestId('icon').length).toBeGreaterThan(0); + }); + + it('should handle empty categories', () => { + vi.mocked(useExplorePosts).mockReturnValue({ + data: { + data: {}, + }, + error: null, + isError: false, + isLoading: false, + } as unknown as ReturnType); + + render(); + expect( + screen.getByTestId('explore-feed-render-tweet-list') + ).toBeInTheDocument(); + }); + + it('should have hover styling on category headers', () => { + render(); + expect(screen.getByText('Technology')).toBeInTheDocument(); + }); + + it('should have cursor pointer on category headers', () => { + render(); + const techCategory = screen + .getByText('Technology') + .closest('div[class*="cursor-pointer"]'); + expect(techCategory).toBeInTheDocument(); + }); +}); diff --git a/src/features/explore/tests/exploreQueries.test.tsx b/src/features/explore/tests/exploreQueries.test.tsx new file mode 100644 index 00000000..21a01510 --- /dev/null +++ b/src/features/explore/tests/exploreQueries.test.tsx @@ -0,0 +1,422 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +// Mock the store +vi.mock('../store/useExploreStore', () => ({ + useSelectedSearchTab: vi.fn(() => 'top'), + useSearch: vi.fn(() => 'test query'), + useSearchDate: vi.fn(() => ''), + useSelectedTab: vi.fn(() => 'personalized'), + useSelectedInterestTab: vi.fn(() => 'top'), +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/explore'), +})); + +// Mock the API +vi.mock('../services/exploreApi', () => ({ + exploreApi: { + getSearchFeed: vi.fn(() => + Promise.resolve({ + status: 'success', + message: 'OK', + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Test tweet', + }, + ], + }, + metadata: { + totalItems: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }) + ), + getForYouFeed: vi.fn(() => + Promise.resolve({ + status: 'success', + message: 'OK', + data: { + Technology: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Tech tweet', + }, + ], + }, + }) + ), + getTrendingFeed: vi.fn(() => + Promise.resolve({ + status: 'success', + data: { + trending: [{ tag: '#test', totalPosts: 100 }], + }, + metadata: { + HashtagsCount: 1, + limit: 10, + category: 'general', + }, + }) + ), + getInterestFeed: vi.fn(() => + Promise.resolve({ + status: 'success', + message: 'OK', + data: { + posts: [ + { + userId: 1, + postId: 1, + date: '2024-01-01', + text: 'Interest tweet', + }, + ], + }, + }) + ), + }, +})); + +// Mock constants +vi.mock('../constants/tabs', () => ({ + FOR_YOU_TAB: 'personalized', + TRENDING_TAB: 'general', + TOP_TAB: 'top', + NEWS_TAB: 'news', + SPORTS_TAB: 'sports', + ENTERTAINMENT_TAB: 'entertainment', +})); + +vi.mock('../constants/api', () => ({ + EXPLORE_ENDPOINTS: { + EXPLORE_FEED_SEARCH_HASHTAG: '/api/v1.0/posts/search/hashtag', + EXPLORE_FEED_SEARCH_TWEETS: '/api/v1.0/posts/search', + EXPLORE_FEED_FOR_YOU: '/api/v1.0/posts/explore/for-you', + EXPLORE_FEED_INTEREST: '/api/v1.0/posts/timeline/explore/interests', + EXPLORE_FEED_TRENDING: '/api/v1.0/hashtags/trending', + }, +})); + +import { + useExploreSearchFeed, + useExplorePosts, + useTrendingFeed, + useExploreInterest, + EXPLORE_QUERY_KEYS, +} from '../hooks/exploreQueries'; +import { + useSelectedSearchTab, + useSearch, + useSelectedTab, +} from '../store/useExploreStore'; +import { usePathname } from 'next/navigation'; +import { exploreApi } from '../services/exploreApi'; + +// Create wrapper for hooks +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Infinity, + }, + }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'QueryWrapper'; + return Wrapper; +}; + +describe('exploreQueries', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('EXPLORE_QUERY_KEYS', () => { + it('should generate correct search top key', () => { + const key = EXPLORE_QUERY_KEYS.EXPLORE_FEED_SEARCH_TOP('test'); + expect(key).toEqual(['explore', 'top', 'test']); + }); + + it('should generate correct search latest key', () => { + const key = EXPLORE_QUERY_KEYS.EXPLORE_FEED_SEARCH_LATEST('test'); + expect(key).toEqual(['explore', 'latest', 'test']); + }); + + it('should have correct for you key', () => { + expect(EXPLORE_QUERY_KEYS.EXPLORE_FEED_FOR_YOU).toEqual([ + 'explore', + 'forYou', + ]); + }); + + it('should generate correct interest key', () => { + const key = EXPLORE_QUERY_KEYS.EXPLORE_FEED_INTEREST('tech', 'top'); + expect(key).toEqual(['explore', 'interest', 'tech', 'top']); + }); + + it('should have correct trending for you key', () => { + expect(EXPLORE_QUERY_KEYS.EXPLORE_TRENDS_FOR_YOU).toEqual([ + 'explore', + 'trends', + 'forYou', + ]); + }); + + it('should have correct trending key', () => { + expect(EXPLORE_QUERY_KEYS.EXPLORE_TRENDS_TRENDING).toEqual([ + 'explore', + 'trends', + 'trending', + ]); + }); + + it('should have correct sports key', () => { + expect(EXPLORE_QUERY_KEYS.EXPLORE_TRENDS_SPORTS).toEqual([ + 'explore', + 'trends', + 'sports', + ]); + }); + + it('should have correct news key', () => { + expect(EXPLORE_QUERY_KEYS.EXPLORE_TRENDS_NEWS).toEqual([ + 'explore', + 'trends', + 'news', + ]); + }); + + it('should have correct entertainment key', () => { + expect(EXPLORE_QUERY_KEYS.EXPLORE_TRENDS_ENTERTAINMENT).toEqual([ + 'explore', + 'trends', + 'entertainment', + ]); + }); + }); + + describe('useExploreSearchFeed', () => { + it('should return initial state', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useExploreSearchFeed(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading || result.current.data).toBeTruthy(); + }); + }); + + it('should call API with correct parameters for search query', async () => { + const wrapper = createWrapper(); + renderHook(() => useExploreSearchFeed(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getSearchFeed).toHaveBeenCalled(); + }); + }); + + it('should not fetch when search is empty', async () => { + vi.mocked(useSearch).mockReturnValue(''); + const wrapper = createWrapper(); + const { result } = renderHook(() => useExploreSearchFeed(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should detect hashtag search', async () => { + vi.mocked(useSearch).mockReturnValue('#hashtag'); + const wrapper = createWrapper(); + renderHook(() => useExploreSearchFeed(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getSearchFeed).toHaveBeenCalled(); + }); + }); + + it('should use latest tab query key', async () => { + vi.mocked(useSelectedSearchTab).mockReturnValue('latest'); + vi.mocked(useSearch).mockReturnValue('test'); + const wrapper = createWrapper(); + const { result } = renderHook(() => useExploreSearchFeed(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading || result.current.data).toBeTruthy(); + }); + }); + }); + + describe('useExplorePosts', () => { + it('should return initial state', async () => { + vi.mocked(useSelectedTab).mockReturnValue('personalized'); + const wrapper = createWrapper(); + const { result } = renderHook(() => useExplorePosts(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading || result.current.data).toBeTruthy(); + }); + }); + + it('should call getForYouFeed API', async () => { + vi.mocked(useSelectedTab).mockReturnValue('personalized'); + const wrapper = createWrapper(); + renderHook(() => useExplorePosts(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getForYouFeed).toHaveBeenCalled(); + }); + }); + + it('should not fetch when not on FOR_YOU_TAB', async () => { + vi.mocked(useSelectedTab).mockReturnValue('general'); + const wrapper = createWrapper(); + const { result } = renderHook(() => useExplorePosts(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + }); + + describe('useTrendingFeed', () => { + it('should return initial state', async () => { + vi.mocked(useSelectedTab).mockReturnValue('general'); + vi.mocked(usePathname).mockReturnValue('/explore'); + const wrapper = createWrapper(); + const { result } = renderHook(() => useTrendingFeed(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading || result.current.data).toBeTruthy(); + }); + }); + + it('should call getTrendingFeed API', async () => { + vi.mocked(useSelectedTab).mockReturnValue('general'); + vi.mocked(usePathname).mockReturnValue('/explore'); + const wrapper = createWrapper(); + renderHook(() => useTrendingFeed(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getTrendingFeed).toHaveBeenCalled(); + }); + }); + + it('should not fetch when not on explore path', async () => { + vi.mocked(usePathname).mockReturnValue('/home'); + const wrapper = createWrapper(); + const { result } = renderHook(() => useTrendingFeed(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should use sports query key for sports tab', async () => { + vi.mocked(useSelectedTab).mockReturnValue('sports'); + vi.mocked(usePathname).mockReturnValue('/explore'); + const wrapper = createWrapper(); + renderHook(() => useTrendingFeed(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getTrendingFeed).toHaveBeenCalledWith('sports', 30); + }); + }); + + it('should use news query key for news tab', async () => { + vi.mocked(useSelectedTab).mockReturnValue('news'); + vi.mocked(usePathname).mockReturnValue('/explore'); + const wrapper = createWrapper(); + renderHook(() => useTrendingFeed(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getTrendingFeed).toHaveBeenCalledWith('news', 30); + }); + }); + + it('should use entertainment query key for entertainment tab', async () => { + vi.mocked(useSelectedTab).mockReturnValue('entertainment'); + vi.mocked(usePathname).mockReturnValue('/explore'); + const wrapper = createWrapper(); + renderHook(() => useTrendingFeed(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getTrendingFeed).toHaveBeenCalledWith( + 'entertainment', + 30 + ); + }); + }); + + it('should use for you limit for FOR_YOU_TAB', async () => { + vi.mocked(useSelectedTab).mockReturnValue('personalized'); + vi.mocked(usePathname).mockReturnValue('/explore'); + const wrapper = createWrapper(); + renderHook(() => useTrendingFeed(), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getTrendingFeed).toHaveBeenCalledWith( + 'personalized', + 5 + ); + }); + }); + }); + + describe('useExploreInterest', () => { + it('should return initial state', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useExploreInterest('Technology'), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isLoading || result.current.data).toBeTruthy(); + }); + }); + + it('should call getInterestFeed API', async () => { + const wrapper = createWrapper(); + renderHook(() => useExploreInterest('Technology'), { wrapper }); + + await waitFor(() => { + expect(exploreApi.getInterestFeed).toHaveBeenCalled(); + }); + }); + + it('should not fetch when interest is empty', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useExploreInterest(''), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should not fetch when interest is whitespace', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useExploreInterest(' '), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + }); +}); diff --git a/src/features/layout/tests/WhatIsHappening.test.tsx b/src/features/layout/tests/WhatIsHappening.test.tsx index 0e692abc..9d554012 100644 --- a/src/features/layout/tests/WhatIsHappening.test.tsx +++ b/src/features/layout/tests/WhatIsHappening.test.tsx @@ -1,49 +1,289 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@/test/test-utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@/test/test-utils'; import WhatIsHappening from '../components/WhatIsHappening'; -import { useRouter } from 'next/navigation'; + +const mockPush = vi.fn(); vi.mock('next/navigation', () => ({ - useRouter: vi.fn(), + useRouter: vi.fn(() => ({ + push: mockPush, + replace: vi.fn(), + prefetch: vi.fn(), + })), })); -vi.mock('@/features/explore/hooks/useTrendingHashtags', () => ({ - useTrendingHashtags: vi.fn(() => ({ - data: { data: { trendingHashtags: [] } }, - isLoading: false, - })), +const mockUseTrendingHashtags = vi.fn(); + +vi.mock('../hooks/useTrendingHashtags', () => ({ + useTrendingHashtags: () => mockUseTrendingHashtags(), })); describe('WhatIsHappening', () => { beforeEach(() => { - vi.mocked(useRouter).mockReturnValue({ - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - } as any); + vi.clearAllMocks(); + mockUseTrendingHashtags.mockReturnValue({ + data: { data: { trending: [] }, metadata: { category: 'Trending' } }, + isLoading: false, + }); }); + it('should render "What\'s happening" heading', () => { render(); - expect(screen.getByText(/what's happening/i)).toBeInTheDocument(); }); it('should render without errors', () => { const { container } = render(); - expect(container.firstChild).toBeInTheDocument(); }); it('should have proper container styling', () => { const { container } = render(); const mainContainer = container.firstChild; - expect(mainContainer).toHaveClass('rounded-2xl'); }); it('should render component without crashing', () => { const { container } = render(); - expect(container).toBeInTheDocument(); }); + + it('should render loading state when isLoading is true', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: undefined, + isLoading: true, + }); + render(); + expect(screen.getByText(/what's happening/i)).toBeInTheDocument(); + // XLoader should be rendered + expect(screen.queryByText(/show more/i)).not.toBeInTheDocument(); + }); + + it('should render trending hashtags', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [ + { tag: '#React', totalPosts: 1000 }, + { tag: 'JavaScript', totalPosts: 500 }, + ], + }, + metadata: { category: 'Technology' }, + }, + isLoading: false, + }); + render(); + expect(screen.getByText('#React')).toBeInTheDocument(); + expect(screen.getByText('#JavaScript')).toBeInTheDocument(); + expect(screen.getByText('1,000 posts')).toBeInTheDocument(); + expect(screen.getByText('500 posts')).toBeInTheDocument(); + }); + + it('should render category from metadata', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#Test', totalPosts: 100 }], + }, + metadata: { category: 'Sports' }, + }, + isLoading: false, + }); + render(); + expect(screen.getByText('Sports')).toBeInTheDocument(); + }); + + it('should render "Show more" link', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { data: { trending: [] }, metadata: {} }, + isLoading: false, + }); + render(); + const showMoreLink = screen.getByText(/show more/i); + expect(showMoreLink).toBeInTheDocument(); + expect(showMoreLink).toHaveAttribute('href', '/explore/tabs/general'); + }); + + it('should navigate to search when trend is clicked', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#TestTag', totalPosts: 50 }], + }, + metadata: { category: 'Trending' }, + }, + isLoading: false, + }); + render(); + const trendItem = screen.getByText('#TestTag').closest('div'); + fireEvent.click(trendItem!); + expect(mockPush).toHaveBeenCalledWith('/search?q=%23TestTag'); + }); + + it('should navigate with raw tag without hashtag prefix', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: 'RawTag', totalPosts: 30 }], + }, + metadata: { category: 'Trending' }, + }, + isLoading: false, + }); + render(); + const trendItem = screen.getByText('#RawTag').closest('div'); + fireEvent.click(trendItem!); + expect(mockPush).toHaveBeenCalledWith('/search?q=RawTag'); + }); + + it('should handle empty trending data', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { data: { trending: [] }, metadata: {} }, + isLoading: false, + }); + render(); + expect(screen.getByText(/what's happening/i)).toBeInTheDocument(); + expect(screen.getByText(/show more/i)).toBeInTheDocument(); + }); + + it('should handle undefined data response', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: undefined, + isLoading: false, + }); + render(); + expect(screen.getByText(/what's happening/i)).toBeInTheDocument(); + }); + + it('should handle null trending array', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { data: { trending: null }, metadata: {} }, + isLoading: false, + }); + render(); + expect(screen.getByText(/show more/i)).toBeInTheDocument(); + }); + + it('should use default category when metadata is empty', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#Default', totalPosts: 10 }], + }, + metadata: {}, + }, + isLoading: false, + }); + render(); + expect(screen.getByText('Trending')).toBeInTheDocument(); + }); + + it('should display multiple trending hashtags', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [ + { tag: '#Tag1', totalPosts: 100 }, + { tag: '#Tag2', totalPosts: 200 }, + { tag: '#Tag3', totalPosts: 300 }, + ], + }, + metadata: { category: 'Trending' }, + }, + isLoading: false, + }); + render(); + expect(screen.getByText('#Tag1')).toBeInTheDocument(); + expect(screen.getByText('#Tag2')).toBeInTheDocument(); + expect(screen.getByText('#Tag3')).toBeInTheDocument(); + }); + + it('should format large post counts with locale string', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#Popular', totalPosts: 1234567 }], + }, + metadata: { category: 'Trending' }, + }, + isLoading: false, + }); + render(); + expect(screen.getByText('1,234,567 posts')).toBeInTheDocument(); + }); + + it('should apply hover styles on trend item', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#Hover', totalPosts: 10 }], + }, + metadata: { category: 'Trending' }, + }, + isLoading: false, + }); + render(); + const trendItem = screen.getByText('#Hover').closest('div'); + expect(trendItem).toHaveClass('hover:bg-[#1D1F23]'); + expect(trendItem).toHaveClass('cursor-pointer'); + }); + + it('should render posts count text', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#Count', totalPosts: 42 }], + }, + metadata: { category: 'Trending' }, + }, + isLoading: false, + }); + render(); + expect(screen.getByText('42 posts')).toBeInTheDocument(); + }); + + it('should handle hashtag with special characters in URL', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#Test&Special', totalPosts: 10 }], + }, + metadata: { category: 'Trending' }, + }, + isLoading: false, + }); + render(); + const trendItem = screen.getByText('#Test&Special').closest('div'); + fireEvent.click(trendItem!); + expect(mockPush).toHaveBeenCalledWith('/search?q=%23Test%26Special'); + }); + + it('should handle undefined metadata', () => { + mockUseTrendingHashtags.mockReturnValue({ + data: { + data: { + trending: [{ tag: '#NoMeta', totalPosts: 5 }], + }, + metadata: undefined, + }, + isLoading: false, + }); + render(); + expect(screen.getByText('Trending')).toBeInTheDocument(); + }); + + it('should have border styling on container', () => { + const { container } = render(); + const mainContainer = container.firstChild; + expect(mainContainer).toHaveClass('border'); + expect(mainContainer).toHaveClass('border-gray-700'); + }); + + it('should have proper heading styling', () => { + render(); + const heading = screen.getByText(/what's happening/i); + expect(heading).toHaveClass('text-xl'); + expect(heading).toHaveClass('font-bold'); + expect(heading).toHaveClass('text-white'); + }); }); diff --git a/src/features/layout/tests/WhoToFollow.test.tsx b/src/features/layout/tests/WhoToFollow.test.tsx index 4f5f9c5d..928ac03d 100644 --- a/src/features/layout/tests/WhoToFollow.test.tsx +++ b/src/features/layout/tests/WhoToFollow.test.tsx @@ -1,37 +1,374 @@ /* eslint-disable @next/next/no-img-element */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@/test/test-utils'; import WhoToFollow from '../components/WhoToFollow'; vi.mock('@/components/generic/Avatar', () => ({ - default: ({ src }: any) => avatar, + default: ({ src }: { src: string }) => avatar, +})); + +const mockUseSuggestedUsers = vi.fn(); + +vi.mock('../hooks/useSuggestedUsers', () => ({ + useSuggestedUsers: () => mockUseSuggestedUsers(), +})); + +vi.mock('@/components/ui/UserCard', () => ({ + default: ({ + name, + handle, + verified, + actionType, + }: { + name: string; + handle: string; + verified: boolean; + actionType: string; + }) => ( +
+ {name} + {handle} + {verified && Verified} + +
+ ), })); describe('WhoToFollow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseSuggestedUsers.mockReturnValue({ + data: { data: { users: [] } }, + isLoading: false, + }); + }); + it('should render "Who to follow" heading', () => { render(); + expect(screen.getByText(/who to follow/i)).toBeInTheDocument(); + }); + + it('should render without errors', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('should have proper container styling', () => { + const { container } = render(); + const mainContainer = container.firstChild; + expect(mainContainer).toHaveClass('rounded-2xl'); + }); + it('should render loading state when isLoading is true', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: undefined, + isLoading: true, + }); + render(); expect(screen.getByText(/who to follow/i)).toBeInTheDocument(); + // Should not show empty message or users during loading + expect( + screen.queryByText(/no suggestions available/i) + ).not.toBeInTheDocument(); }); - it('should render follow suggestions', () => { + it('should render suggested users', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'testuser', + isVerified: false, + is_followed_by_me: false, + profile: { + name: 'Test User', + bio: 'Test bio', + profileImageUrl: 'https://example.com/avatar.jpg', + }, + }, + ], + }, + }, + isLoading: false, + }); render(); + expect(screen.getByTestId('user-name')).toHaveTextContent('Test User'); + expect(screen.getByTestId('user-handle')).toHaveTextContent('@testuser'); + }); - const followButtons = screen.getAllByText(/follow/i); - expect(followButtons.length).toBeGreaterThan(0); + it('should render multiple suggested users', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'user1', + isVerified: false, + is_followed_by_me: false, + profile: { + name: 'User One', + bio: 'Bio 1', + profileImageUrl: null, + }, + }, + { + id: 2, + username: 'user2', + isVerified: true, + is_followed_by_me: false, + profile: { + name: 'User Two', + bio: null, + profileImageUrl: 'https://example.com/avatar2.jpg', + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + const userCards = screen.getAllByTestId('user-card'); + expect(userCards).toHaveLength(2); }); - it('should render loading state or user profiles', () => { - const { container } = render(); + it('should render empty state when no suggestions', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { data: { users: [] } }, + isLoading: false, + }); + render(); + expect(screen.getByText(/no suggestions available/i)).toBeInTheDocument(); + }); - // Should render either loading state or profiles - expect(container).toBeInTheDocument(); + it('should handle undefined data response', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: undefined, + isLoading: false, + }); + render(); + expect(screen.getByText(/no suggestions available/i)).toBeInTheDocument(); }); - it('should have proper container styling', () => { + it('should handle null users array', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { data: { users: null } }, + isLoading: false, + }); + render(); + expect(screen.getByText(/no suggestions available/i)).toBeInTheDocument(); + }); + + it('should render verified badge for verified users', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'verifieduser', + isVerified: true, + is_followed_by_me: false, + profile: { + name: 'Verified User', + bio: 'Verified bio', + profileImageUrl: null, + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + expect(screen.getByTestId('verified-badge')).toBeInTheDocument(); + }); + + it('should not render verified badge for non-verified users', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'normaluser', + isVerified: false, + is_followed_by_me: false, + profile: { + name: 'Normal User', + bio: 'Normal bio', + profileImageUrl: null, + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument(); + }); + + it('should render follow action type button', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'followuser', + isVerified: false, + is_followed_by_me: false, + profile: { + name: 'Follow User', + bio: 'Follow bio', + profileImageUrl: null, + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + expect(screen.getByTestId('follow-button')).toBeInTheDocument(); + }); + + it('should handle user with null profileImageUrl', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'noavatar', + isVerified: false, + is_followed_by_me: false, + profile: { + name: 'No Avatar User', + bio: 'No avatar bio', + profileImageUrl: null, + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + expect(screen.getByTestId('user-name')).toHaveTextContent('No Avatar User'); + }); + + it('should handle user with null bio', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'nobio', + isVerified: false, + is_followed_by_me: false, + profile: { + name: 'No Bio User', + bio: null, + profileImageUrl: 'https://example.com/avatar.jpg', + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + expect(screen.getByTestId('user-name')).toHaveTextContent('No Bio User'); + }); + + it('should have border styling on container', () => { const { container } = render(); const mainContainer = container.firstChild; + expect(mainContainer).toHaveClass('border'); + expect(mainContainer).toHaveClass('border-gray-700'); + }); - expect(mainContainer).toHaveClass('rounded-2xl'); + it('should have proper heading styling', () => { + render(); + const heading = screen.getByText(/who to follow/i); + expect(heading).toHaveClass('text-xl'); + expect(heading).toHaveClass('font-bold'); + expect(heading).toHaveClass('text-white'); + }); + + it('should handle followed users correctly', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'followeduser', + isVerified: false, + is_followed_by_me: true, + profile: { + name: 'Followed User', + bio: 'Already followed', + profileImageUrl: null, + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + expect(screen.getByTestId('user-name')).toHaveTextContent('Followed User'); + }); + + it('should render user handle with @ prefix', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { + data: { + users: [ + { + id: 1, + username: 'handletest', + isVerified: false, + is_followed_by_me: false, + profile: { + name: 'Handle Test', + bio: 'Testing handle', + profileImageUrl: null, + }, + }, + ], + }, + }, + isLoading: false, + }); + render(); + expect(screen.getByTestId('user-handle')).toHaveTextContent('@handletest'); + }); + + it('should handle undefined data.data property', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { data: undefined }, + isLoading: false, + }); + render(); + expect(screen.getByText(/no suggestions available/i)).toBeInTheDocument(); + }); + + it('should render empty state styling correctly', () => { + mockUseSuggestedUsers.mockReturnValue({ + data: { data: { users: [] } }, + isLoading: false, + }); + render(); + const emptyState = screen.getByText(/no suggestions available/i); + expect(emptyState).toHaveClass('text-sm'); + expect(emptyState).toHaveClass('text-center'); }); }); diff --git a/src/features/media/components/Emoji.tsx b/src/features/media/components/Emoji.tsx index 2c113607..d46b0766 100644 --- a/src/features/media/components/Emoji.tsx +++ b/src/features/media/components/Emoji.tsx @@ -19,7 +19,6 @@ export default function Emoji() { const { setEmoji } = selectors.useActions(); function hanldePickEmoji(emojiData: EmojiClickData) { - console.log(emojiData.emoji); setEmoji(emojiData.emoji); } return ( diff --git a/src/features/media/components/GifModal.tsx b/src/features/media/components/GifModal.tsx index 7a47c526..4914af74 100644 --- a/src/features/media/components/GifModal.tsx +++ b/src/features/media/components/GifModal.tsx @@ -45,7 +45,6 @@ export default function GifModal() { isFetchingNextPage, hasNextPage, } = useSearchGif(); - console.log(searchGif, hasNextPage); const pages = searchGif?.pages; const renderSearchedGifs = pages?.map((group, i) => ( diff --git a/src/features/media/components/MediaItem.tsx b/src/features/media/components/MediaItem.tsx index ba2dc1ff..751b9e9c 100644 --- a/src/features/media/components/MediaItem.tsx +++ b/src/features/media/components/MediaItem.tsx @@ -15,7 +15,6 @@ export default function MediaItem({ const selectors = useAddPostContext(); const { removeMedia } = selectors.useActions(); - console.log(media); return (
diff --git a/src/features/media/hooks/mediaQueries.ts b/src/features/media/hooks/mediaQueries.ts index d563c9f2..7ed7928c 100644 --- a/src/features/media/hooks/mediaQueries.ts +++ b/src/features/media/hooks/mediaQueries.ts @@ -41,23 +41,16 @@ export const useSearchGif = () => { >({ queryKey: GIF_QUERY_KEYS.SEARCH_GIF(search), queryFn: ({ pageParam }) => { - console.log('Fetching page:', pageParam); return gifApi.searchGif(search, pageParam, 20); }, initialPageParam: 0, enabled: !!search, getNextPageParam: (lastPage, pages) => { const { offset, count, total_count } = lastPage.pagination; - console.log('Pagination info:', { - offset, - count, - total_count, - pagesLength: pages.length, - }); + // Check if there are more results to fetch const hasMore = offset + count < total_count; const nextPage = hasMore ? pages.length : undefined; - console.log('Next page:', nextPage); return nextPage; }, }); diff --git a/src/features/media/services/gifAPi.ts b/src/features/media/services/gifAPi.ts index 5f5f8845..0ae856ec 100644 --- a/src/features/media/services/gifAPi.ts +++ b/src/features/media/services/gifAPi.ts @@ -13,7 +13,6 @@ class ApiError extends Error { } async function handleResponse(response: Response): Promise { - console.log(response); if (!response.ok) { let errorMessage = 'An error occurred'; const statusCode = response.status; @@ -63,7 +62,7 @@ export const gifApi = { ); // Wait for all promises to resolve const results = await Promise.all(promises); - console.log(results); + return results.map((res) => res.data[0]); }, async searchGif( diff --git a/src/features/profile/tests/RepliesList.test.tsx b/src/features/profile/tests/RepliesList.test.tsx new file mode 100644 index 00000000..72932ca3 --- /dev/null +++ b/src/features/profile/tests/RepliesList.test.tsx @@ -0,0 +1,830 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +// Mock hooks +const mockUseProfileFeed = vi.fn(); +const mockUseProfileContext = vi.fn(); +const mockUseAuthStore = vi.fn(); + +vi.mock('../hooks/profileQueries', () => ({ + useProfileFeed: () => mockUseProfileFeed(), +})); + +vi.mock('@/app/[username]/ProfileProvider', () => ({ + useProfileContext: () => mockUseProfileContext(), +})); + +vi.mock('@/features/authentication/store/authStore', () => ({ + useAuthStore: (selector: any) => mockUseAuthStore(selector), +})); + +// Mock components +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: any) => ( +
{data?.text || 'Tweet'}
+ ), +})); + +vi.mock('@/components/generic/Loader', () => ({ + default: () =>
Loading...
, +})); + +vi.mock('@/components/ui/home/InfiniteScroll', () => ({ + default: ({ + children, + isLoadingMore, + hasInitialData, + 'data-testid': dataTestId, + }: { + children: React.ReactNode; + isLoadingMore: boolean; + hasInitialData: boolean; + 'data-testid'?: string; + }) => ( +
+ {children} + {isLoadingMore &&
Loading more...
} + {!hasInitialData && ( +
No initial data
+ )} +
+ ), +})); + +vi.mock('@/features/timeline/components/Reply', () => ({ + default: ({ data }: any) => ( +
+ {data?.text || 'Reply'} +
+ ), +})); + +import RepliesList from '../components/RepliesList'; + +describe('RepliesList', () => { + const mockUser = { + id: 1, + username: 'testuser', + profile: {}, + }; + + const mockProfile = { + User: { + id: 1, + username: 'testuser', + }, + }; + + const mockRepliesData = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Reply 1', + date: '2024-01-01', + isRepost: false, + isQuote: false, + }, + { + postId: 2, + userId: 1, + text: 'Reply 2', + date: '2024-01-02', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + console.log = vi.fn(); + + // Default mock implementations + mockUseProfileContext.mockReturnValue({ + username: 'testuser', + profile: mockProfile, + }); + + mockUseAuthStore.mockImplementation((selector) => { + const state = { user: mockUser }; + return selector(state); + }); + + mockUseProfileFeed.mockReturnValue({ + data: mockRepliesData, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + }); + + describe('Loading State', () => { + it('should display loader when loading', () => { + mockUseProfileFeed.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.getByTestId('profile-tweets-loading')).toBeInTheDocument(); + }); + + it('should have correct loading container styles', () => { + mockUseProfileFeed.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + const container = screen.getByTestId('profile-tweets-loading'); + expect(container).toHaveClass('flex', 'justify-center', 'items-center'); + }); + }); + + describe('Error State', () => { + it('should display error message when error occurs', () => { + const errorMessage = 'Failed to fetch replies'; + mockUseProfileFeed.mockReturnValue({ + data: undefined, + error: new Error(errorMessage), + isError: true, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('profile-replies-error')).toBeInTheDocument(); + expect(screen.getByText(`Error ${errorMessage}`)).toBeInTheDocument(); + }); + + it('should show error with different error messages', () => { + mockUseProfileFeed.mockReturnValue({ + data: undefined, + error: new Error('Network error'), + isError: true, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByText('Error Network error')).toBeInTheDocument(); + }); + }); + + describe('Success State with Data', () => { + it('should render replies list when data is available', () => { + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + + it('should render all replies from the data', () => { + render(); + + const replies = screen.getAllByTestId('reply'); + expect(replies).toHaveLength(2); + }); + + it('should render replies with correct content', () => { + render(); + + expect(screen.getByText('Reply 1')).toBeInTheDocument(); + expect(screen.getByText('Reply 2')).toBeInTheDocument(); + }); + + it('should pass correct data to Reply components', () => { + render(); + + const replies = screen.getAllByTestId('reply'); + expect(replies[0]).toHaveAttribute('data-post-id', '1'); + expect(replies[1]).toHaveAttribute('data-post-id', '2'); + }); + + it('should render flex container for tweets', () => { + render(); + + const container = screen + .getByTestId('profile-replies-list') + .querySelector('.flex.flex-col.w-full'); + expect(container).toBeInTheDocument(); + }); + }); + + describe('Empty State', () => { + it('should handle empty replies array', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + expect(screen.queryByTestId('reply')).not.toBeInTheDocument(); + }); + + it('should show no initial data message when posts array is empty', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('no-initial-data')).toBeInTheDocument(); + }); + }); + + describe('Infinite Scroll', () => { + it('should pass correct props to InfiniteScroll component', () => { + const mockFetchNextPage = vi.fn(); + mockUseProfileFeed.mockReturnValue({ + data: mockRepliesData, + error: null, + isError: false, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: true, + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + + it('should show loading more indicator when fetching next page', () => { + mockUseProfileFeed.mockReturnValue({ + data: mockRepliesData, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: true, + hasNextPage: true, + }); + + render(); + + expect(screen.getByTestId('loading-more')).toBeInTheDocument(); + }); + + it('should handle multiple pages of data', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Reply 1', + date: '2024-01-01', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + { + data: { + posts: [ + { + postId: 2, + userId: 1, + text: 'Reply 2', + date: '2024-01-02', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + const replies = screen.getAllByTestId('reply'); + expect(replies).toHaveLength(2); + }); + + it('should not show loading more when not fetching next page', () => { + mockUseProfileFeed.mockReturnValue({ + data: mockRepliesData, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: true, + }); + + render(); + + expect(screen.queryByTestId('loading-more')).not.toBeInTheDocument(); + }); + }); + + describe('User Context', () => { + it('should handle when current user is viewing their own profile', () => { + mockUseAuthStore.mockImplementation((selector) => { + const state = { user: mockUser }; + return selector(state); + }); + + mockUseProfileContext.mockReturnValue({ + username: 'testuser', + profile: mockProfile, + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + + it('should handle when viewing another user profile', () => { + mockUseAuthStore.mockImplementation((selector) => { + const state = { + user: { + id: 2, + username: 'anotheruser', + }, + }; + return selector(state); + }); + + mockUseProfileContext.mockReturnValue({ + username: 'testuser', + profile: mockProfile, + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + + it('should handle when user is not authenticated', () => { + mockUseAuthStore.mockImplementation((selector) => { + const state = { user: null }; + return selector(state); + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + }); + + describe('Reply Rendering with Different Types', () => { + it('should render reposts correctly', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Reposted tweet', + date: '2024-01-01', + isRepost: true, + isQuote: false, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('reply')).toBeInTheDocument(); + }); + + it('should render quotes correctly', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Quoted tweet', + date: '2024-01-01', + isRepost: false, + isQuote: true, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('reply')).toBeInTheDocument(); + }); + + it('should render regular replies correctly', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Regular reply', + date: '2024-01-01', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByText('Regular reply')).toBeInTheDocument(); + }); + }); + + describe('Console Logging', () => { + it('should log data from useProfileFeed', () => { + render(); + + expect(console.log).toHaveBeenCalledWith(mockRepliesData); + }); + + it('should log data even when undefined', () => { + mockUseProfileFeed.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: true, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(console.log).toHaveBeenCalledWith(undefined); + }); + }); + + describe('Data Processing', () => { + it('should flatten pages correctly', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Reply 1', + date: '2024-01-01', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + { + data: { + posts: [ + { + postId: 2, + userId: 1, + text: 'Reply 2', + date: '2024-01-02', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + const replies = screen.getAllByTestId('reply'); + expect(replies).toHaveLength(2); + }); + + it('should handle pages with varying post counts', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Reply 1', + date: '2024-01-01', + isRepost: false, + isQuote: false, + }, + { + postId: 2, + userId: 1, + text: 'Reply 2', + date: '2024-01-02', + isRepost: false, + isQuote: false, + }, + { + postId: 3, + userId: 1, + text: 'Reply 3', + date: '2024-01-03', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + { + data: { + posts: [ + { + postId: 4, + userId: 1, + text: 'Reply 4', + date: '2024-01-04', + isRepost: false, + isQuote: false, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + const replies = screen.getAllByTestId('reply'); + expect(replies).toHaveLength(4); + }); + }); + + describe('Edge Cases', () => { + it('should handle undefined data gracefully', () => { + mockUseProfileFeed.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + // Should not crash and should show infinite scroll + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + + it('should handle posts with originalPostData', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Reply with original', + date: '2024-01-01', + isRepost: true, + isQuote: false, + originalPostData: { + postId: 10, + userId: 2, + text: 'Original post', + }, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('reply')).toBeInTheDocument(); + }); + + it('should handle both repost and quote flags', () => { + mockUseProfileFeed.mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + text: 'Repost and quote', + date: '2024-01-01', + isRepost: true, + isQuote: true, + }, + ], + }, + }, + ], + }, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('reply')).toBeInTheDocument(); + }); + }); + + describe('Component Integration', () => { + it('should integrate with InfiniteScroll component', () => { + render(); + + const infiniteScroll = screen.getByTestId('profile-replies-list'); + expect(infiniteScroll).toBeInTheDocument(); + }); + + it('should pass inProfile prop correctly to Reply components', () => { + // When viewing own profile + mockUseAuthStore.mockImplementation((selector) => { + const state = { user: mockUser }; + return selector(state); + }); + + mockUseProfileContext.mockReturnValue({ + username: 'testuser', + profile: mockProfile, + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + + it('should render with hasNextPage true', () => { + mockUseProfileFeed.mockReturnValue({ + data: mockRepliesData, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: true, + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + + it('should render with hasNextPage false', () => { + mockUseProfileFeed.mockReturnValue({ + data: mockRepliesData, + error: null, + isError: false, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + }); + + render(); + + expect(screen.getByTestId('profile-replies-list')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/features/timeline/components/AddPostSection.tsx b/src/features/timeline/components/AddPostSection.tsx index 447e5b74..a9547e94 100644 --- a/src/features/timeline/components/AddPostSection.tsx +++ b/src/features/timeline/components/AddPostSection.tsx @@ -33,20 +33,15 @@ export default function AddPostSection() { const res = await fetch(med.data.images.original.url); const blob = await res.blob(); const gifFile = new File([blob], med.data.title, { type: 'image/gif' }); - console.log(gifFile); tweetFormData.append('media', gifFile); } } - console.log(tweetFormData.getAll('media')); - console.log(media); - console.log(mentions); + const mentionsId = mentions.map((mention) => mention.id); const allMentions = mentionsId.join(','); tweetFormData.append(TweetFormDataKeys.MENTIONS, allMentions); - console.log('mentionsIds:', tweetFormData.getAll('mentionsIds')); - if (tweetText.trim().length !== 0) { tweetFormData.append(TweetFormDataKeys.CONTENT, tweetText); } diff --git a/src/features/timeline/components/AddTweet.tsx b/src/features/timeline/components/AddTweet.tsx index f7d1f53b..3c4994e1 100644 --- a/src/features/timeline/components/AddTweet.tsx +++ b/src/features/timeline/components/AddTweet.tsx @@ -70,7 +70,6 @@ export default function AddTweet({ useEffect(() => { const unloadCallback = (event: BeforeUnloadEvent) => { if (hasText || hasmMedia) { - console.log(event); event.preventDefault(); return ''; } diff --git a/src/features/timeline/components/GrokMenu.tsx b/src/features/timeline/components/GrokMenu.tsx index eeeab5f1..6b18e7d6 100644 --- a/src/features/timeline/components/GrokMenu.tsx +++ b/src/features/timeline/components/GrokMenu.tsx @@ -11,8 +11,6 @@ const PANEL_HEIGHT = 88; export default function GrokMenu() { const selectors = useAddPostContext(); - const [grokOption, setgrokOption] = useState(0); - const canEnhanceTweet = selectors.useTweetText().length > 0; return ( @@ -38,7 +36,6 @@ export default function GrokMenu() { disabled={index === 1 ? !canEnhanceTweet : false} type="button" onClick={() => { - setgrokOption(opt.id); onClose(); }} className={`h-full cursor-pointer w-full flex items-center gap-2 pl-2 outline-none ${ diff --git a/src/features/timeline/components/Mention.tsx b/src/features/timeline/components/Mention.tsx index 1b95de1f..fac024b0 100644 --- a/src/features/timeline/components/Mention.tsx +++ b/src/features/timeline/components/Mention.tsx @@ -27,7 +27,7 @@ export default function Mention() { isFetchingNextPage, hasNextPage, } = useSearchProfile(debouncedMention); - console.log(profiles, mention); + const pages = profiles?.pages.flat(); const divRef = useRef(null); @@ -65,22 +65,15 @@ export default function Mention() { setIsDone( pages[0].data[0].User.username + ' ' + pages[0].data[0].user_id ); - - console.log(pages[0].data[0].User.username); } } else { if (pages) { const limit = pages[0].metadata.limit; - console.log(limit); const index = selectedTab % limit; const page = Math.floor(selectedTab / limit); - console.log(page, index, selectedTab); const profile = pages[page].data[index]; - console.log(profile); - console.log(profile.User.username + ' ' + profile.user_id); setIsDone(profile.User.username + ' ' + profile.user_id); - console.log(profile.User.username); } } setIsOpen(false); @@ -92,7 +85,6 @@ export default function Mention() { } handleKeyDown(currentKey); setKeyDown(''); - console.log(currentKey); }, [currentKey, setKeyDown, profiles, pages] ); @@ -119,7 +111,6 @@ export default function Mention() { key={profile.user_id} className={`flex w-full ${profile.is_followed_by_me ? 'h-20' : ' h-16'} p-3 ${selectedTab === i * group.metadata.limit + indx && 'bg-white/12'} hover:cursor-pointer hover:bg-white/12`} onClick={() => { - console.log(profile.User.username); setIsOpen(false); setIsDone(profile.User.username + ' ' + profile.user_id); diff --git a/src/features/timeline/components/ReplyQuoteSection.tsx b/src/features/timeline/components/ReplyQuoteSection.tsx index fa5901f4..aae2e1d4 100644 --- a/src/features/timeline/components/ReplyQuoteSection.tsx +++ b/src/features/timeline/components/ReplyQuoteSection.tsx @@ -18,7 +18,6 @@ export default function ReplyQuoteSections({ label }: { label: string }) { const media = selectors.useMedia(); const mentions = selectors.useMentions(); const parentId = useParentId(); - console.log(parentId); const mutate = useAddTweet(label); const enableAddTweet = @@ -35,21 +34,15 @@ export default function ReplyQuoteSections({ label }: { label: string }) { const res = await fetch(med.data.images.original.url); const blob = await res.blob(); const gifFile = new File([blob], med.data.title, { type: 'image/gif' }); - console.log(gifFile); tweetFormData.append('media', gifFile); } - console.log('media appended'); } - console.log(tweetFormData.getAll('media')); - console.log(media); - console.log(mentions); + const mentionsId = mentions.map((mention) => mention.id); const allMentions = mentionsId.join(','); tweetFormData.append(TweetFormDataKeys.MENTIONS, allMentions); - console.log('mentionsIds:', tweetFormData.getAll('mentionsIds')); - if (tweetText.trim().length !== 0) { tweetFormData.append(TweetFormDataKeys.CONTENT, tweetText); } diff --git a/src/features/timeline/components/Timeline.tsx b/src/features/timeline/components/Timeline.tsx index 7918f851..0dc09316 100644 --- a/src/features/timeline/components/Timeline.tsx +++ b/src/features/timeline/components/Timeline.tsx @@ -2,8 +2,6 @@ import Icon from '@/components/ui/home/Icon'; import AddTweet from './AddTweet'; import Header from './Header'; -import ShowTweets from './ShowTweets'; -// import TweetFeed from './TweetFeed'; import TweetList from './TweetList'; import { Avatar } from '@/components/generic'; import { @@ -33,13 +31,16 @@ export default function Timeline() { const isPopUpVisible = useFetchAvatars(); const interval = useRef(null); const newTweets = useNewTweets(); - const { data, error, isError, isLoading } = useAvatarsPopUp(); + const { data } = useAvatarsPopUp(); useEffect( function () { if (data && data.pages[0]?.data?.posts?.length > 0) { console.log(data); - // if (newTweets.length === 0) { - const posts = data.pages[0].data.posts; + const end = + data.pages[0].data.posts.length > 3 + ? 3 + : data.pages[0].data.posts.length; + const posts = data.pages[0].data.posts.slice(0, end); const images = posts.map((post) => post.isRepost ? post.originalPostData diff --git a/src/features/timeline/components/TweetOptionsBar.tsx b/src/features/timeline/components/TweetOptionsBar.tsx index 3194060e..81b5f444 100644 --- a/src/features/timeline/components/TweetOptionsBar.tsx +++ b/src/features/timeline/components/TweetOptionsBar.tsx @@ -6,11 +6,7 @@ import { MAX_MEDIA_NUM } from '@/features/media/constants/mediaConstants'; import { useRouter } from 'next/navigation'; import Emoji from '@/features/media/components/Emoji'; import { useAddPostContext } from '../store/AddPostContext'; -export default function TweetOptionsBar({ - showGif = true, -}: { - showGif?: boolean; -}) { +export default function TweetOptionsBar() { const selectors = useAddPostContext(); const { open: openGif, close: closeGif } = selectors.useActions(); diff --git a/src/features/timeline/components/TweetSubmitSection.tsx b/src/features/timeline/components/TweetSubmitSection.tsx index fd9661bd..9d8c99b2 100644 --- a/src/features/timeline/components/TweetSubmitSection.tsx +++ b/src/features/timeline/components/TweetSubmitSection.tsx @@ -1,5 +1,4 @@ import Button from '@/components/ui/home/Button'; -import Icon from '@/components/ui/home/Icon'; import TypingProgressCircle from './TypingProgressCircle'; interface SubmitInterface { diff --git a/src/features/timeline/components/TweetText.tsx b/src/features/timeline/components/TweetText.tsx index a7cfed13..8493891c 100644 --- a/src/features/timeline/components/TweetText.tsx +++ b/src/features/timeline/components/TweetText.tsx @@ -1,6 +1,6 @@ 'use client'; -import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { MAX_TWEET_LENGTH, MAX_WARNING_TWEET_LENGTH, @@ -12,7 +12,6 @@ const startRedText = MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH; function getCurrCursorPos(div: HTMLDivElement) { const selection = window.getSelection(); if (!selection || !selection.anchorNode) return 0; - console.log(selection); const range = document.createRange(); range.setStart(div, 0); @@ -42,7 +41,7 @@ export type notMentionType = mentionType & { }; export default function TweetText({ placeHolder }: { placeHolder: string }) { const selectors = useAddPostContext(); - console.log(placeHolder); + const divRef = useRef(null); const isSuccess = selectors.useIsSuccess(); @@ -76,9 +75,7 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { ); const { data } = useCheckValidUser(lastMention?.username.slice(1) ?? ''); const isOpen = selectors.useIsOpen(); - console.log(notMentions.current); - console.log(completedMentions.current); - console.log(checkValidUsers); + useEffect(function () { if (firstTweetText) { if (divRef.current) { @@ -94,10 +91,7 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { function handelCusror() { if (divRef.current && divRef.current === document.activeElement) { cursorPos.current = getCurrCursorPos(divRef.current); - console.log(cursorPos.current); - console.log('em'); } - console.log('SAsa'); } document.addEventListener('mousedown', handelCusror); return () => document.removeEventListener('mousedown', handelCusror); @@ -105,13 +99,7 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { useEffect( function () { - console.log('dattttttttta155', data); if (data?.data && lastKey) { - console.log(notMentions.current); - - console.log('dattttttttta1', data); - - console.log('dattttttttta0', data); const mention = notMentions.current.find( (men) => `${men.username}-${men.indx}` === lastKey ); @@ -136,8 +124,6 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { return set; }); span.textContent = mention.username; - console.log(notMentions.current); - console.log(completedMentions.current); } }, [checkValidUsers, data, lastKey] @@ -145,11 +131,7 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { const emoji = selectors.useEmoji(); const handleChangeText = useCallback( - (text: string, lastData: string = '') => { - const lastMatch = text.match( - /(?<=^|\s)@[a-zA-Z](?!.*[_.]{2})[a-zA-Z0-9._]+$/ - ) ?? ['']; - const index = lastMatch.index; + (text: string) => { let isActiveMention = false; let lastMention = ''; @@ -176,28 +158,19 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { spanRef1.current.innerHTML = ''; const displayedText = text.slice(0, startRedText); let lastIndex = 0; - let tweetText = ''; const matchRegex = /(?<=^|\s)@[a-zA-Z](?!.*[_.]{2})[a-zA-Z0-9._]+/g; let match; let cursor = 0; if (divRef.current) cursor = getCurrCursorPos(divRef.current); - console.log(cursor); - console.log( - completedMentions, - completedMentions.current.filter((ment) => ment.checked) - ); completedMentions.current = completedMentions.current.map( (mention) => ({ ...mention, checked: false, }) ); - console.log( - completedMentions, - completedMentions.current.filter((ment) => ment.checked) - ); + while ((match = matchRegex.exec(displayedText)) !== null) { const mentionIndex = match.index; const mentionText = match[0]; @@ -207,7 +180,6 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { displayedText.slice(lastIndex, mentionIndex) ); spanRef1.current.appendChild(node); - tweetText += displayedText.slice(lastIndex, mentionIndex); } let currIndx = 0; const isCompleted = completedMentions.current.some((ment, index) => { @@ -229,14 +201,6 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { }; const isActive = cursor >= mentionIndex && cursor <= mentionEndIndx; - console.log( - isActive, - isCompleted, - mentionIndex, - mentionEndIndx, - cursor - ); - tweetText += mentionText; const span = document.createElement('span'); span.textContent = mentionText; @@ -256,13 +220,10 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { isActiveMention = true; lastMention = mentionText.slice(1); } - if (isCompleted) { - tweetText += '$'; - } + spanRef1.current.appendChild(span); } else { span.className = 'text-active'; - console.log(notMentions.current); const mentionKey = `${mentionText}-${mentionIndex}`; const notCompleted = notMentions.current.some( (ment) => `${ment.username}-${ment.indx}` === mentionKey @@ -275,13 +236,11 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { indx: mentionIndex, id: mentionIndex, }); - console.log(notMentions); setCheckValidUsers((check) => { const set = new Set(check); set.add(mentionKey); return set; }); - console.log('enterre'); } spanRef1.current.appendChild(span); } @@ -296,16 +255,14 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { setMentions(completedMentions.current); if (prevLength !== completedMentions.current.length) { - console.log('s'); if (spanMention.current) spanMention.current.style.color = 'var( --color-mention-progress)'; } - console.log(completedMentions); + if (lastIndex < displayedText.length) { spanRef1.current.appendChild( document.createTextNode(displayedText.slice(lastIndex)) ); - tweetText += displayedText.slice(lastIndex); } if (isActiveMention) { @@ -324,7 +281,6 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { } else { setSpanText2(''); } - console.log(tweetText); } }, [ @@ -354,16 +310,11 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { if (spanMention.current && spanMention.current.textContent) spanMention.current.textContent = `@` + currMention[0] + ` `; - const matches = divRef.current?.innerText.matchAll( - /(?<=^|\s)@[a-zA-Z](?!.*[_.]{2})[a-zA-Z0-9._]+/g - ) ?? ['']; const indx = +(spanMention.current?.getAttribute('data-indx') ?? 0); - console.log(indx); if (indx !== undefined) { const text = divRef.current?.innerText; - console.log(mention, mentionIsDone, text, indx); - console.log(currMention, mentionIsDone); + const newText = text?.slice(0, indx) + `@` + @@ -454,13 +405,12 @@ export default function TweetText({ placeHolder }: { placeHolder: string }) { [emoji, divRef, handleChangeText, clearEmoji] ); - function handleInput(e: React.ChangeEvent) { + function handleInput() { if (divRef.current && divRef.current.innerHTML === '
') { divRef.current.innerHTML = ''; } - const input = e.nativeEvent as InputEvent; if (divRef.current) { - handleChangeText(divRef.current.innerText, input.data ?? ''); + handleChangeText(divRef.current.innerText); } } function handleKeyDown(e: React.KeyboardEvent) { diff --git a/src/features/timeline/hooks/timelineQueries.ts b/src/features/timeline/hooks/timelineQueries.ts index f5c50379..2d344744 100644 --- a/src/features/timeline/hooks/timelineQueries.ts +++ b/src/features/timeline/hooks/timelineQueries.ts @@ -18,7 +18,6 @@ import toasterMessage from '@/components/ui/home/ToasterMessage'; import { useFetchAvatars, - useNewTweets, useSearch, useSelectedTab, } from '../store/useTimelineStore'; @@ -31,7 +30,6 @@ import { ProfileResponseDto, } from '@/features/profile'; import { useAddPostContext } from '../store/AddPostContext'; -import { useOptimisticTweet } from '../optimistics/Tweets'; import { ADD_TWEET } from '../constants/tweetConstants'; import useDebounce from './useDebounce'; export const TIMELINE_QUERY_KEYS = { @@ -71,10 +69,7 @@ export const useAddTweet = (label: string) => { } }, onSuccess: async (data) => { - console.log('app', user, label); - if (user && label !== ADD_TWEET.REPLY) { - console.log('quote'); await queryClient.refetchQueries({ queryKey: PROFILE_QUERY_KEYS.profilePosts(user), }); @@ -262,7 +257,7 @@ export const useAvatarsPopUp = () => { queryFn: ({ pageParam }) => timelineApi.getTimelineFeed(pageParam, queryEndPoint, 3), initialPageParam: 1, - getNextPageParam: (lastPage, pages) => undefined, + getNextPageParam: () => undefined, staleTime: 0, }); }; diff --git a/src/features/timeline/hooks/useOnScreen.tsx b/src/features/timeline/hooks/useOnScreen.tsx index 5240dddb..f59cd0ec 100644 --- a/src/features/timeline/hooks/useOnScreen.tsx +++ b/src/features/timeline/hooks/useOnScreen.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { Ref, useEffect, useRef, useState } from 'react'; +import { Ref, useEffect, useRef, useState } from 'react'; export default function useOnScreen( options?: IntersectionObserverInit diff --git a/src/features/timeline/hooks/useRealTimeTweets.tsx b/src/features/timeline/hooks/useRealTimeTweets.tsx index 3ca2a4f0..2b3a6e5d 100644 --- a/src/features/timeline/hooks/useRealTimeTweets.tsx +++ b/src/features/timeline/hooks/useRealTimeTweets.tsx @@ -23,7 +23,7 @@ export const useRealTimeTweets = () => { cb?.(resp); } ); - } catch (err) { + } catch { console.warn('Socket not initialized, cannot join post:', postId); } }, []); @@ -44,7 +44,7 @@ export const useRealTimeTweets = () => { cb?.(resp); } ); - } catch (err) { + } catch { console.warn('Socket not initialized, cannot leave post:', postId); } }, []); @@ -80,7 +80,6 @@ export const useRealTimeTweets = () => { return; } const onLike = (data: { postId: number; count: number }) => { - console.log(data, 'Likkkkkkkeeeeeeee', type); if (postId === data.postId) onMutate( OPTIMISTIC_TYPES.LIKE, @@ -96,7 +95,7 @@ export const useRealTimeTweets = () => { return () => { socket.off(REAL_TIME_TWEETS_SOCKET_EVENTS.LIKE_UPDATE, onLike); }; - } catch (err) { + } catch { console.warn( 'Socket not initialized, cannot listen to post like:', postId @@ -126,7 +125,6 @@ export const useRealTimeTweets = () => { return; } const onComment = (data: { postId: number; count: number }) => { - console.log(data, 'COmmment'); if (postId === data.postId) onMutate( OPTIMISTIC_TYPES.REPLY, @@ -142,7 +140,7 @@ export const useRealTimeTweets = () => { return () => { socket.off(REAL_TIME_TWEETS_SOCKET_EVENTS.COMMENT_UPDATE, onComment); }; - } catch (err) { + } catch { console.warn( 'Socket not initialized, cannot listen to post reply:', postId @@ -172,7 +170,6 @@ export const useRealTimeTweets = () => { return; } const onRepost = (data: { postId: number; count: number }) => { - console.log(data, 'reposssst'); if (postId === data.postId) onMutate( OPTIMISTIC_TYPES.REPOST, @@ -188,7 +185,7 @@ export const useRealTimeTweets = () => { return () => { socket.off(REAL_TIME_TWEETS_SOCKET_EVENTS.REPOST_UPDATE, onRepost); }; - } catch (err) { + } catch { console.warn( 'Socket not initialized, cannot listen to post repost:', postId diff --git a/src/features/timeline/optimistics/RealTimeTweet.ts b/src/features/timeline/optimistics/RealTimeTweet.ts index 7dbeaadd..9c35fd29 100644 --- a/src/features/timeline/optimistics/RealTimeTweet.ts +++ b/src/features/timeline/optimistics/RealTimeTweet.ts @@ -287,6 +287,8 @@ export function useRealTimeTweet() { const isInterest = path?.startsWith('/explore/'); const isProfile = path?.startsWith(`/${username}`); const interest = useInterest(); + const isFullTweet = path?.startsWith('/home/'); + const extractedId = isFullTweet && path ? parseInt(path.split('/')[2]) : -1; const onMutate = async ( type: string, @@ -302,24 +304,43 @@ export function useRealTimeTweet() { }[]; oldTweet: TimelineFeed | undefined; }> => { - if (tweetId) { + if (extractedId !== -1 && isFullTweet) { queryClient.setQueryData( - TWEET_QUERY_KEYS.tweetById(tweetId), + TWEET_QUERY_KEYS.tweetById(extractedId), (old: any) => { if (!old) return old; - const updatedTweet = updateTweet(type, old.data[0], count); + + if ( + isFullTweet && + old?.data[0]?.originalPostData?.postId === tweetId + ) { + const newOriginalData = updateTweet( + type, + old.data[0].originalPostData, + count + ); + return { + ...old, + data: [ + { + ...old.data[0], + originalPostData: newOriginalData, + }, + ], + }; + } return { ...old, - data: [updatedTweet], + data: [updateTweet(type, old.data[0], count)], }; } ); } + if (type === OPTIMISTIC_TYPES.REPLY) { queryClient.refetchQueries({ queryKey: TWEET_QUERY_KEYS.getRepliesByTweetId(tweetId), }); - console.log('ds'); } const tabsFeeds: { @@ -357,7 +378,6 @@ export function useRealTimeTweet() { queryKeys.unshift(currentKey); } - console.log(queryKeys); for (const queryKey of queryKeys) { if (queryKey === EXPLORE_QUERY_KEYS.EXPLORE_FEED_FOR_YOU) { await optimisticsInterests(type, queryKey, tweetId, userId, count); diff --git a/src/features/timeline/optimistics/Tweets.ts b/src/features/timeline/optimistics/Tweets.ts index 76b06373..88507d94 100644 --- a/src/features/timeline/optimistics/Tweets.ts +++ b/src/features/timeline/optimistics/Tweets.ts @@ -610,7 +610,6 @@ export function useOptimisticTweet() { const isHome = path?.startsWith('/home'); const isFullTweet = path?.startsWith('/home/'); - // Extract the tweet ID from the path (e.g., /home/123 -> 123) const extractedId = isFullTweet && path ? parseInt(path.split('/')[2]) : -1; const isInterest = path?.startsWith('/explore/'); @@ -636,12 +635,10 @@ export function useOptimisticTweet() { TWEET_QUERY_KEYS.tweetById(extractedId), (old: any) => { if (!old) return old; - console.log('isFullTweet', isFullTweet); if ( isFullTweet && old?.data[0]?.originalPostData?.postId === tweetId ) { - console.log('here'); const newOriginalData = updateTweet( type, old.data[0].originalPostData, @@ -709,7 +706,6 @@ export function useOptimisticTweet() { queryKeys.unshift(currentKey); } // } - console.log(queryKeys); for (const queryKey of queryKeys) { let result; if (queryKey === EXPLORE_QUERY_KEYS.EXPLORE_FEED_FOR_YOU) { @@ -811,7 +807,6 @@ export function useOptimisticTweet() { } else return false; }); if (tweets.length > 0) { - console.log(tweets); timelineFeed = updateTweetPersonalizedInterestsData( timelineFeed, pages, @@ -913,7 +908,6 @@ export function useOptimisticTweet() { } else return false; }); if (tweets.length > 0) { - console.log(tweets); timelineFeed = updateTweetInInfiniteData( timelineFeed, pages, diff --git a/src/features/timeline/store/useAddPostStore.tsx b/src/features/timeline/store/useAddPostStore.tsx index 00d558ef..fc9e5b83 100644 --- a/src/features/timeline/store/useAddPostStore.tsx +++ b/src/features/timeline/store/useAddPostStore.tsx @@ -2,7 +2,6 @@ import { create, UseBoundStore, StoreApi } from 'zustand'; import { devtools } from 'zustand/middleware'; import { mentionType } from '../components/TweetText'; -import { ADD_TWEET } from '../constants/tweetConstants'; import GifData, { mediaType } from '@/features/media/types/components'; import { EXTERNAL_GIF, diff --git a/src/features/timeline/test/Timeline.test.tsx b/src/features/timeline/test/Timeline.test.tsx new file mode 100644 index 00000000..25c2bb8e --- /dev/null +++ b/src/features/timeline/test/Timeline.test.tsx @@ -0,0 +1,310 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, renderHook, waitFor } from '@testing-library/react'; +import Header from '../components/Header'; +import { useSelectedTab } from '../store/useTimelineStore'; +import { FOLLOWING_TAB, FOR_YOU_TAB } from '../constants/menuName'; +import { TIMELINE_ENDPOINTS } from '../constants/api'; +import { API_CONFIG } from '@/constants/api'; +import { render as customRender } from '@/test/test-utils'; +import TweetList from '../components/TweetList'; +import ProfileLogo from '@/components/ui/home/ProfileLogo'; +import { useAuth } from '@/features/authentication/hooks'; +import AddTweet from '../components/AddTweet'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { image1, image2, image3, image4, image5, tweet } from '../mocks/data'; +import { + ADD_TWEET, + MAX_TWEET_LENGTH, + MAX_WARNING_TWEET_LENGTH, +} from '../constants/tweetConstants'; +import { vi, beforeAll } from 'vitest'; + +vi.mock('next/navigation', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), + usePathname: vi.fn(() => '/home'), + useSearchParams: vi.fn(() => new URLSearchParams()), + }; +}); + +beforeAll(() => { + process.env.NEXT_PUBLIC_API_BASE_URL = 'localhost/500'; + process.env.NEXT_PUBLIC_API_VERSION = 'v1.0'; +}); + +const queryClient1 = new QueryClient(); +const wrapper = ({ children }: { children: React.ReactNode }) => { + return ( + {children} + ); +}; +describe('render Timeline Header ', () => { + it('render header component', () => { + const { getByTestId } = render(
, { wrapper }); + const header = getByTestId('timeline-header'); + expect(header).toBeInTheDocument(); + }); + it('render Tabs in header', () => { + const { getByTestId, getAllByTestId } = render(
, { wrapper }); + const timelineTab = getByTestId('timeline-header'); + const tabs = getAllByTestId(/timeline-tabs-tab/); + const tab1 = getByTestId('timeline-tabs-tab-Following'); + const tab2 = getByTestId('timeline-tabs-tab-ForYou'); + expect(timelineTab).toBeInTheDocument(); + expect(tab1).toBeInTheDocument(); + expect(tab2).toBeInTheDocument(); + expect(tabs.length).toBe(2); + }); + it('selcect tab in header', () => { + const { getByTestId } = render(
, { wrapper }); + const tab1 = getByTestId('timeline-tabs-tab-Following'); + const tab2 = getByTestId('timeline-tabs-tab-ForYou'); + + fireEvent.click(tab2); + const { result: resultForU } = renderHook(() => useSelectedTab()); + const tabForU = resultForU.current; + expect(tabForU).toBe(FOR_YOU_TAB); + fireEvent.click(tab1); + const { result: resultFollowing } = renderHook(() => useSelectedTab()); + const tabFollowing = resultFollowing.current; + expect(tabFollowing).toBe(FOLLOWING_TAB); + }); + it('expecting calling api when select tab', async () => { + global.fetch = vi.fn(); + const { getByTestId } = render(
, { wrapper }); + customRender(); + const tab2 = getByTestId('timeline-tabs-tab-ForYou'); + + // Get the initial call count + const initialCallCount = (global.fetch as any).mock.calls.length; + + expect(global.fetch).toHaveBeenCalledWith( + API_CONFIG.BASE_URL + + TIMELINE_ENDPOINTS.TIMELINE_FEED_FLLOWING + + '?page=1&limit=10', + expect.anything() + ); + + fireEvent.click(tab2); + + expect(global.fetch).toHaveBeenCalledWith( + API_CONFIG.BASE_URL + + TIMELINE_ENDPOINTS.TIMELINE_FEED_FOR_YOU + + '?page=1&limit=10', + expect.anything() + ); + + // Verify that one more call was made after clicking + expect((global.fetch as any).mock.calls.length).toBe(initialCallCount + 1); + }); +}); + +describe('test add tweet component', () => { + it('test click profile picture to route to profile', () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + const mockUser = { + id: 7, + username: 'yousef07', + role: 'user', + email: 'yousef@gmail.com', + profile: { + name: 'Yousef Adel', + profileImageUrl: null, + birthDate: null, // ISO date string + }, + onboardingStatus: undefined, + }; + result.current.setUser(mockUser); + const { getByTestId } = customRender(); + const logo = getByTestId('profile-logo'); + const link = getByTestId('profile-logo-link'); + fireEvent.click(link); + expect(logo).toBeInTheDocument(); + expect(link).toBeInTheDocument(); + expect(link.getAttribute('href')).toBe(`./${mockUser.username}`); + }); +}); + +describe('send post', () => { + it('test try to add empty tweet', () => { + global.fetch = vi.fn(); + const { getByTestId } = render(, { + wrapper, + }); + + const submitButton = getByTestId('button-Post'); + fireEvent.click(submitButton); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toBeDisabled(); + expect(global.fetch).not.toHaveBeenCalledWith( + API_CONFIG.BASE_URL + TIMELINE_ENDPOINTS.ADD_TWEET + ); + }); + beforeEach(() => { + (global.fetch as any) = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + status: 'success', + message: 'Post created successfully', + data: tweet, + }), + }) + ); + global.URL.createObjectURL = vi.fn(); + }); + it('try to send post with only media and clear media after post ( which is valid :) )', async () => { + const { getByTestId, queryByTestId, container } = render( + , + { + wrapper, + } + ); + const mediaInput = getByTestId('media-import'); + expect(mediaInput).toBeInTheDocument(); + + fireEvent.click(getByTestId('add-tweet-container')); + expect(queryByTestId('media-preview')).not.toBeInTheDocument(); + fireEvent.change(mediaInput, { target: { files: [image1] } }); + await waitFor(() => { + expect(queryByTestId('media-preview')).toBeInTheDocument(); + }); + // Check that an image element appears in the DOM + await waitFor(() => { + const images = container.querySelectorAll('[data-testid^="image-"]'); + expect(images.length).toBe(1); + }); + const submitButton = getByTestId('button-Post'); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).not.toBeDisabled(); + fireEvent.click(submitButton); + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + expect(global.fetch).toHaveBeenCalledWith( + API_CONFIG.BASE_URL + TIMELINE_ENDPOINTS.ADD_TWEET, + expect.anything() + ); + + // Check that media preview disappears after successful post + await waitFor(() => { + expect(queryByTestId('media-preview')).not.toBeInTheDocument(); + }); + }); + + it('try to send post with media exceeded 4 items)', async () => { + const { getByTestId, queryByTestId } = render( + , + { + wrapper, + } + ); + + const mediaInput = getByTestId('media-import'); + expect(mediaInput).toBeInTheDocument(); + + const invalidMedia = [image1, image2, image3, image4, image5]; + fireEvent.click(getByTestId('add-tweet-container')); + expect(queryByTestId('media-preview')).not.toBeInTheDocument(); + fireEvent.change(mediaInput, { target: { files: invalidMedia } }); + // Media should not be added (validation fails) + expect(queryByTestId('media-preview')).not.toBeInTheDocument(); + const submitButton = getByTestId('button-Post'); + + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toBeDisabled(); + fireEvent.click(submitButton); + await waitFor(() => expect(global.fetch).not.toHaveBeenCalled()); + }); + it('send post with media and text', async () => { + const { getByTestId, queryByTestId, container } = render( + , + { + wrapper, + } + ); + + const mediaInput = getByTestId('media-import'); + const validMedia = [image1, image2, image3, image4]; + + const submitButton = getByTestId('button-Post'); + fireEvent.change(mediaInput, { target: { files: validMedia } }); + + // Check that images appear in the DOM (could be 2-4 based on layout) + await waitFor(() => { + const images = container.querySelectorAll('[data-testid^="image-"]'); + expect(images.length).toBeGreaterThanOrEqual(2); + expect(images.length).toBeLessThanOrEqual(4); + }); + expect(submitButton).not.toBeDisabled(); + expect(queryByTestId('media-preview')).toBeInTheDocument(); + const inputText = getByTestId('tweet-text-input'); + const text = 'hello X'; + fireEvent.input(inputText, { target: { innerText: text } }); + // Check that the text appears in the display + await waitFor(() => { + const displayText = getByTestId('tweet-text-display'); + expect(displayText.textContent).toBe(text); + }); + fireEvent.click(submitButton); + await waitFor(() => + expect(global.fetch).toHaveBeenCalledWith( + API_CONFIG.BASE_URL + TIMELINE_ENDPOINTS.ADD_TWEET, + expect.anything() + ) + ); + // Check that text is cleared after post + await waitFor(() => { + const displayText = getByTestId('tweet-text-display'); + expect(displayText.textContent).toBe("What's happening?"); + }); + }); + it('excced text length to send post', async () => { + (global.fetch as any) = vi.fn(() => + Promise.resolve({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + status: 'faliure', + message: 'failed to create post', + }), + }) + ); + const { getByTestId } = render(, { + wrapper, + }); + const tweetTextInput = getByTestId('tweet-text-input'); + const text = 'hello X'; + fireEvent.input(tweetTextInput, { target: { innerText: text } }); + const redText = getByTestId('tweet-text-overflow'); + expect(redText.innerHTML.length).toBe(0); + // Check that the text appears in the display + await waitFor(() => { + const displayText = getByTestId('tweet-text-display'); + expect(displayText.textContent).toBe(text); + }); + + const submitButton = getByTestId('button-Post'); + expect(submitButton).not.toBeDisabled(); + const text2 = + 'This is a long test string intended for development, debugging, and validation purposes. It can be used to populate fields, simulate text content, verify rendering, or check how a user interface behaves with a moderately sized block of text. The goal of this sample text is not to deliver meaningful content but to provide a realistic amount of words, characters, and sentence structure similar'; + fireEvent.input(tweetTextInput, { target: { innerText: text2 } }); + await waitFor(() => { + expect(submitButton).toBeDisabled(); + }); + + await waitFor(() => { + expect(redText.innerHTML.length).toBe( + text2.length - MAX_TWEET_LENGTH - MAX_WARNING_TWEET_LENGTH + ); + }); + }); +}); diff --git a/src/features/timeline/tests/AddPostSection.test.tsx b/src/features/timeline/tests/AddPostSection.test.tsx index 1276df87..5326d6b9 100644 --- a/src/features/timeline/tests/AddPostSection.test.tsx +++ b/src/features/timeline/tests/AddPostSection.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; @@ -18,9 +18,10 @@ vi.mock('../store/AddPostContext', () => ({ })); // Mock useAddTweet +const mockMutate = vi.fn(); vi.mock('../hooks/timelineQueries', () => ({ useAddTweet: vi.fn(() => ({ - mutate: vi.fn(), + mutate: mockMutate, isPending: false, isSuccess: false, isError: false, @@ -29,8 +30,21 @@ vi.mock('../hooks/timelineQueries', () => ({ // Mock TweetSubmitSection vi.mock('./TweetSubmitSection', () => ({ - default: ({ label, handleAddTweet, enableAddTweet }: any) => ( -
+ default: ({ + label, + handleAddTweet, + enableAddTweet, + enableSection, + }: { + label: string; + handleAddTweet: () => void; + enableAddTweet: boolean; + enableSection: boolean; + }) => ( +
))}
), })); vi.mock('@/features/layout/components/MobileSidebar', () => ({ - default: () =>
MobileSidebar
, + default: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => ( +
+ +
+ ), })); vi.mock('@/components/ui/icons/BrandIcons', () => ({ @@ -88,7 +110,7 @@ vi.mock('@/components/ui/icons/BrandIcons', () => ({ })); vi.mock('@/components/ui/home/Icon', () => ({ - default: ({ path }: any) => ( + default: ({ path }: { path: string }) => ( @@ -96,6 +118,8 @@ vi.mock('@/components/ui/home/Icon', () => ({ })); import Header from '../components/Header'; +import { useSelectedTab, useTabsScroll } from '../store/useTimelineStore'; +import { useMyProfile } from '@/features/profile/hooks'; const createTestQueryClient = () => new QueryClient({ @@ -113,6 +137,11 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); + window.scrollTo = vi.fn(); + Object.defineProperty(document.documentElement, 'scrollTop', { + value: 100, + writable: true, + }); }); it('should render header component', () => { @@ -156,4 +185,94 @@ describe('Header', () => { render(
, { wrapper }); expect(screen.getByTestId('x-logo')).toBeInTheDocument(); }); + + it('should scroll to top when clicking same tab', () => { + vi.mocked(useSelectedTab).mockReturnValue('ForYou'); + + render(
, { wrapper }); + + fireEvent.click(screen.getByText('For you')); + + expect(window.scrollTo).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth', + }); + expect(mockSelectTab).toHaveBeenCalledWith('ForYou'); + }); + + it('should switch to Following tab and save scroll position', () => { + vi.mocked(useSelectedTab).mockReturnValue('ForYou'); + vi.mocked(useTabsScroll).mockReturnValue([0, 200]); + + render(
, { wrapper }); + + fireEvent.click(screen.getByText('Following')); + + expect(mockSetTabsScroll).toHaveBeenCalled(); + expect(mockSelectTab).toHaveBeenCalledWith('Following'); + expect(mockSetFetchAvatars).toHaveBeenCalledWith(false); + expect(mockSetNewTweets).toHaveBeenCalledWith([]); + expect(mockSetPopUpAvatars).toHaveBeenCalledWith([]); + }); + + it('should switch to For you tab and restore scroll position', () => { + vi.mocked(useSelectedTab).mockReturnValue('Following'); + vi.mocked(useTabsScroll).mockReturnValue([150, 0]); + + render(
, { wrapper }); + + fireEvent.click(screen.getByText('For you')); + + expect(mockSetTabsScroll).toHaveBeenCalled(); + expect(mockSelectTab).toHaveBeenCalledWith('ForYou'); + }); + + it('should open sidebar when clicking avatar', () => { + render(
, { wrapper }); + + const avatar = screen.getByTestId('avatar'); + fireEvent.click(avatar.parentElement!); + + const sidebar = screen.getByTestId('mobile-sidebar'); + expect(sidebar.getAttribute('data-open')).toBe('true'); + }); + + it('should close sidebar when clicking close button', () => { + render(
, { wrapper }); + + const avatar = screen.getByTestId('avatar'); + fireEvent.click(avatar.parentElement!); + + fireEvent.click(screen.getByTestId('close-sidebar')); + + const sidebar = screen.getByTestId('mobile-sidebar'); + expect(sidebar.getAttribute('data-open')).toBe('false'); + }); + + it('should use fallback name when profile name is null', () => { + vi.mocked(useMyProfile).mockReturnValue({ + data: { + data: { + profile_image_url: null, + name: null, + }, + }, + } as ReturnType); + + render(
, { wrapper }); + + expect(screen.getByTestId('avatar')).toBeInTheDocument(); + }); + + it('should refetch queries when tab changes', () => { + vi.mocked(useSelectedTab).mockReturnValue('For you'); + + render(
, { wrapper }); + + fireEvent.click(screen.getByText('Following')); + + expect(mockSetFetchAvatars).toHaveBeenCalledWith(false); + expect(mockSetNewTweets).toHaveBeenCalledWith([]); + expect(mockSetPopUpAvatars).toHaveBeenCalledWith([]); + }); }); diff --git a/src/features/timeline/tests/Mention.test.tsx b/src/features/timeline/tests/Mention.test.tsx index 63436ee5..6ee57c38 100644 --- a/src/features/timeline/tests/Mention.test.tsx +++ b/src/features/timeline/tests/Mention.test.tsx @@ -1,21 +1,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; +// Mock state variables +let mockMention = 'test'; +let mockCurrentKey = ''; +let mockIsOpen = true; +const mockSetIsOpen = vi.fn(); +const mockSetIsDone = vi.fn(); +const mockSetKeyDown = vi.fn(); +const mockFetchNextPage = vi.fn(); + // Mock AddPostContext vi.mock('../store/AddPostContext', () => ({ useAddPostContext: vi.fn(() => ({ useTweetText: () => '', - useMention: () => 'test', - useCurrentKey: () => '', - useIsOpen: () => true, + useMention: () => mockMention, + useCurrentKey: () => mockCurrentKey, + useIsOpen: () => mockIsOpen, useActions: () => ({ setTweetText: vi.fn(), setMention: vi.fn(), - setIsOpen: vi.fn(), - setIsDone: vi.fn(), - setKeyDown: vi.fn(), + setIsOpen: mockSetIsOpen, + setIsDone: mockSetIsDone, + setKeyDown: mockSetKeyDown, }), })), })); @@ -26,13 +35,21 @@ vi.mock('../hooks/timelineQueries', () => ({ data: { pages: [ { - data: [{ User: { username: 'testuser' }, user_id: 1 }], + data: [ + { + User: { username: 'testuser', is_verified: false }, + user_id: 1, + name: 'Test User', + is_followed_by_me: false, + profile_image_url: '/test.jpg', + }, + ], metadata: { total: 1, limit: 10 }, }, ], }, isLoading: false, - fetchNextPage: vi.fn(), + fetchNextPage: mockFetchNextPage, isFetchingNextPage: false, hasNextPage: false, isError: false, @@ -52,7 +69,9 @@ vi.mock('next/navigation', () => ({ // Mock components vi.mock('@/components/ui/UserCard', () => ({ - default: ({ username }: any) =>
{username}
, + default: ({ name }: { name: string }) => ( +
{name}
+ ), })); vi.mock('@/components/generic', () => ({ @@ -60,7 +79,7 @@ vi.mock('@/components/generic', () => ({ })); vi.mock('@/components/ui/home/InfiniteScroll', () => ({ - default: ({ children }: any) => ( + default: ({ children }: { children: React.ReactNode }) => (
{children}
), })); @@ -70,10 +89,14 @@ vi.mock('@/components/ui/home/ToasterMessage', () => ({ })); import Mention from '../components/Mention'; +import { useSearchProfile } from '../hooks/timelineQueries'; describe('Mention', () => { beforeEach(() => { vi.clearAllMocks(); + mockMention = 'test'; + mockCurrentKey = ''; + mockIsOpen = true; }); it('should render mention component', () => { @@ -92,4 +115,184 @@ describe('Mention', () => { render(); expect(screen.getByTestId('infinite-scroll')).toBeInTheDocument(); }); + + it('should return null when isOpen is false', () => { + mockIsOpen = false; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should return null when mention is empty', () => { + mockMention = ''; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should show loading state', () => { + vi.mocked(useSearchProfile).mockReturnValue({ + data: undefined, + isLoading: true, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + } as ReturnType); + + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should handle error state', () => { + vi.mocked(useSearchProfile).mockReturnValue({ + data: undefined, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + isError: true, + error: { message: 'Error occurred' }, + } as unknown as ReturnType); + + render(); + expect( + screen.queryByTestId('render-search-profile-list') + ).not.toBeInTheDocument(); + }); + + it('should close when clicking outside', () => { + render(); + act(() => { + fireEvent.mouseDown(document.body); + }); + expect(mockSetIsOpen).toHaveBeenCalledWith(false); + }); + + it('should render with mention and profiles', () => { + // Re-mock the data for this test since previous test may have changed the mock + vi.mocked(useSearchProfile).mockReturnValue({ + data: { + pages: [ + { + data: [ + { + User: { username: 'testuser', is_verified: false }, + user_id: 1, + name: 'Test User', + is_followed_by_me: false, + profile_image_url: '/test.jpg', + }, + ], + metadata: { total: 1, limit: 10 }, + }, + ], + }, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + } as ReturnType); + + render(); + expect(screen.getByTestId('infinite-scroll')).toBeInTheDocument(); + }); + + it('should handle multiple profiles', () => { + vi.mocked(useSearchProfile).mockReturnValue({ + data: { + pages: [ + { + data: [ + { + User: { username: 'user1', is_verified: false }, + user_id: 1, + name: 'User 1', + is_followed_by_me: false, + profile_image_url: '/1.jpg', + }, + { + User: { username: 'user2', is_verified: true }, + user_id: 2, + name: 'User 2', + is_followed_by_me: true, + profile_image_url: '/2.jpg', + }, + ], + metadata: { total: 2, limit: 10 }, + }, + ], + }, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + } as ReturnType); + + render(); + expect(screen.getByText('User 1')).toBeInTheDocument(); + expect(screen.getByText('User 2')).toBeInTheDocument(); + }); + + it('should display followed users', () => { + vi.mocked(useSearchProfile).mockReturnValue({ + data: { + pages: [ + { + data: [ + { + User: { username: 'followeduser', is_verified: true }, + user_id: 1, + name: 'Followed User', + is_followed_by_me: true, + profile_image_url: '/followed.jpg', + }, + ], + metadata: { total: 1, limit: 10 }, + }, + ], + }, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + } as ReturnType); + + render(); + expect(screen.getByText('Followed User')).toBeInTheDocument(); + }); + + it('should handle empty profiles list', () => { + vi.mocked(useSearchProfile).mockReturnValue({ + data: { + pages: [ + { + data: [], + metadata: { total: 0, limit: 10 }, + }, + ], + }, + isLoading: false, + fetchNextPage: mockFetchNextPage, + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + } as ReturnType); + + render(); + expect( + screen.getByTestId('render-search-profile-list') + ).toBeInTheDocument(); + }); + + it('should render with data available', () => { + render(); + expect(screen.getByTestId('infinite-scroll')).toBeInTheDocument(); + }); }); diff --git a/src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx b/src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx index a56cb8d6..4567dc72 100644 --- a/src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx +++ b/src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx @@ -5,8 +5,8 @@ import { renderHook } from '@testing-library/react'; const mockQueryClient = { getQueryData: vi.fn(), setQueryData: vi.fn(), - cancelQueries: vi.fn(), - refetchQueries: vi.fn(), + cancelQueries: vi.fn().mockResolvedValue(undefined), + refetchQueries: vi.fn().mockResolvedValue(undefined), }; vi.mock('@tanstack/react-query', () => ({ @@ -57,11 +57,6 @@ vi.mock('next/navigation', () => ({ })), })); -// Mock tweet store -vi.mock('@/features/tweets/store/tweetStore', () => ({ - useTweetStore: vi.fn(() => ({})), -})); - // Mock tweet queries vi.mock('@/features/tweets/hooks/tweetQueries', () => ({ TWEET_QUERY_KEYS: { @@ -103,37 +98,164 @@ import { useInterestQueryKey, useRealTimeTweet, } from '../optimistics/RealTimeTweet'; +import { useSelectedTab } from '../store/useTimelineStore'; +import { + useSearch, + useSelectedSearchTab, + useInterest, + useSelectedInterestTab, +} from '@/features/explore/store/useExploreStore'; +import { useSelectedTab as useProfileSelectedTab } from '@/features/profile/store/profileStore'; +import { usePathname } from 'next/navigation'; +import { useAuth } from '@/features/authentication/hooks'; +import { useProfileStore } from '@/features/profile'; describe('RealTimeTweet optimistics', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(useSelectedTab).mockReturnValue('For you'); + vi.mocked(useSearch).mockReturnValue(''); + vi.mocked(useSelectedSearchTab).mockReturnValue('top'); + vi.mocked(useInterest).mockReturnValue(''); + vi.mocked(useSelectedInterestTab).mockReturnValue('top'); + vi.mocked(useProfileSelectedTab).mockReturnValue('Posts'); + vi.mocked(usePathname).mockReturnValue('/home'); + vi.mocked(useAuth).mockReturnValue({ + user: { id: 1, username: 'testuser' }, + } as ReturnType); + // Default mock for setQueryData that calls the updater function + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + return updater(null); + } + return updater; + }); }); describe('useTimelineQueryKey', () => { - it('should return timeline query key', () => { + it('should return For you query key when tab is For you', () => { + vi.mocked(useSelectedTab).mockReturnValue('For you'); const { result } = renderHook(() => useTimelineQueryKey()); - expect(result.current).toBeDefined(); + expect(result.current).toEqual(['timeline', 'forYou']); + }); + + it('should return Following query key when tab is Following', () => { + vi.mocked(useSelectedTab).mockReturnValue('Following'); + const { result } = renderHook(() => useTimelineQueryKey()); + expect(result.current).toEqual(['timeline', 'following']); }); }); describe('useProfileQueryKey', () => { - it('should return profile query key', () => { + it('should return profile posts key for Posts tab', () => { + vi.mocked(useProfileSelectedTab).mockReturnValue('Posts'); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should return profile replies key for Replies tab', () => { + vi.mocked(useProfileSelectedTab).mockReturnValue('Replies'); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should return profile likes key for Likes tab', () => { + vi.mocked(useProfileSelectedTab).mockReturnValue('Likes'); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should return profile mentions key for Mentions tab', () => { + vi.mocked(useProfileSelectedTab).mockReturnValue('Mentions'); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should return profile media key for Media tab', () => { + vi.mocked(useProfileSelectedTab).mockReturnValue('Media'); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should return posts key as default', () => { + vi.mocked(useProfileSelectedTab).mockReturnValue('Unknown'); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should use profile user id when available', () => { + vi.mocked(useProfileStore).mockImplementation((selector) => + selector({ + currentProfile: { User: { id: 999, username: 'profileuser' } }, + } as never) + ); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should fallback to myProfile when no profile user', () => { + vi.mocked(useAuth).mockReturnValue({ + user: { id: 5, username: 'myuser' }, + } as ReturnType); + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + + it('should use -1 when no user available', () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + } as ReturnType); + vi.mocked(useProfileStore).mockImplementation((selector) => + selector({ currentProfile: null } as never) + ); const { result } = renderHook(() => useProfileQueryKey()); expect(result.current).toBeDefined(); }); }); describe('useExploreQueryKey', () => { - it('should return explore query key', () => { + it('should return for you key when no search', () => { + vi.mocked(useSearch).mockReturnValue(''); const { result } = renderHook(() => useExploreQueryKey()); - expect(result.current).toBeDefined(); + expect(result.current).toEqual(['explore', 'for-you']); + }); + + it('should return top search key when search and top tab', () => { + vi.mocked(useSearch).mockReturnValue('test search'); + vi.mocked(useSelectedSearchTab).mockReturnValue('top'); + const { result } = renderHook(() => useExploreQueryKey()); + expect(result.current).toEqual([ + 'explore', + 'search', + 'top', + 'test search', + ]); + }); + + it('should return latest search key when search and latest tab', () => { + vi.mocked(useSearch).mockReturnValue('test search'); + vi.mocked(useSelectedSearchTab).mockReturnValue('latest'); + const { result } = renderHook(() => useExploreQueryKey()); + expect(result.current).toEqual([ + 'explore', + 'search', + 'latest', + 'test search', + ]); }); }); describe('useInterestQueryKey', () => { it('should return interest query key', () => { + vi.mocked(useInterest).mockReturnValue('technology'); + vi.mocked(useSelectedInterestTab).mockReturnValue('latest'); const { result } = renderHook(() => useInterestQueryKey()); - expect(result.current).toBeDefined(); + expect(result.current).toEqual([ + 'explore', + 'interest', + 'technology', + 'latest', + ]); }); }); @@ -144,29 +266,28 @@ describe('RealTimeTweet optimistics', () => { expect(typeof result.current.onMutate).toBe('function'); }); - it('should call onMutate with correct parameters', async () => { + it('should call onMutate with LIKE type', async () => { const { result } = renderHook(() => useRealTimeTweet()); - const response = await result.current.onMutate('LIKE', 1, 1, 10); + const response = await result.current.onMutate('like', 1, 1, 10); expect(response).toHaveProperty('previousFeeds'); expect(response).toHaveProperty('oldTweet'); }); - it('should handle REPOST type', async () => { + it('should call onMutate with REPOST type', async () => { const { result } = renderHook(() => useRealTimeTweet()); - const response = await result.current.onMutate('REPOST', 1, 1, 5); + const response = await result.current.onMutate('repost', 1, 1, 5); expect(response).toBeDefined(); }); - it('should handle REPLY type', async () => { + it('should call onMutate with REPLY type', async () => { const { result } = renderHook(() => useRealTimeTweet()); - // Just test that onMutate can be called with REPLY type const response = await result.current.onMutate( - 'REPLY', + 'reply', 1, 1, 3, @@ -175,6 +296,450 @@ describe('RealTimeTweet optimistics', () => { ); expect(response).toBeDefined(); + expect(mockQueryClient.refetchQueries).toHaveBeenCalled(); + }); + + it('should update tweet by id when tweetId is valid and callback executes', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + // Mock setQueryData to execute the callback with proper data + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [{ postId: 1, likesCount: 5, isRepost: false }], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle setQueryData callback with repost data', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [ + { + postId: 1, + isRepost: true, + originalPostData: { postId: 100, likesCount: 5 }, + }, + ], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 100, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle setQueryData callback returning early when old is null', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + return updater(null); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should update repost type via callback', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [{ postId: 1, retweetsCount: 5, isRepost: false }], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('repost', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should update reply type via callback', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [{ postId: 1, commentsCount: 5, isRepost: false }], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('reply', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should update repost with originalPostData for repost type', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [ + { + postId: 1, + isRepost: true, + originalPostData: { postId: 100, retweetsCount: 5 }, + }, + ], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('repost', 100, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should update repost with originalPostData for reply type', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [ + { + postId: 1, + isRepost: true, + originalPostData: { postId: 100, commentsCount: 5 }, + }, + ], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('reply', 100, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle default case in updateTweet', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [{ postId: 1, isRepost: false }], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('unknown_type', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle repost without originalPostData', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.setQueryData.mockImplementation((key, updater) => { + if (typeof updater === 'function') { + const oldData = { + data: [{ postId: 1, isRepost: true, originalPostData: null }], + }; + return updater(oldData); + } + return updater; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should not update when old data is null', async () => { + mockQueryClient.getQueryData.mockReturnValue(null); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); + }); + + it('should use profile path query key', async () => { + vi.mocked(usePathname).mockReturnValue('/testuser'); + vi.mocked(useProfileStore).mockImplementation((selector) => + selector({ + currentProfile: { User: { id: 1, username: 'testuser' } }, + } as never) + ); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); + }); + + it('should use explore path query key', async () => { + vi.mocked(usePathname).mockReturnValue('/explore'); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); + }); + + it('should use interest path query key', async () => { + vi.mocked(usePathname).mockReturnValue('/explore/technology'); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); + }); + + it('should handle reply postType', async () => { + vi.mocked(usePathname).mockReturnValue('/home'); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10, 'reply', 5); + + expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); + }); + + it('should handle optimisticsInterests for explore for you', async () => { + mockQueryClient.getQueryData.mockImplementation((key) => { + if (JSON.stringify(key) === JSON.stringify(['explore', 'for-you'])) { + return { + data: { + category1: [{ postId: 1, isRepost: false, likesCount: 5 }], + }, + }; + } + return null; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle optimisticsTabs with infinite data', async () => { + mockQueryClient.getQueryData.mockImplementation((key) => { + if (JSON.stringify(key).includes('timeline')) { + return { + pages: [ + { + data: { + posts: [ + { postId: 1, isRepost: false, likesCount: 5, userId: 1 }, + ], + }, + }, + ], + pageParams: [1], + }; + } + return null; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle infinite data with repost', async () => { + mockQueryClient.getQueryData.mockImplementation((key) => { + if (JSON.stringify(key).includes('timeline')) { + return { + pages: [ + { + data: { + posts: [ + { + postId: 2, + isRepost: true, + originalPostData: { postId: 1, likesCount: 5 }, + userId: 1, + }, + ], + }, + }, + ], + pageParams: [1], + }; + } + return null; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle interests data with repost', async () => { + mockQueryClient.getQueryData.mockImplementation((key) => { + if (JSON.stringify(key) === JSON.stringify(['explore', 'for-you'])) { + return { + data: { + category1: [ + { + postId: 2, + isRepost: true, + originalPostData: { postId: 1, likesCount: 5 }, + userId: 1, + }, + ], + }, + }; + } + return null; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle multiple pages in infinite data', async () => { + mockQueryClient.getQueryData.mockImplementation((key) => { + if (JSON.stringify(key).includes('timeline')) { + return { + pages: [ + { + data: { + posts: [ + { postId: 1, isRepost: false, likesCount: 5, userId: 1 }, + ], + }, + }, + { + data: { + posts: [ + { postId: 1, isRepost: false, likesCount: 5, userId: 1 }, + ], + }, + }, + ], + pageParams: [1, 2], + }; + } + return null; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle post not found in pages', async () => { + mockQueryClient.getQueryData.mockImplementation((key) => { + if (JSON.stringify(key).includes('timeline')) { + return { + pages: [ + { + data: { + posts: [ + { postId: 999, isRepost: false, likesCount: 5, userId: 1 }, + ], + }, + }, + ], + pageParams: [1], + }; + } + return null; + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle empty username fallback', async () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + } as ReturnType); + vi.mocked(useProfileStore).mockImplementation((selector) => + selector({ currentProfile: null } as never) + ); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('like', 1, 1, 10); + + expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); + }); + + it('should update repost type without originalPostData', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.getQueryData.mockReturnValue({ + data: [{ postId: 1, isRepost: false, retweetsCount: 5 }], + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('repost', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should update reply type without originalPostData', async () => { + // Mock path to be a full tweet view + vi.mocked(usePathname).mockReturnValue('/home/1'); + + mockQueryClient.getQueryData.mockReturnValue({ + data: [{ postId: 1, isRepost: false, commentsCount: 5 }], + }); + + const { result } = renderHook(() => useRealTimeTweet()); + await result.current.onMutate('reply', 1, 1, 10); + + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); }); }); }); diff --git a/src/features/timeline/tests/Reply.test.tsx b/src/features/timeline/tests/Reply.test.tsx index 24b1563e..61575950 100644 --- a/src/features/timeline/tests/Reply.test.tsx +++ b/src/features/timeline/tests/Reply.test.tsx @@ -5,14 +5,38 @@ import React from 'react'; // Mock Tweet component vi.mock('@/features/tweets/components/Tweet', () => ({ - default: ({ data }: any) => ( -
{data?.text || 'Tweet'}
+ default: ({ + data, + showColumn, + showBorder, + showUpperColumn, + inProfile, + }: { + data: { text?: string }; + showColumn?: boolean; + showBorder?: boolean; + showUpperColumn?: boolean; + inProfile?: boolean; + }) => ( +
+ {data?.text || 'Tweet'} +
), })); // Mock DeletedTweet vi.mock('@/features/tweets/components/DeletedTweet', () => ({ - default: () =>
Deleted Tweet
, + default: ({ id }: { id: number }) => ( +
+ Deleted Tweet +
+ ), })); // Mock useTweetById @@ -24,6 +48,7 @@ vi.mock('@/features/tweets/hooks/tweetQueries', () => ({ })); import Reply from '../components/Reply'; +import { ADD_TWEET } from '../constants/tweetConstants'; describe('Reply', () => { const mockReplyData = { @@ -51,17 +76,17 @@ describe('Reply', () => { }); it('should render reply component', () => { - render(); + render(); expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); }); it('should render without original post', () => { - render(); + render(); expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); }); it('should render in profile mode', () => { - render(); + render(); expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); }); @@ -73,7 +98,124 @@ describe('Reply', () => { isDeleted: true, }, }; - render(); + render(); expect(screen.getByTestId('deleted-tweet')).toBeInTheDocument(); }); + + it('should show column when withoutOriginal is true', () => { + render(); + const tweets = screen.getAllByTestId('tweet'); + const replyTweet = tweets[tweets.length - 1]; + expect(replyTweet.getAttribute('data-show-column')).toBe('true'); + }); + + it('should show border when withoutOriginal is false', () => { + render(); + const tweets = screen.getAllByTestId('tweet'); + const replyTweet = tweets[tweets.length - 1]; + expect(replyTweet.getAttribute('data-show-border')).toBe('true'); + }); + + it('should render nested reply when original is also a reply', () => { + const nestedReplyData = { + ...mockReplyData, + originalPostData: { + ...mockReplyData.originalPostData, + type: ADD_TWEET.REPLY, + originalPostData: { + postId: 3, + userId: 3, + text: 'Root post', + date: '2024-01-01', + isRepost: false, + isQuote: false, + isDeleted: false, + type: 'POST', + }, + }, + }; + render(); + expect(screen.getAllByTestId('tweet').length).toBeGreaterThanOrEqual(1); + }); + + it('should handle missing originalPostData', () => { + const dataWithoutOriginal = { + postId: 1, + userId: 1, + text: 'Reply without original', + date: '2024-01-01', + isRepost: false, + isQuote: false, + type: 'REPLY', + originalPostData: null, + }; + render(); + expect(screen.getByTestId('tweet')).toBeInTheDocument(); + }); + + it('should pass inProfile prop to Tweet components', () => { + render(); + const tweets = screen.getAllByTestId('tweet'); + tweets.forEach((tweet) => { + expect(tweet.getAttribute('data-in-profile')).toBe('true'); + }); + }); + + it('should show upper column when original is deleted', () => { + const deletedData = { + ...mockReplyData, + originalPostData: { + ...mockReplyData.originalPostData, + isDeleted: true, + }, + }; + render(); + const tweet = screen.getByTestId('tweet'); + expect(tweet.getAttribute('data-show-upper-column')).toBe('true'); + }); + + it('should render original post with showColumn true', () => { + render(); + const tweets = screen.getAllByTestId('tweet'); + const originalTweet = tweets[0]; + expect(originalTweet.getAttribute('data-show-column')).toBe('true'); + }); + + it('should handle quote in original post', () => { + const quoteData = { + ...mockReplyData, + originalPostData: { + ...mockReplyData.originalPostData, + isQuote: true, + }, + }; + render(); + expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); + }); + + it('should handle repost in original post', () => { + const repostData = { + ...mockReplyData, + originalPostData: { + ...mockReplyData.originalPostData, + isRepost: true, + }, + }; + render(); + expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); + }); + + it('should pass deleted tweet id correctly', () => { + const deletedData = { + ...mockReplyData, + originalPostData: { + ...mockReplyData.originalPostData, + postId: 42, + isDeleted: true, + }, + }; + render(); + const deletedTweet = screen.getByTestId('deleted-tweet'); + expect(deletedTweet.getAttribute('data-id')).toBe('42'); + }); }); diff --git a/src/features/timeline/tests/ReplyQuoteSection.test.tsx b/src/features/timeline/tests/ReplyQuoteSection.test.tsx index a1dcfa06..17fbc20b 100644 --- a/src/features/timeline/tests/ReplyQuoteSection.test.tsx +++ b/src/features/timeline/tests/ReplyQuoteSection.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; @@ -23,9 +23,10 @@ vi.mock('../store/useTimelineStore', () => ({ })); // Mock useAddTweet +const mockMutate = vi.fn(); vi.mock('../hooks/timelineQueries', () => ({ useAddTweet: vi.fn(() => ({ - mutate: vi.fn(), + mutate: mockMutate, isPending: false, isSuccess: false, isError: false, @@ -34,8 +35,21 @@ vi.mock('../hooks/timelineQueries', () => ({ // Mock TweetSubmitSection vi.mock('./TweetSubmitSection', () => ({ - default: ({ label, handleAddTweet, enableAddTweet }: any) => ( -
+ default: ({ + label, + handleAddTweet, + enableAddTweet, + enableSection, + }: { + label: string; + handleAddTweet: () => void; + enableAddTweet: boolean; + enableSection: boolean; + }) => ( +
+
), })); @@ -57,6 +72,7 @@ vi.mock('@/components/ui/home/ToasterMessage', () => ({ })); import TweetList from '../components/TweetList'; +import { useTimelineFeed } from '../hooks/timelineQueries'; describe('TweetList', () => { beforeEach(() => { @@ -82,4 +98,187 @@ describe('TweetList', () => { render(); expect(screen.getByTestId('tweet')).toBeInTheDocument(); }); + + it('should show loading state when isLoading is true', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + isError: false, + error: null, + } as ReturnType); + + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should show loading state when fetching without initial data', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: { + pages: [{ data: { posts: [] } }], + }, + isLoading: false, + isFetching: true, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + isError: false, + error: null, + } as unknown as ReturnType); + + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should show error state when isError is true', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + isError: true, + error: { message: 'Error loading timeline' }, + } as unknown as ReturnType); + + render(); + expect( + screen.queryByTestId('tweet-list-container') + ).not.toBeInTheDocument(); + }); + + it('should render multiple tweets', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { userId: 1, postId: 1, date: '2024-01-01', text: 'Tweet 1' }, + { userId: 2, postId: 2, date: '2024-01-02', text: 'Tweet 2' }, + { userId: 3, postId: 3, date: '2024-01-03', text: 'Tweet 3' }, + ], + }, + }, + ], + }, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + isError: false, + error: null, + } as unknown as ReturnType); + + render(); + expect(screen.getAllByTestId('tweet')).toHaveLength(3); + }); + + it('should render multiple pages of tweets', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { userId: 1, postId: 1, date: '2024-01-01', text: 'Page 1' }, + ], + }, + }, + { + data: { + posts: [ + { userId: 2, postId: 2, date: '2024-01-02', text: 'Page 2' }, + ], + }, + }, + ], + }, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + isError: false, + error: null, + } as unknown as ReturnType); + + render(); + expect(screen.getAllByTestId('tweet')).toHaveLength(2); + }); + + it('should indicate hasMoreData when hasNextPage is true', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { userId: 1, postId: 1, date: '2024-01-01', text: 'Tweet' }, + ], + }, + }, + ], + }, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: true, + fetchNextPage: mockFetchNextPage, + isError: false, + error: null, + } as unknown as ReturnType); + + render(); + const infiniteScroll = screen.getByTestId('infinite-scroll'); + expect(infiniteScroll.getAttribute('data-has-more')).toBe('true'); + }); + + it('should not show hasMoreData when isFetchingNextPage is true', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: { + pages: [ + { + data: { + posts: [ + { userId: 1, postId: 1, date: '2024-01-01', text: 'Tweet' }, + ], + }, + }, + ], + }, + isLoading: false, + isFetching: false, + isFetchingNextPage: true, + hasNextPage: true, + fetchNextPage: mockFetchNextPage, + isError: false, + error: null, + } as unknown as ReturnType); + + render(); + const infiniteScroll = screen.getByTestId('infinite-scroll'); + expect(infiniteScroll.getAttribute('data-has-more')).toBe('false'); + }); + + it('should handle empty data gracefully', () => { + vi.mocked(useTimelineFeed).mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + isError: false, + error: null, + } as ReturnType); + + render(); + expect(screen.getByTestId('tweet-list-container')).toBeInTheDocument(); + }); }); diff --git a/src/features/timeline/tests/TweetOptionsBar.test.tsx b/src/features/timeline/tests/TweetOptionsBar.test.tsx index 99f1f2ee..46d95ae7 100644 --- a/src/features/timeline/tests/TweetOptionsBar.test.tsx +++ b/src/features/timeline/tests/TweetOptionsBar.test.tsx @@ -1,9 +1,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; +// Mock next/navigation +const mockReplace = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: mockReplace, + })), +})); + // Mock AddPostContext +const mockOpen = vi.fn(); +const mockClose = vi.fn(); vi.mock('../store/AddPostContext', () => ({ useAddPostContext: vi.fn(() => ({ useMedia: () => [], @@ -12,32 +23,54 @@ vi.mock('../store/AddPostContext', () => ({ addMedia: vi.fn(), addGifs: vi.fn(), setEmoji: vi.fn(), - open: vi.fn(), - close: vi.fn(), + open: mockOpen, + close: mockClose, }), })), })); -// Mock next/navigation -vi.mock('next/navigation', () => ({ - useRouter: vi.fn(() => ({ - push: vi.fn(), - replace: vi.fn(), - })), -})); - // Mock Icon vi.mock('@/components/ui/home/Icon', () => ({ - default: ({ title, ...props }: any) => ( - ), })); vi.mock('../../../components/ui/home/Icon', () => ({ - default: ({ title, ...props }: any) => ( - ), @@ -58,6 +91,7 @@ vi.mock('@/features/media/components/Emoji', () => ({ })); import TweetOptionsBar from '../components/TweetOptionsBar'; +import { useAddPostContext } from '../store/AddPostContext'; describe('TweetOptionsBar', () => { beforeEach(() => { @@ -71,7 +105,6 @@ describe('TweetOptionsBar', () => { it('should render GIF icon', () => { render(); - // The icon renders with title "GIF" expect(screen.getByText('GIF')).toBeInTheDocument(); }); @@ -85,9 +118,85 @@ describe('TweetOptionsBar', () => { expect(screen.getByTestId('emoji')).toBeInTheDocument(); }); - it('should hide GIF icon when showGif is false', () => { - render(); - // When showGif is false, GIF icon should still render but may be hidden via CSS - expect(screen.getByTestId('tweet-options-bar')).toBeInTheDocument(); + it('should open GIF when clicking GIF icon and not open', () => { + render(); + + fireEvent.click(screen.getByTestId('tweet-option-gif')); + + expect(mockOpen).toHaveBeenCalled(); + }); + + it('should close GIF and replace route when clicking GIF icon while open', () => { + vi.mocked(useAddPostContext).mockReturnValue({ + useMedia: () => [], + useGifVisibility: () => true, + useActions: () => ({ + addMedia: vi.fn(), + addGifs: vi.fn(), + setEmoji: vi.fn(), + open: mockOpen, + close: mockClose, + }), + } as ReturnType); + + render(); + + fireEvent.click(screen.getByTestId('tweet-option-gif')); + + expect(mockClose).toHaveBeenCalled(); + expect(mockReplace).toHaveBeenCalledWith('home', { scroll: false }); + }); + + it('should not open GIF when max media reached', () => { + vi.mocked(useAddPostContext).mockReturnValue({ + useMedia: () => [{}, {}, {}, {}], + useGifVisibility: () => false, + useActions: () => ({ + addMedia: vi.fn(), + addGifs: vi.fn(), + setEmoji: vi.fn(), + open: mockOpen, + close: mockClose, + }), + } as ReturnType); + + render(); + + const gifButton = screen.getByTestId('tweet-option-gif'); + fireEvent.click(gifButton); + + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it('should disable GIF icon when max media reached', () => { + vi.mocked(useAddPostContext).mockReturnValue({ + useMedia: () => [{}, {}, {}, {}], + useGifVisibility: () => false, + useActions: () => ({ + addMedia: vi.fn(), + addGifs: vi.fn(), + setEmoji: vi.fn(), + open: mockOpen, + close: mockClose, + }), + } as ReturnType); + + render(); + + const gifIcon = screen.getByTestId('tweet-option-gif'); + expect(gifIcon.getAttribute('data-disabled')).toBe('true'); + }); + + it('should render location icon as disabled', () => { + render(); + const locationIcon = screen.getByTestId('tweet-option-location'); + expect(locationIcon).toBeInTheDocument(); + }); + + it('should have correct layout classes', () => { + render(); + const optionsBar = screen.getByTestId('tweet-options-bar'); + expect(optionsBar.className).toContain('flex'); + expect(optionsBar.className).toContain('items-center'); }); }); diff --git a/src/features/timeline/tests/TweetText.test.tsx b/src/features/timeline/tests/TweetText.test.tsx index 98472785..97bb4207 100644 --- a/src/features/timeline/tests/TweetText.test.tsx +++ b/src/features/timeline/tests/TweetText.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; @@ -13,15 +13,22 @@ const mockSetKeyDown = vi.fn(); const mockClearEmoji = vi.fn(); const mockSetMentions = vi.fn(); +let mockIsOpen = false; +let mockIsSuccess = false; +let mockEmoji = ''; +let mockMentionIsDone = ''; +let mockMention = ''; +let mockFirstTweetText = ''; + vi.mock('../store/AddPostContext', () => ({ useAddPostContext: vi.fn(() => ({ - useTweetText: () => '', + useTweetText: () => mockFirstTweetText, useMedia: () => [], - useEmoji: () => '', - useMention: () => '', - useIsOpen: () => false, - useIsSuccess: () => false, - useMentionIsDone: () => '', + useEmoji: () => mockEmoji, + useMention: () => mockMention, + useIsOpen: () => mockIsOpen, + useIsSuccess: () => mockIsSuccess, + useMentionIsDone: () => mockMentionIsDone, useCurrentKey: () => '', usePlaceHolder: () => "What's happening?", useActions: () => ({ @@ -54,10 +61,28 @@ vi.mock('../hooks/timelineQueries', () => ({ })); import TweetText from '../components/TweetText'; +import { useCheckValidUser } from '../hooks/timelineQueries'; describe('TweetText', () => { beforeEach(() => { vi.clearAllMocks(); + mockIsOpen = false; + mockIsSuccess = false; + mockEmoji = ''; + mockMentionIsDone = ''; + mockMention = ''; + mockFirstTweetText = ''; + + // Mock window.getSelection + Object.defineProperty(window, 'getSelection', { + writable: true, + value: vi.fn().mockReturnValue({ + anchorNode: null, + anchorOffset: 0, + removeAllRanges: vi.fn(), + addRange: vi.fn(), + }), + }); }); it('should render textarea', () => { @@ -109,4 +134,167 @@ describe('TweetText', () => { fireEvent.keyDown(input, { key: 'Enter' }); expect(input).toBeDefined(); }); + + it('should handle input event and call setTweetText', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + input.innerText = 'Hello world'; + fireEvent.input(input); + expect(mockSetTweetText).toHaveBeenCalledWith('Hello world'); + }); + + it('should reset when text is empty', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + input.innerText = ''; + fireEvent.input(input); + expect(mockSetMention).toHaveBeenCalledWith(''); + expect(mockSetIsDone).toHaveBeenCalledWith(''); + expect(mockSetIsOpen).toHaveBeenCalledWith(false); + }); + + it('should handle ArrowUp key when isOpen is true', () => { + mockIsOpen = true; + render(); + const input = screen.getByTestId('tweet-text-input'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(mockSetKeyDown).toHaveBeenCalledWith('ArrowUp'); + }); + + it('should handle ArrowDown key when isOpen is true', () => { + mockIsOpen = true; + render(); + const input = screen.getByTestId('tweet-text-input'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(mockSetKeyDown).toHaveBeenCalledWith('ArrowDown'); + }); + + it('should handle Enter key when isOpen is true', () => { + mockIsOpen = true; + render(); + const input = screen.getByTestId('tweet-text-input'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(mockSetKeyDown).toHaveBeenCalledWith('Enter'); + }); + + it('should not call setKeyDown when isOpen is false', () => { + mockIsOpen = false; + render(); + const input = screen.getByTestId('tweet-text-input'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(mockSetKeyDown).not.toHaveBeenCalled(); + }); + + it('should prevent default on Ctrl+Z', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + const event = new KeyboardEvent('keydown', { + key: 'z', + ctrlKey: true, + bubbles: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + input.dispatchEvent(event); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should clear input when isSuccess is true', () => { + mockIsSuccess = true; + render(); + expect(mockSetIsDone).toHaveBeenCalledWith(''); + expect(mockSetIsOpen).toHaveBeenCalledWith(false); + expect(mockSetMention).toHaveBeenCalledWith(''); + }); + + it('should clear br tags from innerHTML', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + // Simulate what happens when user types and deletes all content + input.innerText = ''; + input.innerHTML = '
'; + fireEvent.input(input); + // The br tag should be cleared + expect(input.innerHTML).toBe(''); + }); + + it('should handle text with mentions', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + input.innerText = '@testuser'; + fireEvent.input(input); + expect(mockSetTweetText).toHaveBeenCalled(); + }); + + it('should handle valid user data for mentions', () => { + vi.mocked(useCheckValidUser).mockReturnValue({ + data: { data: { User: { id: 1 } } }, + isLoading: false, + isError: false, + error: null, + } as ReturnType); + + render(); + const input = screen.getByTestId('tweet-text-input'); + input.innerText = '@validuser hello'; + fireEvent.input(input); + expect(mockSetTweetText).toHaveBeenCalled(); + }); + + it('should render overflow text when exceeding max length', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + const longText = 'a'.repeat(300); + input.innerText = longText; + fireEvent.input(input); + const overflow = screen.getByTestId('tweet-text-overflow'); + expect(overflow).toBeInTheDocument(); + }); + + it('should initialize with firstTweetText if provided', () => { + mockFirstTweetText = 'initial text'; + render(); + expect(mockSetTweetText).toHaveBeenCalled(); + }); + + it('should handle mousedown for cursor position', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + act(() => { + input.focus(); + }); + fireEvent.mouseDown(document); + expect(screen.getByTestId('tweet-text-input')).toBeDefined(); + }); + + it('should render mention component when visible', () => { + mockIsOpen = true; + mockMention = '@test'; + render(); + expect(screen.getByTestId('tweet-text-input')).toBeInTheDocument(); + }); + + it('should handle mention completion', () => { + mockMention = 'testuser'; + mockMentionIsDone = 'testuser 123'; + render(); + expect(screen.getByTestId('tweet-text-input')).toBeInTheDocument(); + }); + + it('should have spellcheck enabled', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + expect(input).toHaveAttribute('spellCheck', 'true'); + }); + + it('should have proper aria-label for accessibility', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + expect(input).toHaveAttribute('aria-label', 'Tweet text input overlay'); + }); + + it('should update placeholder when prop changes', () => { + const { rerender } = render(); + rerender(); + expect(screen.getByText('Post your reply')).toBeInTheDocument(); + }); }); diff --git a/src/features/timeline/tests/TweetsOptimistics.test.tsx b/src/features/timeline/tests/TweetsOptimistics.test.tsx index 5f9fa97b..86fd110c 100644 --- a/src/features/timeline/tests/TweetsOptimistics.test.tsx +++ b/src/features/timeline/tests/TweetsOptimistics.test.tsx @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; +const mockRouterPush = vi.fn(); +const mockSetBlockedFlag = vi.fn(); +const mockSetCurrentTweet = vi.fn(); + // Mock react-query const mockQueryClient = { getQueryData: vi.fn(), @@ -32,12 +36,14 @@ vi.mock('@/features/explore/store/useExploreStore', () => ({ vi.mock('@/features/profile/store/profileStore', () => ({ useSelectedTab: vi.fn(() => 'Posts'), useActions: vi.fn(() => ({ - setCurrentProfile: vi.fn(), + setBlockedFlag: mockSetBlockedFlag, })), })); vi.mock('@/features/profile', () => ({ - useProfileStore: vi.fn((selector) => selector({ currentProfile: null })), + useProfileStore: vi.fn((selector) => + selector({ currentProfile: { User: { id: 2, username: 'otheruser' } } }) + ), PROFILE_QUERY_KEYS: { profilePosts: (id: number) => ['profile', 'posts', id], profileReplies: (id: number) => ['profile', 'replies', id], @@ -54,11 +60,14 @@ vi.mock('@/features/authentication/hooks', () => ({ })), })); +// Path mock value that can be changed between tests +let mockPathname = '/home'; + // Mock navigation vi.mock('next/navigation', () => ({ - usePathname: vi.fn(() => '/home'), + usePathname: vi.fn(() => mockPathname), useRouter: vi.fn(() => ({ - push: vi.fn(), + push: mockRouterPush, })), })); @@ -67,11 +76,11 @@ vi.mock('@/features/tweets/store/tweetStore', () => ({ useTweetStore: vi.fn((selector) => { if (typeof selector === 'function') { return selector({ - setCurrentTweet: vi.fn(), - currentTweet: null, + setCurrentTweet: mockSetCurrentTweet, + currentTweet: { userId: 5, originalPostData: { userId: 6 } }, }); } - return { setCurrentTweet: vi.fn(), currentTweet: null }; + return { setCurrentTweet: mockSetCurrentTweet, currentTweet: null }; }), })); @@ -120,6 +129,7 @@ import { describe('Tweets optimistics', () => { beforeEach(() => { vi.clearAllMocks(); + mockQueryClient.getQueryData.mockReturnValue(undefined); }); describe('useTimelineQueryKey', () => { @@ -165,139 +175,106 @@ describe('Tweets optimistics', () => { it('should call onMutate with LIKE type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('LIKE', 1, 1); - + const response = await result.current.onMutate('like', 1, 1); expect(response).toHaveProperty('previousFeeds'); expect(response).toHaveProperty('oldTweet'); }); it('should call onMutate with REPOST type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('REPOST', 1, 1); - + const response = await result.current.onMutate('repost', 1, 1); expect(response).toBeDefined(); }); it('should call onMutate with BLOCK type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('BLOCK', 1, 1); - + const response = await result.current.onMutate('block', 1, 1); expect(response).toBeDefined(); }); it('should call onMutate with DELETE type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('DELETE', 1, 1); - + const response = await result.current.onMutate('delete', 1, 1); expect(response).toBeDefined(); }); it('should call handleErrorOptimisticTweet with valid context', () => { const { result } = renderHook(() => useOptimisticTweet()); - const context = { previousFeeds: [ { queryKey: ['timeline', 'forYou'], previousFeed: { pages: [] } }, ], - oldTweet: null, + oldTweet: undefined, }; - - // Call handleErrorOptimisticTweet with proper context result.current.handleErrorOptimisticTweet(context); - - // Should not throw expect(mockQueryClient.setQueryData).toHaveBeenCalled(); }); it('should handle handleErrorOptimisticTweet with empty context', () => { const { result } = renderHook(() => useOptimisticTweet()); - - // Call with undefined context should not throw expect(() => result.current.handleErrorOptimisticTweet(undefined) ).not.toThrow(); }); - it('should call onMutate with FOLLOW type', async () => { + it('should call onMutate with follow type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('FOLLOW', 1, 1); - + const response = await result.current.onMutate('follow', 1, 1); expect(response).toBeDefined(); expect(response).toHaveProperty('previousFeeds'); }); - it('should call onMutate with MUTE type', async () => { + it('should call onMutate with mute type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('MUTE', 1, 1); - + const response = await result.current.onMutate('mute', 1, 1); expect(response).toBeDefined(); }); it('should call onMutate with Quote type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('Quote', 1, 1); - + const response = await result.current.onMutate('repost', 1, 1); expect(response).toBeDefined(); }); - it('should call onMutate with REPLY type', async () => { + it('should call onMutate with reply type', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('REPLY', 1, 1, 'reply', 2); - + const response = await result.current.onMutate('reply', 1, 1, 'reply', 2); expect(response).toBeDefined(); }); it('should call onMutate without tweetId', async () => { const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('LIKE', 1); - + const response = await result.current.onMutate('like', 1); expect(response).toBeDefined(); }); it('should handle multiple previousFeeds in handleErrorOptimisticTweet', () => { const { result } = renderHook(() => useOptimisticTweet()); - const context = { previousFeeds: [ { queryKey: ['timeline', 'forYou'], previousFeed: { pages: [] } }, { queryKey: ['timeline', 'following'], previousFeed: { pages: [] } }, { queryKey: ['explore', 'for-you'], previousFeed: { data: {} } }, ], - oldTweet: { postId: 1, userId: 1 }, + oldTweet: { postId: 1, userId: 1 } as any, }; - result.current.handleErrorOptimisticTweet(context); - expect(mockQueryClient.setQueryData).toHaveBeenCalledTimes(3); }); it('should handle previousFeeds with null previousFeed', () => { const { result } = renderHook(() => useOptimisticTweet()); - const context = { previousFeeds: [ - { queryKey: ['timeline', 'forYou'], previousFeed: null }, + { queryKey: ['timeline', 'forYou'], previousFeed: undefined }, ], - oldTweet: null, + oldTweet: undefined, }; - result.current.handleErrorOptimisticTweet(context); - - // Should not throw even with null previousFeed expect(true).toBe(true); }); it('should process LIKE mutation with existing feed data', async () => { - // Mock getQueryData to return mock feed data const mockFeed = { pages: [ { @@ -316,15 +293,64 @@ describe('Tweets optimistics', () => { ], }; mockQueryClient.getQueryData.mockReturnValue(mockFeed); - const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('LIKE', 1, 1); - + const response = await result.current.onMutate('like', 1, 1); expect(response.previousFeeds).toBeDefined(); expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); }); + it('should process LIKE mutation with liked tweet', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + likesCount: 5, + isLikedByMe: true, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should process LIKE mutation with repost', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + likesCount: 10, + isLikedByMe: false, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 2); + expect(response).toBeDefined(); + }); + it('should process REPOST mutation with existing feed data', async () => { const mockFeed = { pages: [ @@ -344,11 +370,38 @@ describe('Tweets optimistics', () => { ], }; mockQueryClient.getQueryData.mockReturnValue(mockFeed); - const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); - const response = await result.current.onMutate('REPOST', 1, 1); - + it('should process REPOST mutation when already reposted by me', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + retweetsCount: 3, + isRepostedByMe: true, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + retweetsCount: 5, + isRepostedByMe: true, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 2); expect(response.previousFeeds).toBeDefined(); }); @@ -369,11 +422,106 @@ describe('Tweets optimistics', () => { ], }; mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + it('should process DELETE mutation with quote tweet', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: false, + isQuote: true, + type: 'POST', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); - const response = await result.current.onMutate('DELETE', 1, 1); + it('should process DELETE mutation with reply tweet', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: false, + isQuote: false, + type: 'REPLY', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + isQuote: true, + originalPostData: { + postId: 3, + userId: 3, + isDeleted: false, + }, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 3); + expect(response.previousFeeds).toBeDefined(); + }); + it('should process DELETE mutation with repost containing nested original', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: true, + isQuote: false, + type: 'POST', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + originalPostData: { + postId: 3, + userId: 3, + isDeleted: false, + }, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 3); expect(response.previousFeeds).toBeDefined(); }); @@ -395,15 +543,12 @@ describe('Tweets optimistics', () => { ], }; mockQueryClient.getQueryData.mockReturnValue(mockFeed); - const { result } = renderHook(() => useOptimisticTweet()); - - const response = await result.current.onMutate('FOLLOW', 2, 1); - + const response = await result.current.onMutate('follow', 2, 1); expect(response.previousFeeds).toBeDefined(); }); - it('should handle repost with original post data', async () => { + it('should process FOLLOW mutation with original post data', async () => { const mockFeed = { pages: [ { @@ -412,12 +557,12 @@ describe('Tweets optimistics', () => { { postId: 1, userId: 1, + isFollowedByMe: false, isRepost: true, originalPostData: { postId: 2, userId: 2, - likesCount: 10, - isLikedByMe: false, + isFollowedByMe: false, }, }, ], @@ -426,11 +571,1598 @@ describe('Tweets optimistics', () => { ], }; mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('follow', 2, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should process BLOCK mutation', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 2, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('block', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); + it('should process MUTE mutation', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 2, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('mute', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); - const response = await result.current.onMutate('LIKE', 2, 2); + it('should process reply mutation', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + commentsCount: 5, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('reply', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + it('should process reply mutation with repost', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + commentsCount: 5, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('reply', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests feed data', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + likesCount: 5, + isLikedByMe: false, + isRepost: false, + }, + ], + Sports: [ + { + postId: 2, + userId: 2, + likesCount: 3, + isLikedByMe: false, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests feed data with DELETE', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests feed data with BLOCK', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 2, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('block', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests feed data with FOLLOW', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 2, + isFollowedByMe: false, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('follow', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests feed data with repost in category', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + likesCount: 5, + isLikedByMe: false, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should update tweet by id', async () => { + const mockTweet = { + data: [ + { + postId: 1, + userId: 1, + likesCount: 5, + isLikedByMe: false, + isRepost: false, + }, + ], + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if (queryKey && queryKey[0] === 'tweet' && queryKey[1] === 1) { + return mockTweet; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response).toBeDefined(); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle default type in updateTweet', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('unknown', 1, 1); + expect(response).toBeDefined(); + }); + + it('should handle handleErrorOptimisticTweet with oldTweet', () => { + const { result } = renderHook(() => useOptimisticTweet()); + const mockTweet = { + postId: 1, + userId: 1, + likesCount: 5, + isLikedByMe: false, + isRepost: false, + }; + const context = { + previousFeeds: [], + oldTweet: mockTweet as any, + }; + result.current.handleErrorOptimisticTweet(context); + expect(mockSetCurrentTweet).toHaveBeenCalledWith(mockTweet); + }); + + it('should handle quote mutation', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + retweetsCount: 3, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + // Quote type is same as repost in constants + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle quote mutation with repost data', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + retweetsCount: 5, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests delete with quote tweet', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isQuote: true, + isRepost: false, + type: 'POST', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests delete with nested original', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isQuote: false, + isRepost: false, + type: 'REPLY', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + isQuote: true, + originalPostData: { + postId: 3, + userId: 3, + isDeleted: false, + }, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 3); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests delete with repost nested original', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isQuote: false, + isRepost: true, + type: 'POST', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + originalPostData: { + postId: 3, + userId: 3, + isDeleted: false, + }, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 3); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle multiple pages of feed data', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 1, userId: 1, likesCount: 5, isRepost: false }], + }, + }, + { + data: { + posts: [{ postId: 2, userId: 1, likesCount: 3, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle empty posts array', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle like count going to zero', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + likesCount: 0, + isLikedByMe: true, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle retweet count going to zero', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + retweetsCount: 0, + isRepostedByMe: true, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle BLOCK with currentTweet userId matching - triggers router.push', async () => { + // Mock currentTweet to match the userId being blocked + vi.doMock('@/features/tweets/store/tweetStore', () => ({ + useTweetStore: vi.fn((selector) => { + if (typeof selector === 'function') { + return selector({ + setCurrentTweet: mockSetCurrentTweet, + currentTweet: { userId: 3, originalPostData: null }, + }); + } + return { + setCurrentTweet: mockSetCurrentTweet, + currentTweet: { userId: 3 }, + }; + }), + })); + + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 1, userId: 3, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('block', 3, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle MUTE with currentTweet userId matching - triggers router.push', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 1, userId: 3, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('mute', 3, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle REPOST mutation with isRepostedByMe true and filter reposts', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, // same as myId from auth mock + isRepost: false, + retweetsCount: 3, + isRepostedByMe: true, + originalPostData: { + postId: 2, + userId: 2, + isRepostedByMe: true, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests feed with REPOST and filter reposts', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: false, + retweetsCount: 3, + isRepostedByMe: true, + originalPostData: { + postId: 2, + userId: 2, + isRepostedByMe: true, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle BLOCK with currentTweet originalPostData userId matching', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 1, userId: 6, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('block', 6, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle MUTE with currentTweet originalPostData userId matching', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 1, userId: 6, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('mute', 6, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests BLOCK with currentTweet userId matching', async () => { + const mockInterestsFeed = { + data: { + Technology: [{ postId: 1, userId: 5, isRepost: false }], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('block', 5, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests MUTE with currentTweet originalPostData userId matching', async () => { + const mockInterestsFeed = { + data: { + Technology: [{ postId: 1, userId: 6, isRepost: false }], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('mute', 6, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle DELETE for user-owned post', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, // same as current user + isRepost: false, + isQuote: false, + type: 'POST', + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle FOLLOW when user already followed', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 2, + isFollowedByMe: true, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('follow', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle DELETE with repost filter on interests feed', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: true, + isQuote: false, + type: 'POST', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests FOLLOW with original post data', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: true, + isFollowedByMe: false, + originalPostData: { + postId: 2, + userId: 2, + isFollowedByMe: false, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('follow', 2, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests REPOST with original post data', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: true, + retweetsCount: 5, + isRepostedByMe: false, + originalPostData: { + postId: 2, + userId: 2, + retweetsCount: 3, + isRepostedByMe: false, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests reply mutation', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: false, + commentsCount: 5, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('reply', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests reply mutation with repost', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + commentsCount: 5, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('reply', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle multiple categories in interests feed', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + likesCount: 5, + isLikedByMe: false, + isRepost: false, + }, + ], + Sports: [ + { + postId: 2, + userId: 1, + likesCount: 3, + isLikedByMe: false, + isRepost: false, + }, + ], + Music: [ + { + postId: 3, + userId: 1, + likesCount: 7, + isLikedByMe: false, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle post not found in feed', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 99, userId: 99, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle Quote type mutation', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + retweetsCount: 3, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle Quote type mutation with repost', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + retweetsCount: 5, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests quote mutation', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + retweetsCount: 3, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests mute mutation', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 2, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('mute', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle LIKE mutation with repost that has original liked by me', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + likesCount: 10, + isLikedByMe: true, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle REPOST mutation with repost original not reposted by me', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + retweetsCount: 5, + isRepostedByMe: false, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 2); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle empty interests data categories', async () => { + const mockInterestsFeed = { + data: {}, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle feed with undefined posts', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: undefined, + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle block mutation with profile user', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 2, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('block', 2, 1); + expect(response.previousFeeds).toBeDefined(); + expect(mockSetBlockedFlag).toHaveBeenCalled(); + }); + + it('should handle mute mutation with profile user', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 2, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('mute', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle FOLLOW with both userId matching and originalPostData', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 2, + isFollowedByMe: true, + isRepost: true, + originalPostData: { + postId: 2, + userId: 2, + isFollowedByMe: true, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('follow', 2, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests LIKE with count going to zero', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + likesCount: 0, + isLikedByMe: true, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests REPOST count going to zero', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + retweetsCount: 0, + isRepostedByMe: true, + isRepost: false, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle DELETE mutation for post with nested replies', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + isRepost: false, + isQuote: false, + type: 'REPLY', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + type: 'REPLY', + originalPostData: { + postId: 3, + userId: 3, + isDeleted: false, + }, + }, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 3); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle interests DELETE with nested replies', async () => { + const mockInterestsFeed = { + data: { + Technology: [ + { + postId: 1, + userId: 1, + isRepost: false, + isQuote: false, + type: 'REPLY', + originalPostData: { + postId: 2, + userId: 2, + isDeleted: false, + type: 'REPLY', + originalPostData: { + postId: 3, + userId: 3, + isDeleted: false, + }, + }, + }, + ], + }, + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if ( + queryKey && + JSON.stringify(queryKey) === JSON.stringify(['explore', 'for-you']) + ) { + return mockInterestsFeed; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 3); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle BLOCK matching currentTweet userId directly', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 1, userId: 5, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('block', 5, 1); + expect(response.previousFeeds).toBeDefined(); + }); + + it('should handle MUTE matching currentTweet userId directly', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [{ postId: 1, userId: 5, isRepost: false }], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('mute', 5, 1); + expect(response.previousFeeds).toBeDefined(); + }); + }); + + describe('useOptimisticTweet with full tweet path', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockQueryClient.getQueryData.mockReturnValue(undefined); + // Change the pathname to a full tweet path + mockPathname = '/home/123'; + }); + + afterEach(() => { + // Reset pathname to default + mockPathname = '/home'; + }); + + it('should update tweet by id when on full tweet page', async () => { + const mockTweet = { + data: [ + { + postId: 123, + userId: 1, + likesCount: 5, + isLikedByMe: false, + isRepost: false, + }, + ], + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if (queryKey && queryKey[0] === 'tweet' && queryKey[1] === 123) { + return mockTweet; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); + expect(response).toBeDefined(); + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should update originalPostData when tweetId matches originalPostData.postId', async () => { + const mockTweet = { + data: [ + { + postId: 123, + userId: 1, + likesCount: 5, + isLikedByMe: false, + isRepost: false, + originalPostData: { + postId: 456, + userId: 2, + likesCount: 10, + isLikedByMe: false, + }, + }, + ], + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if (queryKey && queryKey[0] === 'tweet' && queryKey[1] === 123) { + return mockTweet; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 456); + expect(response).toBeDefined(); + expect(mockQueryClient.setQueryData).toHaveBeenCalled(); + }); + + it('should handle full tweet REPOST mutation', async () => { + const mockTweet = { + data: [ + { + postId: 123, + userId: 1, + retweetsCount: 5, + isRepostedByMe: false, + isRepost: false, + }, + ], + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if (queryKey && queryKey[0] === 'tweet' && queryKey[1] === 123) { + return mockTweet; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('repost', 1, 1); + expect(response).toBeDefined(); + }); + + it('should handle full tweet DELETE mutation', async () => { + const mockTweet = { + data: [ + { + postId: 123, + userId: 1, + isRepost: false, + }, + ], + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if (queryKey && queryKey[0] === 'tweet' && queryKey[1] === 123) { + return mockTweet; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('delete', 1, 123); + expect(response).toBeDefined(); + }); + + it('should handle full tweet FOLLOW mutation', async () => { + const mockTweet = { + data: [ + { + postId: 123, + userId: 2, + isFollowedByMe: false, + isRepost: false, + }, + ], + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if (queryKey && queryKey[0] === 'tweet' && queryKey[1] === 123) { + return mockTweet; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('follow', 2, 1); + expect(response).toBeDefined(); + }); + + it('should handle full tweet reply mutation', async () => { + const mockTweet = { + data: [ + { + postId: 123, + userId: 1, + commentsCount: 5, + isRepost: false, + }, + ], + }; + mockQueryClient.getQueryData.mockImplementation((queryKey) => { + if (queryKey && queryKey[0] === 'tweet' && queryKey[1] === 123) { + return mockTweet; + } + return undefined; + }); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('reply', 1, 123); + expect(response).toBeDefined(); + }); + + it('should handle null old data in full tweet update', async () => { + mockQueryClient.getQueryData.mockReturnValue(undefined); + const { result } = renderHook(() => useOptimisticTweet()); + const response = await result.current.onMutate('like', 1, 1); expect(response).toBeDefined(); }); }); diff --git a/src/features/timeline/tests/timelineQueries.test.tsx b/src/features/timeline/tests/timelineQueries.test.tsx index 96f0db45..24863647 100644 --- a/src/features/timeline/tests/timelineQueries.test.tsx +++ b/src/features/timeline/tests/timelineQueries.test.tsx @@ -1,60 +1,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook } from '@testing-library/react'; - -// Mock API_CONFIG first -vi.mock('@/lib/config', () => ({ - API_CONFIG: { - baseUrl: 'http://localhost:3000', - timeout: 5000, - }, -})); +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; // Mock toaster vi.mock('@/components/ui/home/ToasterMessage', () => ({ default: vi.fn(), })); -// Mock react-query -const mockMutate = vi.fn(); -const mockQueryClient = { - refetchQueries: vi.fn(), - invalidateQueries: vi.fn(), - getQueryData: vi.fn(), - setQueryData: vi.fn(), -}; - -vi.mock('@tanstack/react-query', () => ({ - useMutation: vi.fn(() => ({ - mutate: mockMutate, - mutateAsync: vi.fn(), - isPending: false, - isSuccess: false, - isError: false, - error: null, - })), - useQuery: vi.fn(() => ({ - data: null, - isLoading: false, - error: null, - })), - useInfiniteQuery: vi.fn(() => ({ - data: { pages: [] }, - isLoading: false, - hasNextPage: false, - fetchNextPage: vi.fn(), - })), - useQueryClient: () => mockQueryClient, -})); - // Mock timelineApi vi.mock('../services/timelineAPi', () => ({ timelineApi: { addTweet: vi.fn(), - getTimeline: vi.fn(), - searchProfiles: vi.fn(), - searchHashtags: vi.fn(), - validateUser: vi.fn(), - deleteTweet: vi.fn(), + getTimelineFeed: vi.fn(), + searchProfile: vi.fn(), + searchHashtag: vi.fn(), }, })); @@ -76,17 +36,6 @@ vi.mock('../store/AddPostContext', () => ({ clearMedia: vi.fn(), clearEmoji: vi.fn(), }), - useTweetText: () => '', - useMedia: () => [], - usePoll: () => null, - })), -})); - -// Mock optimistics -vi.mock('../optimistics/Tweets', () => ({ - useOptimisticTweet: vi.fn(() => ({ - onMutate: vi.fn(), - handleErrorOptimisticTweet: vi.fn(), })), })); @@ -111,22 +60,64 @@ vi.mock('@/features/profile', () => ({ // Mock useDebounce vi.mock('../hooks/useDebounce', () => ({ - default: vi.fn((value) => value), + default: (value: string) => value, })); // Import after mocks import { TIMELINE_QUERY_KEYS, + useAddTweet, useTimelineFeed, useSearchProfile, useSearchHashtag, useCheckValidUser, useAvatarsPopUp, } from '../hooks/timelineQueries'; +import { timelineApi } from '../services/timelineAPi'; +import { + useSelectedTab, + useFetchAvatars, + useSearch, +} from '../store/useTimelineStore'; +import { useAuth } from '@/features/authentication/hooks'; +import { profileApi } from '@/features/profile'; + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); + +const createWrapper = () => { + const queryClient = createTestQueryClient(); + const Wrapper = function Wrapper({ + children, + }: { + children: React.ReactNode; + }) { + return ( + {children} + ); + }; + return Wrapper; +}; describe('timelineQueries', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(useSelectedTab).mockReturnValue('For you'); + vi.mocked(useFetchAvatars).mockReturnValue(false); + vi.mocked(useSearch).mockReturnValue(''); + vi.mocked(useAuth).mockReturnValue({ + user: { id: 1, username: 'testuser' }, + } as ReturnType); }); describe('TIMELINE_QUERY_KEYS', () => { @@ -186,42 +177,413 @@ describe('timelineQueries', () => { }); }); + describe('useAddTweet', () => { + it('should return mutation object', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAddTweet('POST'), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current.mutate).toBeDefined(); + }); + + it('should handle successful tweet submission', async () => { + vi.mocked(timelineApi.addTweet).mockResolvedValueOnce({ + data: { postId: 123, content: 'Test tweet' }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAddTweet('POST'), { wrapper }); + + const formData = new FormData(); + formData.append('content', 'Test tweet'); + + result.current.mutate(formData); + + await waitFor(() => { + expect(result.current.isPending || result.current.isSuccess).toBe(true); + }); + }); + + it('should handle successful tweet and update cache', async () => { + vi.mocked(timelineApi.addTweet).mockResolvedValueOnce({ + data: { postId: 456, content: 'Cache test tweet' }, + } as never); + + const queryClient = createTestQueryClient(); + // Pre-populate cache with existing data + queryClient.setQueryData(TIMELINE_QUERY_KEYS.TIMELINE_FEED_FOLLOWING, { + pages: [{ data: { posts: [{ postId: 1, content: 'Existing post' }] } }], + pageParams: [1], + }); + queryClient.setQueryData(TIMELINE_QUERY_KEYS.TIMELINE_FEED_FOR_YOU, { + pages: [ + { + data: { posts: [{ postId: 2, content: 'Another existing post' }] }, + }, + { data: { posts: [{ postId: 3, content: 'Third post' }] } }, + ], + pageParams: [1, 2], + }); + + const Wrapper = function Wrapper({ + children, + }: { + children: React.ReactNode; + }) { + return ( + + {children} + + ); + }; + + const { result } = renderHook(() => useAddTweet('POST'), { + wrapper: Wrapper, + }); + + const formData = new FormData(); + result.current.mutate(formData); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + }); + + it('should handle tweet submission for reply label', async () => { + vi.mocked(timelineApi.addTweet).mockResolvedValueOnce({ + data: { postId: 123, content: 'Reply content' }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAddTweet('REPLY'), { wrapper }); + + const formData = new FormData(); + result.current.mutate(formData); + + await waitFor(() => { + expect(result.current.isPending || result.current.isSuccess).toBe(true); + }); + }); + + it('should handle tweet submission error', async () => { + const error = new Error('API Error'); + vi.mocked(timelineApi.addTweet).mockRejectedValueOnce(error); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAddTweet('POST'), { wrapper }); + + const formData = new FormData(); + result.current.mutate(formData); + + await waitFor(() => { + expect( + result.current.isPending || + result.current.isError || + result.current.isSuccess + ).toBe(true); + }); + }); + + it('should handle generic error object', async () => { + vi.mocked(timelineApi.addTweet).mockRejectedValueOnce('Generic error'); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAddTweet('POST'), { wrapper }); + + const formData = new FormData(); + result.current.mutate(formData); + + await waitFor(() => { + expect( + result.current.isPending || + result.current.isError || + result.current.isSuccess + ).toBe(true); + }); + }); + + it('should handle null user', async () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + } as ReturnType); + + vi.mocked(timelineApi.addTweet).mockResolvedValueOnce({ + data: { postId: 123, content: 'Test tweet' }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAddTweet('POST'), { wrapper }); + + const formData = new FormData(); + result.current.mutate(formData); + + await waitFor(() => { + expect(result.current.isPending || result.current.isSuccess).toBe(true); + }); + }); + + it('should handle cache update when old data is null', async () => { + vi.mocked(timelineApi.addTweet).mockResolvedValueOnce({ + data: { postId: 789, content: 'New tweet' }, + } as never); + + const queryClient = createTestQueryClient(); + // No pre-populated cache (old is undefined/null) + + const Wrapper = function Wrapper({ + children, + }: { + children: React.ReactNode; + }) { + return ( + + {children} + + ); + }; + + const { result } = renderHook(() => useAddTweet('POST'), { + wrapper: Wrapper, + }); + + const formData = new FormData(); + result.current.mutate(formData); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + }); + }); + describe('useTimelineFeed', () => { - it('should return timeline feed data', () => { - const { result } = renderHook(() => useTimelineFeed()); + it('should return timeline feed data for For you tab', () => { + vi.mocked(useSelectedTab).mockReturnValue('For you'); + vi.mocked(timelineApi.getTimelineFeed).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTimelineFeed(), { wrapper }); expect(result.current).toBeDefined(); - expect(result.current.data).toBeDefined(); + }); + + it('should return timeline feed data for Following tab', () => { + vi.mocked(useSelectedTab).mockReturnValue('Following'); + vi.mocked(timelineApi.getTimelineFeed).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTimelineFeed(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should return next page param when posts exist', async () => { + vi.mocked(useSelectedTab).mockReturnValue('For you'); + vi.mocked(timelineApi.getTimelineFeed).mockResolvedValueOnce({ + data: { posts: [{ postId: 1 }] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTimelineFeed(), { wrapper }); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + }); + + it('should return undefined next page when no posts', async () => { + vi.mocked(useSelectedTab).mockReturnValue('For you'); + vi.mocked(timelineApi.getTimelineFeed).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTimelineFeed(), { wrapper }); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); }); }); describe('useSearchProfile', () => { it('should return search profile data', () => { - const { result } = renderHook(() => useSearchProfile('testuser')); + vi.mocked(timelineApi.searchProfile).mockResolvedValueOnce({ + data: [], + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchProfile('testuser'), { + wrapper, + }); + + expect(result.current).toBeDefined(); + }); + + it('should not search with empty string', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchProfile(''), { wrapper }); expect(result.current).toBeDefined(); }); + + it('should not search with whitespace only', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchProfile(' '), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should handle search with results', async () => { + vi.mocked(timelineApi.searchProfile).mockResolvedValueOnce({ + data: [{ id: 1, username: 'test' }], + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchProfile('test'), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + }); }); describe('useSearchHashtag', () => { it('should return search hashtag data', () => { - const { result } = renderHook(() => useSearchHashtag()); + vi.mocked(useSearch).mockReturnValue('test'); + vi.mocked(timelineApi.searchHashtag).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchHashtag(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should not search with empty hashtag', () => { + vi.mocked(useSearch).mockReturnValue(''); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchHashtag(), { wrapper }); expect(result.current).toBeDefined(); }); + + it('should trim leading whitespace from hashtag', () => { + vi.mocked(useSearch).mockReturnValue(' trending'); + vi.mocked(timelineApi.searchHashtag).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchHashtag(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should handle search with results', async () => { + vi.mocked(useSearch).mockReturnValue('trending'); + vi.mocked(timelineApi.searchHashtag).mockResolvedValueOnce({ + data: { posts: [{ postId: 1 }] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSearchHashtag(), { wrapper }); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + }); }); describe('useCheckValidUser', () => { it('should return valid user data', () => { - const { result } = renderHook(() => useCheckValidUser('testuser')); + vi.mocked(profileApi.getProfileByUsername).mockResolvedValueOnce({ + data: { id: 1, username: 'testuser' }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCheckValidUser('testuser'), { + wrapper, + }); expect(result.current).toBeDefined(); }); + + it('should not query with empty username', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useCheckValidUser(''), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should handle user not found', async () => { + vi.mocked(profileApi.getProfileByUsername).mockRejectedValueOnce( + new Error('Not found') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useCheckValidUser('unknown'), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + }); }); describe('useAvatarsPopUp', () => { - it('should return avatars popup data', () => { - const { result } = renderHook(() => useAvatarsPopUp()); + it('should return avatars popup data when popup is visible', () => { + vi.mocked(useFetchAvatars).mockReturnValue(true); + vi.mocked(useSelectedTab).mockReturnValue('For you'); + vi.mocked(timelineApi.getTimelineFeed).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAvatarsPopUp(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should not fetch when popup is not visible', () => { + vi.mocked(useFetchAvatars).mockReturnValue(false); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAvatarsPopUp(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should use Following endpoint when on Following tab', () => { + vi.mocked(useFetchAvatars).mockReturnValue(true); + vi.mocked(useSelectedTab).mockReturnValue('Following'); + vi.mocked(timelineApi.getTimelineFeed).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAvatarsPopUp(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it('should use For you endpoint when on For you tab', () => { + vi.mocked(useFetchAvatars).mockReturnValue(true); + vi.mocked(useSelectedTab).mockReturnValue('For you'); + vi.mocked(timelineApi.getTimelineFeed).mockResolvedValueOnce({ + data: { posts: [] }, + } as never); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAvatarsPopUp(), { wrapper }); expect(result.current).toBeDefined(); }); diff --git a/src/features/timeline/tests/useRealTimeTweets.test.tsx b/src/features/timeline/tests/useRealTimeTweets.test.tsx index e6c65703..0af2a304 100644 --- a/src/features/timeline/tests/useRealTimeTweets.test.tsx +++ b/src/features/timeline/tests/useRealTimeTweets.test.tsx @@ -14,21 +14,21 @@ const mockSocket = { connected: true, }; +let shouldThrowSocketError = false; + // Mock socket service vi.mock('@/features/messages/services/socket', () => ({ - getSocket: () => mockSocket, + getSocket: () => { + if (shouldThrowSocketError) { + throw new Error('Socket not initialized'); + } + return mockSocket; + }, })); // Mock optimistic hooks -const mockOptimisticOnMutate = vi.fn(); const mockRealTimeOnMutate = vi.fn(); -vi.mock('../optimistics/Tweets', () => ({ - useOptimisticTweet: vi.fn(() => ({ - onMutate: mockOptimisticOnMutate, - })), -})); - vi.mock('../optimistics/RealTimeTweet', () => ({ useRealTimeTweet: vi.fn(() => ({ onMutate: mockRealTimeOnMutate, @@ -69,6 +69,7 @@ describe('useRealTimeTweets', () => { mockSocket.off.mockReset(); mockSocket.emit.mockReset(); mockSocket.connected = true; + shouldThrowSocketError = false; }); afterEach(() => { @@ -116,6 +117,25 @@ describe('useRealTimeTweets', () => { expect(callback).toHaveBeenCalledWith({ status: 'success' }); }); + it('should warn when join post fails', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(); + + mockSocket.emit.mockImplementation((event, postId, cb) => { + cb({ status: 'error' }); + }); + + act(() => { + result.current.joinPost(123); + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith('failed to join post', { + status: 'error', + }); + consoleWarnSpy.mockRestore(); + }); + it('should not emit when socket is disconnected', () => { mockSocket.connected = false; const wrapper = createWrapper(); @@ -127,6 +147,23 @@ describe('useRealTimeTweets', () => { expect(mockSocket.emit).not.toHaveBeenCalled(); }); + + it('should handle socket error gracefully', () => { + shouldThrowSocketError = true; + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(); + + act(() => { + result.current.joinPost(123); + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not initialized, cannot join post:', + 123 + ); + consoleWarnSpy.mockRestore(); + }); }); describe('leavePost', () => { @@ -161,6 +198,25 @@ describe('useRealTimeTweets', () => { expect(callback).toHaveBeenCalledWith({ status: 'success' }); }); + it('should warn when leave post fails', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(); + + mockSocket.emit.mockImplementation((event, postId, cb) => { + cb({ status: 'error' }); + }); + + act(() => { + result.current.leavePost(123); + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith('failed to leave post', { + status: 'error', + }); + consoleWarnSpy.mockRestore(); + }); + it('should not emit when socket is disconnected', () => { mockSocket.connected = false; const wrapper = createWrapper(); @@ -172,6 +228,23 @@ describe('useRealTimeTweets', () => { expect(mockSocket.emit).not.toHaveBeenCalled(); }); + + it('should handle socket error gracefully', () => { + shouldThrowSocketError = true; + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(); + + act(() => { + result.current.leavePost(123); + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not initialized, cannot leave post:', + 123 + ); + consoleWarnSpy.mockRestore(); + }); }); describe('usePostUpdates', () => { @@ -236,15 +309,21 @@ describe('useRealTimeTweets socket event handlers', () => { mockSocket.off.mockReset(); mockSocket.emit.mockReset(); mockSocket.connected = true; + shouldThrowSocketError = false; }); - it('should handle like update event', () => { + it('should handle like update event with matching postId', () => { const wrapper = createWrapper(); - let likeHandler: ((...args: unknown[]) => void) | undefined; + let likeHandler: + | ((data: { postId: number; count: number }) => void) + | undefined; mockSocket.on.mockImplementation( - (event: string, handler: (...args: unknown[]) => void) => { - if (event === 'post:like:update') { + ( + event: string, + handler: (data: { postId: number; count: number }) => void + ) => { + if (event === 'likeUpdate') { likeHandler = handler; } } @@ -260,20 +339,32 @@ describe('useRealTimeTweets socket event handlers', () => { if (likeHandler) { act(() => { - likeHandler({ postId: 123, count: 10 }); + likeHandler!({ postId: 123, count: 10 }); }); - expect(mockRealTimeOnMutate).toHaveBeenCalled(); + expect(mockRealTimeOnMutate).toHaveBeenCalledWith( + 'like', + 123, + 456, + 10, + 'Post', + -1 + ); } }); - it('should handle comment update event', () => { + it('should handle comment update event with matching postId', () => { const wrapper = createWrapper(); - let commentHandler: ((...args: unknown[]) => void) | undefined; + let commentHandler: + | ((data: { postId: number; count: number }) => void) + | undefined; mockSocket.on.mockImplementation( - (event: string, handler: (...args: unknown[]) => void) => { - if (event === 'post:comment:update') { + ( + event: string, + handler: (data: { postId: number; count: number }) => void + ) => { + if (event === 'commentUpdate') { commentHandler = handler; } } @@ -289,20 +380,32 @@ describe('useRealTimeTweets socket event handlers', () => { if (commentHandler) { act(() => { - commentHandler({ postId: 123, count: 5 }); + commentHandler!({ postId: 123, count: 5 }); }); - expect(mockRealTimeOnMutate).toHaveBeenCalled(); + expect(mockRealTimeOnMutate).toHaveBeenCalledWith( + 'reply', + 123, + 456, + 5, + 'Post', + -1 + ); } }); - it('should handle repost update event', () => { + it('should handle repost update event with matching postId', () => { const wrapper = createWrapper(); - let repostHandler: ((...args: unknown[]) => void) | undefined; + let repostHandler: + | ((data: { postId: number; count: number }) => void) + | undefined; mockSocket.on.mockImplementation( - (event: string, handler: (...args: unknown[]) => void) => { - if (event === 'post:repost:update') { + ( + event: string, + handler: (data: { postId: number; count: number }) => void + ) => { + if (event === 'repostUpdate') { repostHandler = handler; } } @@ -318,20 +421,32 @@ describe('useRealTimeTweets socket event handlers', () => { if (repostHandler) { act(() => { - repostHandler({ postId: 123, count: 3 }); + repostHandler!({ postId: 123, count: 3 }); }); - expect(mockRealTimeOnMutate).toHaveBeenCalled(); + expect(mockRealTimeOnMutate).toHaveBeenCalledWith( + 'repost', + 123, + 456, + 3, + 'Post', + -1 + ); } }); - it('should not call onMutate for different postId', () => { + it('should not call onMutate for different postId on like', () => { const wrapper = createWrapper(); - let likeHandler: ((...args: unknown[]) => void) | undefined; + let likeHandler: + | ((data: { postId: number; count: number }) => void) + | undefined; mockSocket.on.mockImplementation( - (event: string, handler: (...args: unknown[]) => void) => { - if (event === 'post:like:update') { + ( + event: string, + handler: (data: { postId: number; count: number }) => void + ) => { + if (event === 'likeUpdate') { likeHandler = handler; } } @@ -347,11 +462,147 @@ describe('useRealTimeTweets socket event handlers', () => { if (likeHandler) { act(() => { - // Different postId - likeHandler({ postId: 999, count: 10 }); + likeHandler!({ postId: 999, count: 10 }); }); expect(mockRealTimeOnMutate).not.toHaveBeenCalled(); } }); + + it('should not call onMutate for different postId on comment', () => { + const wrapper = createWrapper(); + let commentHandler: + | ((data: { postId: number; count: number }) => void) + | undefined; + + mockSocket.on.mockImplementation( + ( + event: string, + handler: (data: { postId: number; count: number }) => void + ) => { + if (event === 'commentUpdate') { + commentHandler = handler; + } + } + ); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + if (commentHandler) { + act(() => { + commentHandler!({ postId: 999, count: 5 }); + }); + + expect(mockRealTimeOnMutate).not.toHaveBeenCalled(); + } + }); + + it('should not call onMutate for different postId on repost', () => { + const wrapper = createWrapper(); + let repostHandler: + | ((data: { postId: number; count: number }) => void) + | undefined; + + mockSocket.on.mockImplementation( + ( + event: string, + handler: (data: { postId: number; count: number }) => void + ) => { + if (event === 'repostUpdate') { + repostHandler = handler; + } + } + ); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + if (repostHandler) { + act(() => { + repostHandler!({ postId: 999, count: 3 }); + }); + + expect(mockRealTimeOnMutate).not.toHaveBeenCalled(); + } + }); + + it('should handle socket disconnected for listeners', () => { + mockSocket.connected = false; + const wrapper = createWrapper(); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not connected, cannot listen to post like:', + 123 + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not connected, cannot listen to post Reply:', + 123 + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not connected, cannot listen to post repost:', + 123 + ); + consoleWarnSpy.mockRestore(); + }); + + it('should handle socket error for listeners', () => { + shouldThrowSocketError = true; + const wrapper = createWrapper(); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not initialized, cannot listen to post like:', + 123 + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not initialized, cannot listen to post reply:', + 123 + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Socket not initialized, cannot listen to post repost:', + 123 + ); + consoleWarnSpy.mockRestore(); + }); + + it('should use default type value', () => { + const wrapper = createWrapper(); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456); + return null; + }; + + render(, { wrapper }); + expect(mockSocket.on).toHaveBeenCalled(); + }); }); diff --git a/src/features/timeline/tests/useTimelineStore.test.tsx b/src/features/timeline/tests/useTimelineStore.test.tsx index 6ffaebf7..70ebf3a9 100644 --- a/src/features/timeline/tests/useTimelineStore.test.tsx +++ b/src/features/timeline/tests/useTimelineStore.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { act, renderHook } from '@testing-library/react'; -import React from 'react'; // Mock next/navigation vi.mock('next/navigation', () => ({ diff --git a/src/features/tweets/components/Actions.tsx b/src/features/tweets/components/Actions.tsx index 21d18032..d790d49b 100644 --- a/src/features/tweets/components/Actions.tsx +++ b/src/features/tweets/components/Actions.tsx @@ -133,7 +133,6 @@ export default function Actions({ case 'quote_post': setPostType(ADD_TWEET.QUOTE); setParentId(stats.postId); - console.log(stats.postId); setIsQuoteOpen(true); if (modalClick) modalClick(); @@ -156,7 +155,6 @@ export default function Actions({ color={actionsMeta[0].color} onClick={() => { setPostType(ADD_TWEET.REPLY); - console.log(stats.postId); setParentId(stats.postId); setIsReplyOpen(true); if (modalClick) modalClick(); diff --git a/src/features/tweets/components/SharePostModal.tsx b/src/features/tweets/components/SharePostModal.tsx index d8ac3a54..669a166f 100644 --- a/src/features/tweets/components/SharePostModal.tsx +++ b/src/features/tweets/components/SharePostModal.tsx @@ -8,7 +8,7 @@ import { createConversation, fetchConversations, } from '@/features/messages/api/messages'; -import { getSocket, initSocket } from '@/features/messages/services/socket'; +import { initSocket } from '@/features/messages/services/socket'; import { MESSAGES_SOCKET_EVENTS } from '@/features/messages/constants/api'; import { useMessageStore } from '@/features/messages/store/useMessageStore'; import { useRouter } from 'next/navigation'; diff --git a/src/features/tweets/components/Tweet.tsx b/src/features/tweets/components/Tweet.tsx index 61910687..88d0c29e 100644 --- a/src/features/tweets/components/Tweet.tsx +++ b/src/features/tweets/components/Tweet.tsx @@ -90,26 +90,18 @@ export default function Tweet({ if (hasJoined.current !== dataViewd.postId) { joinPost(dataViewd.postId, (resp) => { if (resp?.status === 'success') { - console.log('Join post response:', resp); hasJoined.current = dataViewd.postId; } else { - console.warn('Join post response:', resp); } }); } else { - console.log('kk', data.text); } } else { - console.log('not visible', data.text); if (hasJoined.current === dataViewd.postId) { - console.log('not visible leave', data.text); - leavePost(dataViewd.postId, (resp) => { if (resp?.status === 'success') { - console.log('Leave post response:', resp); hasJoined.current = null; } else { - console.warn('Leave post response:', resp); } }); }