From cd967facb3de9bce79a0bd611f92d2301dc0c646 Mon Sep 17 00:00:00 2001 From: ahmedfathy0-0 Date: Mon, 15 Dec 2025 22:05:36 +0200 Subject: [PATCH 1/9] fix: refactor profile-related components for improved readability and performance --- .../@gifModal/(.)i/foundmedia/search/page.tsx | 4 --- src/app/[username]/ProfileProvider.tsx | 25 +++++++++++++------ .../[username]/followers-you-know/page.tsx | 5 ++-- src/app/[username]/followers/page.tsx | 5 ++-- src/app/[username]/following/page.tsx | 5 ++-- src/app/[username]/layout.tsx | 4 +-- src/app/[username]/page.tsx | 5 ++-- 7 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/app/@gifModal/(.)i/foundmedia/search/page.tsx b/src/app/@gifModal/(.)i/foundmedia/search/page.tsx index 77f10f4e..c451e38f 100644 --- a/src/app/@gifModal/(.)i/foundmedia/search/page.tsx +++ b/src/app/@gifModal/(.)i/foundmedia/search/page.tsx @@ -1,10 +1,6 @@ // 'use client'; import Gif from '@/features/media/components/Gif'; -import GifModal from '@/features/media/components/GifModal'; -// import Gif from '@/features/media/components/Gif'; export default async function Page() { - // export default function Page() { - // return ; return ; } diff --git a/src/app/[username]/ProfileProvider.tsx b/src/app/[username]/ProfileProvider.tsx index b0f3e817..21d8fd4d 100644 --- a/src/app/[username]/ProfileProvider.tsx +++ b/src/app/[username]/ProfileProvider.tsx @@ -1,8 +1,12 @@ 'use client'; -import React, { createContext, useContext, ReactNode } from 'react'; -import { use } from 'react'; -import { useProfileByUsername } from '@/features/profile/hooks'; -import { useMyProfile } from '@/features/profile/hooks'; +import React, { + createContext, + useContext, + ReactNode, + use, + useMemo, +} from 'react'; +import { useProfileByUsername, useMyProfile } from '@/features/profile/hooks'; import { useAuthStore } from '@/features/authentication/store/authStore'; import { UserProfile } from '@/features/profile/types/api'; import Loader from '@/components/generic/Loader'; @@ -25,14 +29,14 @@ export const useProfileContext = () => { }; interface ProfileProviderProps { - children: ReactNode; - params: Promise<{ username: string }>; + readonly children: ReactNode; + readonly params: Promise<{ username: string }>; } export function ProfileProvider({ children, params }: ProfileProviderProps) { const { username } = use(params); const currentUser = useAuthStore((s) => s.user); - const useMy = Boolean(currentUser && currentUser.username === username); + const useMy = Boolean(currentUser?.username === username); const myProfileQuery = useMyProfile(); const { @@ -49,6 +53,11 @@ export function ProfileProvider({ children, params }: ProfileProviderProps) { const error = useMy ? myProfileQuery.error : errorByUsername; + const contextValue = useMemo( + () => ({ profile, isLoading, error, username }), + [profile, isLoading, error, username] + ); + if (isLoading) { return (
@@ -71,7 +80,7 @@ export function ProfileProvider({ children, params }: ProfileProviderProps) { } return ( - + {children} ); diff --git a/src/app/[username]/followers-you-know/page.tsx b/src/app/[username]/followers-you-know/page.tsx index cc328ffd..4f342210 100644 --- a/src/app/[username]/followers-you-know/page.tsx +++ b/src/app/[username]/followers-you-know/page.tsx @@ -1,6 +1,5 @@ 'use client'; -import React, { useEffect } from 'react'; -import { use } from 'react'; +import React, { useEffect, use } from 'react'; import { useRouter } from 'next/navigation'; import Breadcrumb from '@/components/ui/Breadcrumb'; import { Tabs, GenericUserList } from '@/components/generic'; @@ -10,7 +9,7 @@ import { useAuthStore } from '@/features/authentication/store/authStore'; import Loader from '@/components/generic/Loader'; interface FollowersYouKnowPageProps { - params: Promise<{ + readonly params: Promise<{ username: string; }>; } diff --git a/src/app/[username]/followers/page.tsx b/src/app/[username]/followers/page.tsx index 6366ee0d..f8d5faaf 100644 --- a/src/app/[username]/followers/page.tsx +++ b/src/app/[username]/followers/page.tsx @@ -1,6 +1,5 @@ 'use client'; -import React from 'react'; -import { use } from 'react'; +import React, { use } from 'react'; import { useRouter } from 'next/navigation'; import Breadcrumb from '@/components/ui/Breadcrumb'; import { Tabs, GenericUserList } from '@/components/generic'; @@ -10,7 +9,7 @@ import { useAuthStore } from '@/features/authentication/store/authStore'; import Loader from '@/components/generic/Loader'; interface FollowersPageProps { - params: Promise<{ + readonly params: Promise<{ username: string; }>; } diff --git a/src/app/[username]/following/page.tsx b/src/app/[username]/following/page.tsx index 5c5a2f21..5c549b2d 100644 --- a/src/app/[username]/following/page.tsx +++ b/src/app/[username]/following/page.tsx @@ -1,6 +1,5 @@ 'use client'; -import React from 'react'; -import { use } from 'react'; +import React, { use } from 'react'; import { useRouter } from 'next/navigation'; import Breadcrumb from '@/components/ui/Breadcrumb'; import { Tabs, GenericUserList } from '@/components/generic'; @@ -10,7 +9,7 @@ import { useAuthStore } from '@/features/authentication/store/authStore'; import Loader from '@/components/generic/Loader'; interface FollowingPageProps { - params: Promise<{ + readonly params: Promise<{ username: string; }>; } diff --git a/src/app/[username]/layout.tsx b/src/app/[username]/layout.tsx index e318b043..c7fa4b97 100644 --- a/src/app/[username]/layout.tsx +++ b/src/app/[username]/layout.tsx @@ -2,8 +2,8 @@ import LayoutWrapper from '@/features/layout/components/LayoutWrapper'; import { ProfileProvider } from './ProfileProvider'; interface UsernameLayoutProps { - children: React.ReactNode; - params: Promise<{ username: string }>; + readonly children: React.ReactNode; + readonly params: Promise<{ username: string }>; } export default function UsernameLayout({ diff --git a/src/app/[username]/page.tsx b/src/app/[username]/page.tsx index 1ce0b55b..6dbcbbb5 100644 --- a/src/app/[username]/page.tsx +++ b/src/app/[username]/page.tsx @@ -15,10 +15,9 @@ const UserPage = () => { const { profile, username } = useProfileContext(); const { setCurrentProfile } = useProfileStore(); const currentUser = useAuthStore((s) => s.user); - const isMine = Boolean(currentUser && currentUser.username === username); + const isMine = Boolean(currentUser?.username === username); const [showBlockedPosts, setShowBlockedPosts] = useState(false); const router = useRouter(); - // Update page title with unread count (uses "H" branding, static favicon) usePageTitleNotifications('H', false); useEffect(() => { @@ -29,7 +28,7 @@ const UserPage = () => { }, [profile, setCurrentProfile]); const handleBack = () => { - window.history.back(); + router.push('/home'); }; if (!profile) { From 2e01ab0e01524fa707fad805433ed0abbfe392b8 Mon Sep 17 00:00:00 2001 From: Yousef Adel Date: Mon, 15 Dec 2025 23:38:01 +0200 Subject: [PATCH 2/9] feat: add comprehensive tests for timeline features --- .../layout/components/MobilePostButton.tsx | 4 +- src/features/layout/components/PostButton.tsx | 2 +- .../timeline/components/AddPostSection.tsx | 2 +- src/features/timeline/components/AddTweet.tsx | 4 +- .../timeline/components/ComposeModal.tsx | 2 +- src/features/timeline/components/Mention.tsx | 2 +- .../timeline/hooks/timelineQueries.ts | 19 +- .../timeline/tests/AddPostContext.test.tsx | 283 ++++++++++ .../timeline/tests/AddPostSection.test.tsx | 72 +++ src/features/timeline/tests/AddTweet.test.tsx | 61 +++ .../timeline/tests/ComposeModal.test.tsx | 170 ++++++ src/features/timeline/tests/GrokMenu.test.tsx | 61 +++ src/features/timeline/tests/Header.test.tsx | 159 ++++++ src/features/timeline/tests/Input.test.tsx | 204 +++++++ src/features/timeline/tests/Mention.test.tsx | 95 ++++ src/features/timeline/tests/Poll.test.tsx | 254 +++++++++ .../tests/RealTimeTweetOptimistics.test.tsx | 180 ++++++ src/features/timeline/tests/Reply.test.tsx | 79 +++ .../timeline/tests/ReplyQuoteSection.test.tsx | 76 +++ .../timeline/tests/SearchProfile.test.tsx | 143 +++++ .../timeline/tests/ShowTweets.test.tsx | 49 ++ .../timeline/tests/TimeOptions.test.tsx | 265 +++++++++ src/features/timeline/tests/Timeline.test.tsx | 202 +++++++ .../timeline/tests/TweetFeed.test.tsx | 88 +++ .../timeline/tests/TweetFooter.test.tsx | 103 ++++ .../timeline/tests/TweetImages.test.tsx | 55 ++ .../timeline/tests/TweetList.test.tsx | 85 +++ .../timeline/tests/TweetOptionsBar.test.tsx | 93 ++++ .../tests/TweetReplySettings.test.tsx | 71 +++ .../tests/TweetSubmitSection.test.tsx | 236 ++++++++ .../timeline/tests/TweetText.test.tsx | 112 ++++ .../timeline/tests/TweetsOptimistics.test.tsx | 437 +++++++++++++++ .../tests/TypingProgressCircle.test.tsx | 221 ++++++++ .../timeline/tests/replyRegistry.test.tsx | 91 ++++ .../timeline/tests/timelineQueries.test.tsx | 229 ++++++++ .../timeline/tests/useAddPostStore.test.tsx | 362 +++++++++++++ .../timeline/tests/useDebounce.test.tsx | 240 ++++++++ .../timeline/tests/useOnScreen.test.tsx | 311 +++++++++++ .../timeline/tests/usePollStore.test.tsx | 296 ++++++++++ .../timeline/tests/useRealTimeTweets.test.tsx | 357 ++++++++++++ .../tests/useTimelineComposer.test.tsx | 511 ++++++++++++++++++ .../timeline/tests/useTimelineStore.test.tsx | 330 +++++++++++ 42 files changed, 6590 insertions(+), 26 deletions(-) create mode 100644 src/features/timeline/tests/AddPostContext.test.tsx create mode 100644 src/features/timeline/tests/AddPostSection.test.tsx create mode 100644 src/features/timeline/tests/AddTweet.test.tsx create mode 100644 src/features/timeline/tests/ComposeModal.test.tsx create mode 100644 src/features/timeline/tests/GrokMenu.test.tsx create mode 100644 src/features/timeline/tests/Header.test.tsx create mode 100644 src/features/timeline/tests/Input.test.tsx create mode 100644 src/features/timeline/tests/Mention.test.tsx create mode 100644 src/features/timeline/tests/Poll.test.tsx create mode 100644 src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx create mode 100644 src/features/timeline/tests/Reply.test.tsx create mode 100644 src/features/timeline/tests/ReplyQuoteSection.test.tsx create mode 100644 src/features/timeline/tests/SearchProfile.test.tsx create mode 100644 src/features/timeline/tests/ShowTweets.test.tsx create mode 100644 src/features/timeline/tests/TimeOptions.test.tsx create mode 100644 src/features/timeline/tests/Timeline.test.tsx create mode 100644 src/features/timeline/tests/TweetFeed.test.tsx create mode 100644 src/features/timeline/tests/TweetFooter.test.tsx create mode 100644 src/features/timeline/tests/TweetImages.test.tsx create mode 100644 src/features/timeline/tests/TweetList.test.tsx create mode 100644 src/features/timeline/tests/TweetOptionsBar.test.tsx create mode 100644 src/features/timeline/tests/TweetReplySettings.test.tsx create mode 100644 src/features/timeline/tests/TweetSubmitSection.test.tsx create mode 100644 src/features/timeline/tests/TweetText.test.tsx create mode 100644 src/features/timeline/tests/TweetsOptimistics.test.tsx create mode 100644 src/features/timeline/tests/TypingProgressCircle.test.tsx create mode 100644 src/features/timeline/tests/replyRegistry.test.tsx create mode 100644 src/features/timeline/tests/timelineQueries.test.tsx create mode 100644 src/features/timeline/tests/useAddPostStore.test.tsx create mode 100644 src/features/timeline/tests/useDebounce.test.tsx create mode 100644 src/features/timeline/tests/useOnScreen.test.tsx create mode 100644 src/features/timeline/tests/usePollStore.test.tsx create mode 100644 src/features/timeline/tests/useRealTimeTweets.test.tsx create mode 100644 src/features/timeline/tests/useTimelineComposer.test.tsx create mode 100644 src/features/timeline/tests/useTimelineStore.test.tsx diff --git a/src/features/layout/components/MobilePostButton.tsx b/src/features/layout/components/MobilePostButton.tsx index 58624009..c89ed64c 100644 --- a/src/features/layout/components/MobilePostButton.tsx +++ b/src/features/layout/components/MobilePostButton.tsx @@ -11,10 +11,10 @@ export default function MobilePostButton() { setIsComposeOpen(true)} - className="bg-white hover:bg-gray-200 text-black font-bold rounded-full transition-colors mt-4 w-14 h-14 min-[1400px]:w-full min-[1400px]:h-auto min-[1400px]:py-3 flex items-center justify-center" + className="bg-white hover:bg-gray-200 cursor-pointer text-black font-bold rounded-full transition-colors mt-4 w-14 h-14 min-[1400px]:w-full min-[1400px]:h-auto min-[1400px]:py-3 flex items-center justify-center" > {/* Show + icon on small screens, "Post" text at 1400px+ */} diff --git a/src/features/timeline/components/AddPostSection.tsx b/src/features/timeline/components/AddPostSection.tsx index b704208e..447e5b74 100644 --- a/src/features/timeline/components/AddPostSection.tsx +++ b/src/features/timeline/components/AddPostSection.tsx @@ -40,7 +40,7 @@ export default function AddPostSection() { console.log(tweetFormData.getAll('media')); console.log(media); console.log(mentions); - const mentionsId = mentions.map((mention) => mention.id + 1); + const mentionsId = mentions.map((mention) => mention.id); const allMentions = mentionsId.join(','); tweetFormData.append(TweetFormDataKeys.MENTIONS, allMentions); diff --git a/src/features/timeline/components/AddTweet.tsx b/src/features/timeline/components/AddTweet.tsx index f24363d2..bc85735e 100644 --- a/src/features/timeline/components/AddTweet.tsx +++ b/src/features/timeline/components/AddTweet.tsx @@ -86,7 +86,7 @@ export default function AddTweet({ id="Add tweet" ref={ref} data-testid="add-tweet-container" - className={` relative flex flex-col items-start w-full ${showBorder && 'border-b border-border'} `} + className={` relative flex flex-col items-start w-full h-full flex-1 ${showBorder && 'border-b border-border'} `} > {/* {error && (
@@ -112,7 +112,7 @@ export default function AddTweet({ )}
diff --git a/src/features/timeline/components/ComposeModal.tsx b/src/features/timeline/components/ComposeModal.tsx index e34befc3..504fd93b 100644 --- a/src/features/timeline/components/ComposeModal.tsx +++ b/src/features/timeline/components/ComposeModal.tsx @@ -25,7 +25,7 @@ export default function ComposeModal({ isOpen, onClose }: ComposeModalProps) { showCloseButton={true} showLogo={false} > -
+
diff --git a/src/features/timeline/components/Mention.tsx b/src/features/timeline/components/Mention.tsx index fe1c5af5..0341d752 100644 --- a/src/features/timeline/components/Mention.tsx +++ b/src/features/timeline/components/Mention.tsx @@ -66,7 +66,7 @@ export default function Mention() { if (totalProfiles && pages) { // setMention(pages[0].data[0].User.username + ''); setIsDone( - pages[0].data[0].User.username + ' ' + pages[0].data[0] + pages[0].data[0].User.username + ' ' + pages[0].data[0].user_id ); console.log(pages[0].data[0].User.username); diff --git a/src/features/timeline/hooks/timelineQueries.ts b/src/features/timeline/hooks/timelineQueries.ts index 84c61bf6..0d7d515b 100644 --- a/src/features/timeline/hooks/timelineQueries.ts +++ b/src/features/timeline/hooks/timelineQueries.ts @@ -20,14 +20,11 @@ import { useFetchAvatars, useNewTweets, useSearch, - useSearchIsopen, - useSearchUser, useSelectedTab, } from '../store/useTimelineStore'; import { FOLLOWING_TAB } from '../constants/menuName'; -import { OPTIMISTIC_TYPES, TIMELINE_ENDPOINTS } from '../constants/api'; +import { TIMELINE_ENDPOINTS } from '../constants/api'; import { useAuth } from '@/features/authentication/hooks'; -import { Search } from 'lucide-react'; import { PROFILE_QUERY_KEYS, profileApi, @@ -75,16 +72,6 @@ export const useAddTweet = (label: string) => { } }, onSuccess: async (data) => { - // // queryClient.invalidateQueries({ queryKey: [''] }); - // if (label === ADD_TWEET.QUOTE) { - // onMutate( - // OPTIMISTIC_TYPES.Quote, - // data.data.originalPostData?.userId ?? data.data.userId, - // data.data.originalPostData?.postId, - // data.data.originalPostData?.type, - // data.data.originalPostData?.parentId - // ); - // } console.log('app', user, label); if (user && label !== ADD_TWEET.REPLY) { @@ -207,10 +194,6 @@ export const useTimelineFeed = () => { }); }; export const useSearchProfile = (searchUser: string) => { - // const search = useSearch(); - // const isSearch = useSearchIsopen(); - // const mention = useMention(); - // const searchUser = isSearch ? search : mention; return useInfiniteQuery< ProfileSearchDtoResponse, Error, diff --git a/src/features/timeline/tests/AddPostContext.test.tsx b/src/features/timeline/tests/AddPostContext.test.tsx new file mode 100644 index 00000000..1aa3c6ae --- /dev/null +++ b/src/features/timeline/tests/AddPostContext.test.tsx @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, renderHook } from '@testing-library/react'; +import { + AddPostStoreContext, + useAddPostContext, +} from '../store/AddPostContext'; +import { + createAddTweetStore, + createAddTweetSelectors, +} from '../store/useAddPostStore'; + +describe('AddPostContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('AddPostStoreContext', () => { + it('should be a React context', () => { + expect(AddPostStoreContext).toBeDefined(); + expect(AddPostStoreContext.Provider).toBeDefined(); + expect(AddPostStoreContext.Consumer).toBeDefined(); + }); + + it('should have default value of null', () => { + // Render a consumer without provider + let contextValue: any = 'not-null'; + + render( + + {(value) => { + contextValue = value; + return null; + }} + + ); + + expect(contextValue).toBeNull(); + }); + + it('should provide value to consumers', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + let contextValue: any = null; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + + ); + + expect(contextValue).not.toBeNull(); + }); + }); + + describe('useAddPostContext', () => { + it('should throw error when used outside provider', () => { + expect(() => { + renderHook(() => useAddPostContext()); + }).toThrow('Missing Store context'); + }); + + it('should return context when inside provider', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current).toBe(selectors); + }); + + it('should provide useTweetText selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useTweetText).toBeDefined(); + }); + + it('should provide useMedia selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useMedia).toBeDefined(); + }); + + it('should provide useIsSending selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useIsSending).toBeDefined(); + }); + + it('should provide useIsSuccess selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useIsSuccess).toBeDefined(); + }); + + it('should provide useError selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useError).toBeDefined(); + }); + + it('should provide useActions selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useActions).toBeDefined(); + }); + + it('should provide useMentions selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useMentions).toBeDefined(); + }); + + it('should provide useEmoji selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useEmoji).toBeDefined(); + }); + + it('should provide useMention selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useMention).toBeDefined(); + }); + + it('should provide useCurrentKey selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useCurrentKey).toBeDefined(); + }); + + it('should provide useMentionIsDone selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useMentionIsDone).toBeDefined(); + }); + + it('should provide useIsOpen selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.useIsOpen).toBeDefined(); + }); + + it('should provide usePlaceHolder selector', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAddPostContext(), { wrapper }); + + expect(result.current.usePlaceHolder).toBeDefined(); + }); + }); +}); diff --git a/src/features/timeline/tests/AddPostSection.test.tsx b/src/features/timeline/tests/AddPostSection.test.tsx new file mode 100644 index 00000000..1276df87 --- /dev/null +++ b/src/features/timeline/tests/AddPostSection.test.tsx @@ -0,0 +1,72 @@ +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 AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useTweetText: () => 'test tweet', + useMedia: () => [], + useMentions: () => [], + useActions: () => ({ + setTweetText: vi.fn(), + addMedia: vi.fn(), + reset: vi.fn(), + }), + })), +})); + +// Mock useAddTweet +vi.mock('../hooks/timelineQueries', () => ({ + useAddTweet: vi.fn(() => ({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + })), +})); + +// Mock TweetSubmitSection +vi.mock('./TweetSubmitSection', () => ({ + default: ({ label, handleAddTweet, enableAddTweet }: any) => ( +
+ +
+ ), +})); + +import AddPostSection from '../components/AddPostSection'; + +describe('AddPostSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render tweet submit section', () => { + render(); + expect(screen.getByTestId('tweet-submit-section')).toBeInTheDocument(); + }); + + it('should render post button', () => { + render(); + expect(screen.getByText('Post')).toBeInTheDocument(); + }); + + it('should enable button when text exists', () => { + render(); + // Button should be present + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should render submit button', () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/AddTweet.test.tsx b/src/features/timeline/tests/AddTweet.test.tsx new file mode 100644 index 00000000..98777119 --- /dev/null +++ b/src/features/timeline/tests/AddTweet.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import '@testing-library/jest-dom'; + +// Since AddTweet is a complex component with many dependencies, +// we test that the store and context exports work correctly +import { + createAddTweetStore, + createAddTweetSelectors, +} from '../store/useAddPostStore'; +import { timelineComposerSelectors } from '../store/useTimelineComposer'; +import { ADD_TWEET } from '../constants/tweetConstants'; + +describe('AddTweet', () => { + describe('ADD_TWEET constants', () => { + it('should have POST type', () => { + expect(ADD_TWEET.POST).toBe('POST'); + }); + + it('should have REPLY type', () => { + expect(ADD_TWEET.REPLY).toBe('REPLY'); + }); + + it('should have QUOTE type', () => { + expect(ADD_TWEET.QUOTE).toBe('QUOTE'); + }); + }); + + describe('Store factory functions', () => { + it('should create store with createAddTweetStore', () => { + const store = createAddTweetStore(); + expect(store).toBeDefined(); + expect(typeof store).toBe('function'); + }); + + it('should create selectors with createAddTweetSelectors', () => { + const store = createAddTweetStore(); + const selectors = createAddTweetSelectors(store); + expect(selectors).toBeDefined(); + expect(selectors.useTweetText).toBeDefined(); + expect(selectors.useMedia).toBeDefined(); + }); + }); + + describe('Timeline composer selectors', () => { + it('should have useTweetText selector', () => { + expect(timelineComposerSelectors.useTweetText).toBeDefined(); + }); + + it('should have useMedia selector', () => { + expect(timelineComposerSelectors.useMedia).toBeDefined(); + }); + + it('should have useIsSending selector', () => { + expect(timelineComposerSelectors.useIsSending).toBeDefined(); + }); + + it('should have useActions selector', () => { + expect(timelineComposerSelectors.useActions).toBeDefined(); + }); + }); +}); diff --git a/src/features/timeline/tests/ComposeModal.test.tsx b/src/features/timeline/tests/ComposeModal.test.tsx new file mode 100644 index 00000000..5d7d5e67 --- /dev/null +++ b/src/features/timeline/tests/ComposeModal.test.tsx @@ -0,0 +1,170 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import ComposeModal from '../components/ComposeModal'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock the AddTweet component with correct path +vi.mock('../components/AddTweet', () => ({ + default: ({ type }: { type: string }) => ( +
AddTweet component with type: {type}
+ ), +})); + +vi.mock('./AddTweet', () => ({ + default: ({ type }: { type: string }) => ( +
AddTweet component with type: {type}
+ ), +})); + +// Mock XModal +vi.mock('@/components/ui/hoc/XModal', () => ({ + default: ({ + isOpen, + onClose, + children, + overlayColor, + size, + }: { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + overlayColor?: string; + size?: string; + title?: string; + padding?: string; + showCloseButton?: boolean; + showLogo?: boolean; + }) => + isOpen ? ( +
+ + {children} +
+ ) : null, +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), + usePathname: vi.fn(() => '/home'), + useSearchParams: vi.fn(() => new URLSearchParams()), +})); + +describe('ComposeModal Component', () => { + const mockOnClose = vi.fn(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + mockOnClose.mockClear(); + }); + + it('should not render when isOpen is false', () => { + render(, { wrapper }); + + expect(screen.queryByTestId('x-modal')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', () => { + render(, { wrapper }); + + expect(screen.getByTestId('x-modal')).toBeInTheDocument(); + }); + + it('should render AddTweet component', () => { + render(, { wrapper }); + + expect(screen.getByTestId('add-tweet-mock')).toBeInTheDocument(); + }); + + it('should pass correct type to AddTweet', () => { + render(, { wrapper }); + + const addTweet = screen.getByTestId('add-tweet-mock'); + expect(addTweet).toHaveTextContent('POST'); + }); + + it('should call onClose when close button is clicked', () => { + render(, { wrapper }); + + const closeButton = screen.getByTestId('close-button'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should pass correct overlay color', () => { + render(, { wrapper }); + + const modal = screen.getByTestId('x-modal'); + expect(modal).toHaveAttribute( + 'data-overlay-color', + 'bg-[rgba(91,112,131,0.4)]' + ); + }); + + it('should pass correct size', () => { + render(, { wrapper }); + + const modal = screen.getByTestId('x-modal'); + expect(modal).toHaveAttribute('data-size', '4xl'); + }); + + it('should transition from closed to open', () => { + const { rerender } = render( + , + { wrapper } + ); + + expect(screen.queryByTestId('x-modal')).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId('x-modal')).toBeInTheDocument(); + }); + + it('should transition from open to closed', () => { + const { rerender } = render( + , + { wrapper } + ); + + expect(screen.getByTestId('x-modal')).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByTestId('x-modal')).not.toBeInTheDocument(); + }); + + it('should have correct container styling', () => { + render(, { wrapper }); + + // The container div with pt-14 px-5 classes wraps AddTweet + const addTweet = screen.getByTestId('add-tweet-mock'); + expect(addTweet.parentElement).toHaveClass('pt-14'); + expect(addTweet.parentElement).toHaveClass('px-5'); + expect(addTweet.parentElement).toHaveClass('w-full'); + expect(addTweet.parentElement).toHaveClass('h-full'); + expect(addTweet.parentElement).toHaveClass('flex'); + expect(addTweet.parentElement).toHaveClass('flex-col'); + }); +}); diff --git a/src/features/timeline/tests/GrokMenu.test.tsx b/src/features/timeline/tests/GrokMenu.test.tsx new file mode 100644 index 00000000..64f77629 --- /dev/null +++ b/src/features/timeline/tests/GrokMenu.test.tsx @@ -0,0 +1,61 @@ +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 AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useTweetText: () => 'test tweet', + useActions: () => ({}), + })), +})); + +// Mock Icon +vi.mock('../../../components/ui/home/Icon', () => ({ + default: (props: any) => ( + + ), +})); + +// Mock XMenu +vi.mock('@/components/ui/home/XMenu', () => { + const XMenu = function XMenu({ children }: { children: React.ReactNode }) { + return
{children}
; + }; + XMenu.Button = function XMenuButton({ + children, + }: { + children: React.ReactNode; + }) { + return
{children}
; + }; + XMenu.List = function XMenuList({ children }: { children: React.ReactNode }) { + return
{children}
; + }; + return { default: XMenu, onClose: vi.fn() }; +}); + +import GrokMenu from '../components/GrokMenu'; + +describe('GrokMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render grok menu', () => { + render(); + expect(screen.getByTestId('x-menu')).toBeInTheDocument(); + }); + + it('should render grok icon button', () => { + render(); + // The icon has title "Enhance you post with Grok" + expect(screen.getByText('Enhance you post with Grok')).toBeInTheDocument(); + }); + + it('should render x-menu wrapper', () => { + render(); + expect(screen.getByTestId('x-menu')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/Header.test.tsx b/src/features/timeline/tests/Header.test.tsx new file mode 100644 index 00000000..755ad0f7 --- /dev/null +++ b/src/features/timeline/tests/Header.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock dependencies +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/home'), + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +vi.mock('@/features/explore/store/useExploreStore', () => ({ + useSearchExplore: vi.fn(() => ''), + useActions: vi.fn(() => ({ setSearchQuery: vi.fn() })), +})); + +vi.mock('../store/useTimelineStore', () => ({ + useSelectedTab: vi.fn(() => 'forYou'), + useTabsScroll: vi.fn(() => [0, 0]), + useActions: vi.fn(() => ({ + selectTab: vi.fn(), + setFetchAvatars: vi.fn(), + setNewTweets: vi.fn(), + setPopUpAvatars: vi.fn(), + setTabsScroll: vi.fn(), + })), +})); + +vi.mock('@/features/authentication/store/useAuthStore', () => ({ + default: vi.fn(() => ({ avatar: '/avatar.jpg', username: 'testuser' })), + useAuthStore: vi.fn(() => ({ avatar: '/avatar.jpg', username: 'testuser' })), + useUser: vi.fn(() => ({ avatar: '/avatar.jpg', username: 'testuser' })), +})); + +vi.mock('@/features/profile/hooks', () => ({ + useMyProfile: vi.fn(() => ({ + data: { + data: { + profile_image_url: 'https://example.com/avatar.jpg', + name: 'Test User', + }, + }, + })), +})); + +vi.mock('@/components/generic/Avatar', () => ({ + default: function Avatar({ + avatarImage, + name, + size, + }: { + avatarImage: string; + name: string; + size: string; + }) { + return ( +
+ ); + }, +})); + +vi.mock('@/components/generic/Tabs', () => ({ + default: ({ tabs }: any) => ( +
+ {tabs?.map((tab: any) => ( +
{tab.title}
+ ))} +
+ ), +})); + +vi.mock('@/features/layout/components/MobileSidebar', () => ({ + default: () =>
MobileSidebar
, +})); + +vi.mock('@/components/ui/icons/BrandIcons', () => ({ + XLogo: () => ( + + + + ), +})); + +vi.mock('@/components/ui/home/Icon', () => ({ + default: ({ path }: any) => ( + + + + ), +})); + +import Header from '../components/Header'; + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('Header', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render header component', () => { + render(
, { wrapper }); + expect(screen.getByTestId('timeline-header')).toBeInTheDocument(); + }); + + it('should render For You tab', () => { + render(
, { wrapper }); + expect(screen.getByText('For you')).toBeInTheDocument(); + }); + + it('should render Following tab', () => { + render(
, { wrapper }); + expect(screen.getByText('Following')).toBeInTheDocument(); + }); + + it('should render avatar', () => { + render(
, { wrapper }); + expect(screen.getByTestId('avatar')).toBeInTheDocument(); + }); + + it('should render tabs component', () => { + render(
, { wrapper }); + expect(screen.getByTestId('tabs')).toBeInTheDocument(); + }); + + it('should have correct header structure', () => { + const { container } = render(
, { wrapper }); + expect( + container.querySelector('[data-testid="timeline-header"]') + ).toBeDefined(); + }); + + it('should render mobile sidebar component', () => { + render(
, { wrapper }); + expect(screen.getByTestId('mobile-sidebar')).toBeInTheDocument(); + }); + + it('should render X logo', () => { + render(
, { wrapper }); + expect(screen.getByTestId('x-logo')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/Input.test.tsx b/src/features/timeline/tests/Input.test.tsx new file mode 100644 index 00000000..57aa5283 --- /dev/null +++ b/src/features/timeline/tests/Input.test.tsx @@ -0,0 +1,204 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Input from '../components/Input'; + +describe('Input Component', () => { + const mockSetValue = vi.fn(); + + beforeEach(() => { + mockSetValue.mockClear(); + }); + + it('should render the input with label', () => { + render(); + + const input = screen.getByLabelText('Choice 1'); + expect(input).toBeInTheDocument(); + }); + + it('should render with data-testid based on id', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toBeInTheDocument(); + }); + + it('should display the value in the input', () => { + render( + + ); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toHaveValue('Test Value'); + }); + + it('should call setValue when input changes', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + fireEvent.change(input, { target: { value: 'New Value' } }); + + expect(mockSetValue).toHaveBeenCalledWith(1, 'New Value'); + }); + + it('should be required by default', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toBeRequired(); + }); + + it('should not be required when required is false', () => { + render( + + ); + + const input = screen.getByTestId('poll-choice-input-3'); + expect(input).not.toBeRequired(); + }); + + it('should have maxLength of 25', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toHaveAttribute('maxLength', '25'); + }); + + it('should have type text', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toHaveAttribute('type', 'text'); + }); + + it('should have spellcheck disabled', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toHaveAttribute('spellcheck', 'false'); + }); + + it('should render the label text', () => { + render(); + + const label = screen.getByText('Choice 1'); + expect(label).toBeInTheDocument(); + }); + + it('should render with different IDs', () => { + const { rerender } = render( + + ); + expect(screen.getByTestId('poll-choice-input-1')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByTestId('poll-choice-input-2')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByTestId('poll-choice-input-3')).toBeInTheDocument(); + }); + + it('should display character count on focus', () => { + render( + + ); + + const input = screen.getByTestId('poll-choice-input-1'); + fireEvent.focus(input); + + // The character count span has class "peer-focus:block" so it should be visible on focus + // Verify the input exists and has the correct value + expect(input).toHaveValue('Test'); + }); + + it('should handle empty value correctly', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toHaveValue(''); + }); + + it('should handle value at max length', () => { + const maxValue = 'a'.repeat(25); + render( + + ); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toHaveValue(maxValue); + expect(input.getAttribute('value')?.length).toBe(25); + }); + + it('should have correct input styling classes', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + expect(input).toHaveClass('peer'); + expect(input).toHaveClass('w-full'); + expect(input).toHaveClass('h-15'); + expect(input).toHaveClass('bg-transparent'); + }); + + it('should pass the correct id to setValue callback', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-4'); + fireEvent.change(input, { target: { value: 'Test' } }); + + expect(mockSetValue).toHaveBeenCalledWith(4, 'Test'); + }); + + it('should render within container div with max-width', () => { + const { container } = render( + + ); + + const containerDiv = container.querySelector('.max-w-\\[435px\\]'); + expect(containerDiv).toBeInTheDocument(); + }); + + it('should handle rapid value changes', () => { + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.change(input, { target: { value: 'ab' } }); + fireEvent.change(input, { target: { value: 'abc' } }); + + expect(mockSetValue).toHaveBeenCalledTimes(3); + expect(mockSetValue).toHaveBeenNthCalledWith(1, 1, 'a'); + expect(mockSetValue).toHaveBeenNthCalledWith(2, 1, 'ab'); + expect(mockSetValue).toHaveBeenNthCalledWith(3, 1, 'abc'); + }); + + it('should render label with optional indicator', () => { + render( + + ); + + expect(screen.getByText('Choice 3 (optional)')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/Mention.test.tsx b/src/features/timeline/tests/Mention.test.tsx new file mode 100644 index 00000000..63436ee5 --- /dev/null +++ b/src/features/timeline/tests/Mention.test.tsx @@ -0,0 +1,95 @@ +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 AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useTweetText: () => '', + useMention: () => 'test', + useCurrentKey: () => '', + useIsOpen: () => true, + useActions: () => ({ + setTweetText: vi.fn(), + setMention: vi.fn(), + setIsOpen: vi.fn(), + setIsDone: vi.fn(), + setKeyDown: vi.fn(), + }), + })), +})); + +// Mock useSearchProfile +vi.mock('../hooks/timelineQueries', () => ({ + useSearchProfile: vi.fn(() => ({ + data: { + pages: [ + { + data: [{ User: { username: 'testuser' }, user_id: 1 }], + metadata: { total: 1, limit: 10 }, + }, + ], + }, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + })), +})); + +// Mock useDebounce +vi.mock('../hooks/useDebounce', () => ({ + default: vi.fn((value: string) => value), +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +// Mock components +vi.mock('@/components/ui/UserCard', () => ({ + default: ({ username }: any) =>
{username}
, +})); + +vi.mock('@/components/generic', () => ({ + Loader: () =>
Loading...
, +})); + +vi.mock('@/components/ui/home/InfiniteScroll', () => ({ + default: ({ children }: any) => ( +
{children}
+ ), +})); + +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn(), +})); + +import Mention from '../components/Mention'; + +describe('Mention', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render mention component', () => { + render(); + expect( + screen.getByTestId('render-search-profile-list') + ).toBeInTheDocument(); + }); + + it('should render user card when profiles available', () => { + render(); + expect(screen.getByTestId('user-card')).toBeInTheDocument(); + }); + + it('should render infinite scroll', () => { + render(); + expect(screen.getByTestId('infinite-scroll')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/Poll.test.tsx b/src/features/timeline/tests/Poll.test.tsx new file mode 100644 index 00000000..176cf85c --- /dev/null +++ b/src/features/timeline/tests/Poll.test.tsx @@ -0,0 +1,254 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Poll from '../components/Poll'; +import usePollStore from '../store/usePollStore'; + +// Mock Input component +vi.mock('./Input', () => ({ + default: ({ + label, + id, + value, + setValue, + required, + }: { + label: string; + id: number; + value: string; + setValue: (id: number, value: string) => void; + required?: boolean; + }) => ( + setValue(id, e.target.value)} + placeholder={label} + required={required} + /> + ), +})); + +// Mock TimeOptions component +vi.mock('./TimeOptions', () => ({ + default: ({ + type, + option, + setOption, + }: { + id: number; + type: string; + option: number; + setOption: (id: number, value: number) => void; + }) => ( + + ), +})); + +describe('Poll Component', () => { + beforeEach(() => { + // Reset the store before each test + usePollStore.setState({ + choices: ['', '', '', ''], + time: [1, 0, 0], + shiftStartMinutes: 0, + isOpen: false, + buttonInputIndex: 2, + }); + }); + + it('should not render when isOpen is false', () => { + usePollStore.setState({ isOpen: false }); + + render(); + + expect(screen.queryByTestId('poll-container')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', () => { + usePollStore.setState({ isOpen: true }); + + render(); + + expect(screen.getByTestId('poll-container')).toBeInTheDocument(); + }); + + it('should render initial two choice inputs', () => { + usePollStore.setState({ isOpen: true, buttonInputIndex: 2 }); + + render(); + + expect(screen.getByTestId('poll-choice-input-1')).toBeInTheDocument(); + expect(screen.getByTestId('poll-choice-input-2')).toBeInTheDocument(); + expect(screen.queryByTestId('poll-choice-input-3')).not.toBeInTheDocument(); + expect(screen.queryByTestId('poll-choice-input-4')).not.toBeInTheDocument(); + }); + + it('should render three choice inputs when buttonInputIndex is 3', () => { + usePollStore.setState({ isOpen: true, buttonInputIndex: 3 }); + + render(); + + expect(screen.getByTestId('poll-choice-input-1')).toBeInTheDocument(); + expect(screen.getByTestId('poll-choice-input-2')).toBeInTheDocument(); + expect(screen.getByTestId('poll-choice-input-3')).toBeInTheDocument(); + expect(screen.queryByTestId('poll-choice-input-4')).not.toBeInTheDocument(); + }); + + it('should render all four choice inputs when buttonInputIndex is 4', () => { + usePollStore.setState({ isOpen: true, buttonInputIndex: 4 }); + + render(); + + expect(screen.getByTestId('poll-choice-input-1')).toBeInTheDocument(); + expect(screen.getByTestId('poll-choice-input-2')).toBeInTheDocument(); + expect(screen.getByTestId('poll-choice-input-3')).toBeInTheDocument(); + expect(screen.getByTestId('poll-choice-input-4')).toBeInTheDocument(); + }); + + it('should render add choice button for current buttonInputIndex', () => { + usePollStore.setState({ isOpen: true, buttonInputIndex: 2 }); + + render(); + + expect(screen.getByTestId('poll-add-choice-3')).toBeInTheDocument(); + }); + + it('should not render add choice button when buttonInputIndex is 4', () => { + usePollStore.setState({ isOpen: true, buttonInputIndex: 4 }); + + render(); + + expect(screen.queryByTestId('poll-add-choice-5')).not.toBeInTheDocument(); + }); + + it('should increment buttonInputIndex when add choice button is clicked', () => { + usePollStore.setState({ isOpen: true, buttonInputIndex: 2 }); + + render(); + + const addButton = screen.getByTestId('poll-add-choice-3'); + fireEvent.click(addButton); + + expect(usePollStore.getState().buttonInputIndex).toBe(3); + }); + + it('should render time options', () => { + usePollStore.setState({ isOpen: true }); + + render(); + + expect(screen.getByTestId('poll-time-options')).toBeInTheDocument(); + expect(screen.getByLabelText('Days')).toBeInTheDocument(); + expect(screen.getByLabelText('Hours')).toBeInTheDocument(); + expect(screen.getByLabelText('Minutes')).toBeInTheDocument(); + }); + + it('should render poll length section', () => { + usePollStore.setState({ isOpen: true }); + + render(); + + expect(screen.getByText('Poll length')).toBeInTheDocument(); + }); + + it('should render remove poll button', () => { + usePollStore.setState({ isOpen: true }); + + render(); + + expect(screen.getByTestId('poll-remove-button')).toBeInTheDocument(); + expect(screen.getByTestId('poll-remove-button')).toHaveTextContent( + 'Remove poll' + ); + }); + + it('should close poll when remove button is clicked', () => { + usePollStore.setState({ isOpen: true }); + + render(); + + const removeButton = screen.getByTestId('poll-remove-button'); + fireEvent.click(removeButton); + + expect(usePollStore.getState().isOpen).toBe(false); + }); + + it('should have correct container styling', () => { + usePollStore.setState({ isOpen: true }); + + render(); + + const container = screen.getByTestId('poll-container'); + expect(container).toHaveClass('w-[513px]'); + expect(container).toHaveClass('rounded-lg'); + expect(container).toHaveClass('bg-background'); + expect(container).toHaveClass('border'); + expect(container).toHaveClass('border-border'); + }); + + it('should display choice values from store', () => { + usePollStore.setState({ + isOpen: true, + buttonInputIndex: 2, + choices: ['Option A', 'Option B', '', ''], + }); + + render(); + + expect(screen.getByTestId('poll-choice-input-1')).toHaveValue('Option A'); + expect(screen.getByTestId('poll-choice-input-2')).toHaveValue('Option B'); + }); + + it('should update choice when input changes', () => { + usePollStore.setState({ isOpen: true, buttonInputIndex: 2 }); + + render(); + + const input = screen.getByTestId('poll-choice-input-1'); + fireEvent.change(input, { target: { value: 'New Choice' } }); + + expect(usePollStore.getState().choices[0]).toBe('New Choice'); + }); + + it('should display time values from store', () => { + usePollStore.setState({ + isOpen: true, + time: [3, 12, 30], + }); + + render(); + + expect(screen.getByLabelText('Days')).toHaveValue('3'); + expect(screen.getByLabelText('Hours')).toHaveValue('12'); + expect(screen.getByLabelText('Minutes')).toHaveValue('30'); + }); + + it('should update time when time option changes', () => { + usePollStore.setState({ isOpen: true, time: [1, 0, 0] }); + + render(); + + const daysSelect = screen.getByLabelText('Days'); + fireEvent.change(daysSelect, { target: { value: '5' } }); + + expect(usePollStore.getState().time[0]).toBe(5); + }); + + it('should remove button have correct error styling', () => { + usePollStore.setState({ isOpen: true }); + + render(); + + const removeButton = screen.getByTestId('poll-remove-button'); + expect(removeButton).toHaveClass('text-error'); + expect(removeButton).toHaveClass('border-t'); + expect(removeButton).toHaveClass('border-border'); + }); +}); diff --git a/src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx b/src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx new file mode 100644 index 00000000..a56cb8d6 --- /dev/null +++ b/src/features/timeline/tests/RealTimeTweetOptimistics.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +// Mock react-query +const mockQueryClient = { + getQueryData: vi.fn(), + setQueryData: vi.fn(), + cancelQueries: vi.fn(), + refetchQueries: vi.fn(), +}; + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => mockQueryClient, +})); + +// Mock useTimelineStore +vi.mock('../store/useTimelineStore', () => ({ + useSelectedTab: vi.fn(() => 'For you'), +})); + +// Mock useExploreStore +vi.mock('@/features/explore/store/useExploreStore', () => ({ + useSearch: vi.fn(() => ''), + useSelectedSearchTab: vi.fn(() => 'top'), + useInterest: vi.fn(() => ''), + useSelectedInterestTab: vi.fn(() => 'top'), +})); + +// Mock profile store +vi.mock('@/features/profile/store/profileStore', () => ({ + useSelectedTab: vi.fn(() => 'Posts'), +})); + +vi.mock('@/features/profile', () => ({ + useProfileStore: vi.fn((selector) => selector({ currentProfile: null })), + PROFILE_QUERY_KEYS: { + profilePosts: (id: number) => ['profile', 'posts', id], + profileReplies: (id: number) => ['profile', 'replies', id], + profileLikes: (id: number) => ['profile', 'likes', id], + profileMentions: (id: number) => ['profile', 'mentions', id], + profileMedia: (id: number) => ['profile', 'media', id], + }, +})); + +// Mock auth +vi.mock('@/features/authentication/hooks', () => ({ + useAuth: vi.fn(() => ({ + user: { id: 1, username: 'testuser' }, + })), +})); + +// Mock navigation +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/home'), + useRouter: vi.fn(() => ({ + push: vi.fn(), + })), +})); + +// Mock tweet store +vi.mock('@/features/tweets/store/tweetStore', () => ({ + useTweetStore: vi.fn(() => ({})), +})); + +// Mock tweet queries +vi.mock('@/features/tweets/hooks/tweetQueries', () => ({ + TWEET_QUERY_KEYS: { + tweetById: (id: number) => ['tweet', id], + getRepliesByTweetId: (id: number) => ['tweet', 'replies', id], + }, +})); + +// Mock explore queries +vi.mock('@/features/explore/hooks/exploreQueries', () => ({ + EXPLORE_QUERY_KEYS: { + EXPLORE_FEED_FOR_YOU: ['explore', 'for-you'], + EXPLORE_FEED_SEARCH_TOP: (search: string) => [ + 'explore', + 'search', + 'top', + search, + ], + EXPLORE_FEED_SEARCH_LATEST: (search: string) => [ + 'explore', + 'search', + 'latest', + search, + ], + EXPLORE_FEED_INTEREST: (interest: string, tab: string) => [ + 'explore', + 'interest', + interest, + tab, + ], + }, +})); + +// Import after mocks +import { + useTimelineQueryKey, + useProfileQueryKey, + useExploreQueryKey, + useInterestQueryKey, + useRealTimeTweet, +} from '../optimistics/RealTimeTweet'; + +describe('RealTimeTweet optimistics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useTimelineQueryKey', () => { + it('should return timeline query key', () => { + const { result } = renderHook(() => useTimelineQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useProfileQueryKey', () => { + it('should return profile query key', () => { + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useExploreQueryKey', () => { + it('should return explore query key', () => { + const { result } = renderHook(() => useExploreQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useInterestQueryKey', () => { + it('should return interest query key', () => { + const { result } = renderHook(() => useInterestQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useRealTimeTweet', () => { + it('should return onMutate function', () => { + const { result } = renderHook(() => useRealTimeTweet()); + expect(result.current.onMutate).toBeDefined(); + expect(typeof result.current.onMutate).toBe('function'); + }); + + it('should call onMutate with correct parameters', async () => { + const { result } = renderHook(() => useRealTimeTweet()); + + const response = await result.current.onMutate('LIKE', 1, 1, 10); + + expect(response).toHaveProperty('previousFeeds'); + expect(response).toHaveProperty('oldTweet'); + }); + + it('should handle REPOST type', async () => { + const { result } = renderHook(() => useRealTimeTweet()); + + const response = await result.current.onMutate('REPOST', 1, 1, 5); + + expect(response).toBeDefined(); + }); + + it('should handle REPLY type', async () => { + const { result } = renderHook(() => useRealTimeTweet()); + + // Just test that onMutate can be called with REPLY type + const response = await result.current.onMutate( + 'REPLY', + 1, + 1, + 3, + 'POST', + 2 + ); + + expect(response).toBeDefined(); + }); + }); +}); diff --git a/src/features/timeline/tests/Reply.test.tsx b/src/features/timeline/tests/Reply.test.tsx new file mode 100644 index 00000000..24b1563e --- /dev/null +++ b/src/features/timeline/tests/Reply.test.tsx @@ -0,0 +1,79 @@ +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 Tweet component +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: any) => ( +
{data?.text || 'Tweet'}
+ ), +})); + +// Mock DeletedTweet +vi.mock('@/features/tweets/components/DeletedTweet', () => ({ + default: () =>
Deleted Tweet
, +})); + +// Mock useTweetById +vi.mock('@/features/tweets/hooks/tweetQueries', () => ({ + useTweetById: vi.fn(() => ({ + data: null, + isLoading: false, + })), +})); + +import Reply from '../components/Reply'; + +describe('Reply', () => { + const mockReplyData = { + postId: 1, + userId: 1, + text: 'This is a reply', + date: '2024-01-01', + isRepost: false, + isQuote: false, + type: 'REPLY', + originalPostData: { + postId: 2, + userId: 2, + text: 'Original post', + date: '2024-01-01', + isRepost: false, + isQuote: false, + isDeleted: false, + type: 'POST', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render reply component', () => { + render(); + expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); + }); + + it('should render without original post', () => { + render(); + expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); + }); + + it('should render in profile mode', () => { + render(); + expect(screen.getAllByTestId('tweet').length).toBeGreaterThan(0); + }); + + it('should render deleted tweet when original is deleted', () => { + const deletedData = { + ...mockReplyData, + originalPostData: { + ...mockReplyData.originalPostData, + isDeleted: true, + }, + }; + render(); + expect(screen.getByTestId('deleted-tweet')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/ReplyQuoteSection.test.tsx b/src/features/timeline/tests/ReplyQuoteSection.test.tsx new file mode 100644 index 00000000..a1dcfa06 --- /dev/null +++ b/src/features/timeline/tests/ReplyQuoteSection.test.tsx @@ -0,0 +1,76 @@ +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 AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useTweetText: () => 'test tweet', + useMedia: () => [], + useMentions: () => [], + useActions: () => ({ + setTweetText: vi.fn(), + addMedia: vi.fn(), + reset: vi.fn(), + }), + })), +})); + +// Mock useParentId +vi.mock('../store/useTimelineStore', () => ({ + useParentId: vi.fn(() => 1), +})); + +// Mock useAddTweet +vi.mock('../hooks/timelineQueries', () => ({ + useAddTweet: vi.fn(() => ({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + })), +})); + +// Mock TweetSubmitSection +vi.mock('./TweetSubmitSection', () => ({ + default: ({ label, handleAddTweet, enableAddTweet }: any) => ( +
+ +
+ ), +})); + +import ReplyQuoteSections from '../components/ReplyQuoteSection'; + +describe('ReplyQuoteSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render tweet submit section', () => { + render(); + expect(screen.getByTestId('tweet-submit-section')).toBeInTheDocument(); + }); + + it('should render Reply label for REPLY type', () => { + render(); + expect(screen.getByText('Reply')).toBeInTheDocument(); + }); + + it('should render Post label for QUOTE type', () => { + render(); + expect(screen.getByText('Post')).toBeInTheDocument(); + }); + + it('should render submit button', () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/SearchProfile.test.tsx b/src/features/timeline/tests/SearchProfile.test.tsx new file mode 100644 index 00000000..a3a45f71 --- /dev/null +++ b/src/features/timeline/tests/SearchProfile.test.tsx @@ -0,0 +1,143 @@ +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 useTimelineStore +vi.mock('../store/useTimelineStore', () => ({ + useSearch: vi.fn(() => 'test'), + useSearchAction: vi.fn(() => ({ + setSearch: vi.fn(), + setIsOpen: vi.fn(), + })), + useSearchIsopen: vi.fn(() => true), +})); + +// Mock timelineQueries +vi.mock('../hooks/timelineQueries', () => ({ + useSearchProfile: vi.fn(() => ({ + data: { + pages: [ + { + data: [{ User: { username: 'testuser' }, user_id: 1 }], + metadata: { total: 1, limit: 10 }, + }, + ], + }, + isLoading: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + hasNextPage: false, + isError: false, + error: null, + })), + useSearchHashtag: vi.fn(() => ({ + data: null, + isLoading: false, + })), +})); + +// Mock useDebounce +vi.mock('../hooks/useDebounce', () => ({ + default: vi.fn((value: string) => value), +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), + usePathname: vi.fn(() => '/home'), +})); + +// Mock components +vi.mock('@/components/ui/home/XMenu', () => { + const XMenu = function XMenu({ children }: { children: React.ReactNode }) { + return
{children}
; + }; + XMenu.Button = function XMenuButton({ + children, + }: { + children: React.ReactNode; + }) { + return
{children}
; + }; + XMenu.List = function XMenuList({ children }: { children: React.ReactNode }) { + return
{children}
; + }; + XMenu.Items = function XMenuItems({ + children, + }: { + children: React.ReactNode; + }) { + return
{children}
; + }; + return { default: XMenu }; +}); + +vi.mock('@/components/ui/input', () => ({ + SearchInput: ({ value, onChange }: any) => ( + onChange(e.target.value)} + /> + ), +})); + +vi.mock('@/components/ui/UserCard', () => ({ + default: ({ username }: any) =>
{username}
, +})); + +vi.mock('@/components/generic', () => ({ + Loader: () =>
Loading...
, +})); + +vi.mock('@/components/ui/home/InfiniteScroll', () => ({ + default: ({ children }: any) => ( +
{children}
+ ), +})); + +vi.mock('@/components/ui/home/Icon', () => ({ + default: function Icon() { + return
Icon
; + }, +})); + +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn(), +})); + +import SearchProfile from '../components/SearchProfile'; + +describe('SearchProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render search profile component', () => { + render(); + // SearchProfile renders an XMenu wrapper + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + }); + + it('should render search input', () => { + render(); + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + }); + + it('should render x-menu button', () => { + render(); + // SearchProfile renders with search input + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + }); + + it('should render user cards when data is available', () => { + render(); + expect(screen.getByTestId('user-card')).toBeInTheDocument(); + }); + + it('should render infinite scroll', () => { + render(); + expect(screen.getByTestId('infinite-scroll')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/ShowTweets.test.tsx b/src/features/timeline/tests/ShowTweets.test.tsx new file mode 100644 index 00000000..02eb0412 --- /dev/null +++ b/src/features/timeline/tests/ShowTweets.test.tsx @@ -0,0 +1,49 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import ShowTweets from '../components/ShowTweets'; + +describe('ShowTweets Component', () => { + it('should render the show tweets button', () => { + render(); + + const button = screen.getByTestId('show-tweets-button'); + expect(button).toBeInTheDocument(); + }); + + it('should display correct text', () => { + render(); + + const button = screen.getByTestId('show-tweets-button'); + expect(button).toHaveTextContent('Show Hankers posts'); + }); + + it('should have correct styling classes', () => { + render(); + + const button = screen.getByTestId('show-tweets-button'); + expect(button).toHaveClass('flex'); + expect(button).toHaveClass('h-12'); + expect(button).toHaveClass('w-full'); + expect(button).toHaveClass('p-3'); + expect(button).toHaveClass('justify-center'); + expect(button).toHaveClass('text-primary'); + expect(button).toHaveClass('border-b-1'); + expect(button).toHaveClass('border-border'); + }); + + it('should have hover styles', () => { + render(); + + const button = screen.getByTestId('show-tweets-button'); + expect(button).toHaveClass('hover:cursor-pointer'); + expect(button).toHaveClass('hover:bg-border'); + }); + + it('should render as a div element', () => { + render(); + + const button = screen.getByTestId('show-tweets-button'); + expect(button.tagName).toBe('DIV'); + }); +}); diff --git a/src/features/timeline/tests/TimeOptions.test.tsx b/src/features/timeline/tests/TimeOptions.test.tsx new file mode 100644 index 00000000..903504be --- /dev/null +++ b/src/features/timeline/tests/TimeOptions.test.tsx @@ -0,0 +1,265 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import TimeOptions from '../components/TimeOptions'; + +describe('TimeOptions Component', () => { + const mockSetOption = vi.fn(); + + beforeEach(() => { + mockSetOption.mockClear(); + }); + + it('should render the select element', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + expect(select).toBeInTheDocument(); + }); + + it('should display the label', () => { + render( + + ); + + expect(screen.getByText('Days')).toBeInTheDocument(); + }); + + it('should render correct options for Days (0-7)', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + const options = select.querySelectorAll('option'); + + expect(options.length).toBe(8); // 0-7 = 8 options + expect(options[0]).toHaveValue('0'); + expect(options[7]).toHaveValue('7'); + }); + + it('should render correct options for Hours (0-23)', () => { + render( + + ); + + const select = screen.getByLabelText('Hours'); + const options = select.querySelectorAll('option'); + + expect(options.length).toBe(24); // 0-23 = 24 options + expect(options[0]).toHaveValue('0'); + expect(options[23]).toHaveValue('23'); + }); + + it('should render correct options for Minutes (0-59)', () => { + render( + + ); + + const select = screen.getByLabelText('Minutes'); + const options = select.querySelectorAll('option'); + + expect(options.length).toBe(60); // 0-59 = 60 options + expect(options[0]).toHaveValue('0'); + expect(options[59]).toHaveValue('59'); + }); + + it('should render options starting from shiftStart for Minutes', () => { + render( + + ); + + const select = screen.getByLabelText('Minutes'); + const options = select.querySelectorAll('option'); + + expect(options.length).toBe(55); // 5-59 = 55 options + expect(options[0]).toHaveValue('5'); + expect(options[54]).toHaveValue('59'); + }); + + it('should have the correct value selected', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + expect(select).toHaveValue('3'); + }); + + it('should call setOption when selection changes', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + fireEvent.change(select, { target: { value: '5' } }); + + expect(mockSetOption).toHaveBeenCalledWith(1, 5); + }); + + it('should pass the correct id to setOption', () => { + render( + + ); + + const select = screen.getByLabelText('Hours'); + fireEvent.change(select, { target: { value: '12' } }); + + expect(mockSetOption).toHaveBeenCalledWith(2, 12); + }); + + it('should have correct styling classes', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + expect(select).toHaveClass('appearance-none'); + expect(select).toHaveClass('peer'); + expect(select).toHaveClass('w-full'); + expect(select).toHaveClass('h-15'); + expect(select).toHaveClass('bg-transparent'); + }); + + it('should not apply shiftStart for non-Minutes types', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + const options = select.querySelectorAll('option'); + + // Days should still be 0-7, ignoring shiftStart + expect(options.length).toBe(8); + expect(options[0]).toHaveValue('0'); + }); + + it('should not apply shiftStart for Hours', () => { + render( + + ); + + const select = screen.getByLabelText('Hours'); + const options = select.querySelectorAll('option'); + + // Hours should still be 0-23, ignoring shiftStart + expect(options.length).toBe(24); + expect(options[0]).toHaveValue('0'); + }); + + it('should default shiftStart to 0', () => { + render( + + ); + + const select = screen.getByLabelText('Minutes'); + const options = select.querySelectorAll('option'); + + expect(options.length).toBe(60); + expect(options[0]).toHaveValue('0'); + }); + + it('should render container with max-width', () => { + const { container } = render( + + ); + + const containerDiv = container.querySelector('.max-w-\\[150px\\]'); + expect(containerDiv).toBeInTheDocument(); + }); + + it('should render dropdown arrow icon', () => { + const { container } = render( + + ); + + const svgIcon = container.querySelector('svg'); + expect(svgIcon).toBeInTheDocument(); + }); + + it('should handle selection of edge values - 0', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + expect(select).toHaveValue('0'); + + fireEvent.change(select, { target: { value: '0' } }); + expect(mockSetOption).toHaveBeenCalledWith(1, 0); + }); + + it('should handle selection of edge values - max Days', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + expect(select).toHaveValue('7'); + + fireEvent.change(select, { target: { value: '7' } }); + expect(mockSetOption).toHaveBeenCalledWith(1, 7); + }); + + it('should handle selection of edge values - max Hours', () => { + render( + + ); + + const select = screen.getByLabelText('Hours'); + expect(select).toHaveValue('23'); + + fireEvent.change(select, { target: { value: '23' } }); + expect(mockSetOption).toHaveBeenCalledWith(2, 23); + }); + + it('should handle selection of edge values - max Minutes', () => { + render( + + ); + + const select = screen.getByLabelText('Minutes'); + expect(select).toHaveValue('59'); + + fireEvent.change(select, { target: { value: '59' } }); + expect(mockSetOption).toHaveBeenCalledWith(3, 59); + }); + + it('should render options with correct background class', () => { + render( + + ); + + const select = screen.getByLabelText('Days'); + const options = select.querySelectorAll('option'); + + options.forEach((option) => { + expect(option).toHaveClass('bg-background'); + }); + }); +}); diff --git a/src/features/timeline/tests/Timeline.test.tsx b/src/features/timeline/tests/Timeline.test.tsx new file mode 100644 index 00000000..eb033d2f --- /dev/null +++ b/src/features/timeline/tests/Timeline.test.tsx @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +// Mock next/navigation first +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/home'), +})); + +// Mock explore store +vi.mock('@/features/explore/store/useExploreStore', () => ({ + useSearchExplore: vi.fn(() => ''), + useActions: vi.fn(() => ({ + setSearchQuery: vi.fn(), + })), +})); + +// Mock child components +vi.mock('../components/AddTweet', () => ({ + default: ({ type, persistent }: { type?: string; persistent?: boolean }) => ( +
+ AddTweet +
+ ), +})); + +vi.mock('../components/Header', () => ({ + default: () =>
Header
, +})); + +vi.mock('../components/TweetList', () => ({ + default: () =>
TweetList
, +})); + +vi.mock('@/components/ui/home/Icon', () => ({ + default: ({ path }: { path: string }) => ( + + + + ), +})); + +vi.mock('@/components/generic', () => ({ + Avatar: function Avatar({ + avatarImage, + name, + size, + }: { + avatarImage?: string; + name?: string; + size?: string; + }) { + return ( +
+ ); + }, +})); + +// Mock useTimelineStore hooks +const mockSetPopUpAvatars = vi.fn(); +const mockSetFetchAvatars = vi.fn(); +const mockSetNewTweets = vi.fn(); + +vi.mock('../store/useTimelineStore', () => ({ + default: vi.fn(), + useActions: vi.fn(() => ({ + setPopUpAvatars: mockSetPopUpAvatars, + setFetchAvatars: mockSetFetchAvatars, + setNewTweets: mockSetNewTweets, + })), + useFetchAvatars: vi.fn(() => false), + useNewTweets: vi.fn(() => []), + usePopUpAvatars: vi.fn(() => []), + useSelectedTab: vi.fn(() => 'forYou'), +})); + +// Mock timelineQueries +vi.mock('../hooks/timelineQueries', () => ({ + TIMELINE_QUERY_KEYS: { + TIMELINE_FEED_FOR_YOU: ['timeline', 'forYou'], + TIMELINE_FEED_FOLLOWING: ['timeline', 'following'], + }, + useAvatarsPopUp: vi.fn(() => ({ + data: null, + error: null, + isError: false, + isLoading: false, + })), +})); + +// Import component after mocks +import Timeline from '../components/Timeline'; + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('Timeline', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock IntersectionObserver + const mockIntersectionObserver = vi.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }); + window.IntersectionObserver = mockIntersectionObserver; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should render Timeline component', () => { + render(, { wrapper }); + + const timeline = screen.getByTestId('timeline'); + expect(timeline).toBeDefined(); + }); + + it('should render Header component', () => { + render(, { wrapper }); + + const header = screen.getByTestId('header'); + expect(header).toBeDefined(); + }); + + it('should render AddTweet component', () => { + render(, { wrapper }); + + const addTweet = screen.getByTestId('add-tweet'); + expect(addTweet).toBeDefined(); + }); + + it('should render TweetList component', () => { + render(, { wrapper }); + + const tweetList = screen.getByTestId('tweet-list'); + expect(tweetList).toBeDefined(); + }); + + it('should pass POST type to AddTweet', () => { + render(, { wrapper }); + + const addTweet = screen.getByTestId('add-tweet'); + expect(addTweet.getAttribute('data-type')).toBe('POST'); + }); + + it('should pass persistent prop to AddTweet', () => { + render(, { wrapper }); + + const addTweet = screen.getByTestId('add-tweet'); + expect(addTweet.getAttribute('data-persistent')).toBe('true'); + }); + + it('should not show popup when avatars is empty', () => { + render(, { wrapper }); + + const popupText = screen.queryByText('Posted'); + expect(popupText).toBeNull(); + }); + + it('should set up IntersectionObserver', () => { + render(, { wrapper }); + + expect(window.IntersectionObserver).toHaveBeenCalled(); + }); + + it('should have correct layout classes', () => { + render(, { wrapper }); + + const timeline = screen.getByTestId('timeline'); + expect(timeline.className).toContain('flex'); + expect(timeline.className).toContain('flex-col'); + }); + + it('should render timeline-content container', () => { + render(, { wrapper }); + + const content = screen.getByTestId('timeline-content'); + expect(content).toBeDefined(); + }); +}); diff --git a/src/features/timeline/tests/TweetFeed.test.tsx b/src/features/timeline/tests/TweetFeed.test.tsx new file mode 100644 index 00000000..4a7e09b1 --- /dev/null +++ b/src/features/timeline/tests/TweetFeed.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +// Since TweetFeed is an async server component, we test TweetList directly +// which is what TweetFeed renders + +// Mock useTimelineFeed +vi.mock('../hooks/timelineQueries', () => ({ + useTimelineFeed: vi.fn(() => ({ + data: { + pages: [ + { + data: { + posts: [ + { userId: 1, postId: 1, date: '2024-01-01', text: 'Test tweet' }, + ], + }, + }, + ], + }, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + isError: false, + error: null, + })), +})); + +// Mock Tweet component +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: any) => ( +
{data?.text || 'Tweet'}
+ ), +})); + +// Mock useTweetStore +vi.mock('@/features/tweets/store/tweetStore', () => ({ + useTweetStore: vi.fn(() => ({})), +})); + +// Mock InfiniteScroll +vi.mock('@/components/ui/home/InfiniteScroll', () => ({ + default: ({ children }: any) => ( +
{children}
+ ), +})); + +// Mock Loader +vi.mock('@/components/generic/Loader', () => ({ + default: () =>
Loading...
, +})); + +// Mock toasterMessage +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn(), +})); + +import TweetList from '../components/TweetList'; + +describe('TweetFeed (via TweetList)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render tweet list container', () => { + render(); + expect(screen.getByTestId('tweet-list-container')).toBeInTheDocument(); + }); + + it('should render tweets from feed', () => { + render(); + expect(screen.getByTestId('tweet')).toBeInTheDocument(); + }); + + it('should render infinite scroll', () => { + render(); + expect(screen.getByTestId('infinite-scroll')).toBeInTheDocument(); + }); + + it('should show tweet content', () => { + render(); + expect(screen.getByText('Test tweet')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/TweetFooter.test.tsx b/src/features/timeline/tests/TweetFooter.test.tsx new file mode 100644 index 00000000..3282c38c --- /dev/null +++ b/src/features/timeline/tests/TweetFooter.test.tsx @@ -0,0 +1,103 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import TweetFooter from '../components/TweetFooter'; + +describe('TweetFooter Component', () => { + it('should render children correctly', () => { + render( + + Child Content + + ); + + const child = screen.getByTestId('child-element'); + expect(child).toBeInTheDocument(); + expect(child).toHaveTextContent('Child Content'); + }); + + it('should render the footer container', () => { + render( + + Content + + ); + + const footer = screen.getByTestId('tweet-footer'); + expect(footer).toBeInTheDocument(); + }); + + it('should have correct styling classes', () => { + render( + + Content + + ); + + const footer = screen.getByTestId('tweet-footer'); + expect(footer).toHaveClass('w-full'); + expect(footer).toHaveClass('h-13'); + expect(footer).toHaveClass('flex'); + expect(footer).toHaveClass('flex-1'); + expect(footer).toHaveClass('items-center'); + expect(footer).toHaveClass('justify-between'); + expect(footer).toHaveClass('pb-2'); + expect(footer).toHaveClass('border-t'); + expect(footer).toHaveClass('border-border'); + }); + + it('should render multiple children', () => { + render( + + First + Second + Third + + ); + + expect(screen.getByTestId('child-1')).toBeInTheDocument(); + expect(screen.getByTestId('child-2')).toBeInTheDocument(); + expect(screen.getByTestId('child-3')).toBeInTheDocument(); + }); + + it('should render complex nested children', () => { + render( + +
+ Nested Content +
+
+ ); + + const parent = screen.getByTestId('nested-parent'); + const child = screen.getByTestId('nested-child'); + expect(parent).toBeInTheDocument(); + expect(child).toBeInTheDocument(); + expect(parent).toContainElement(child); + }); + + it('should render empty children without errors', () => { + render({null}); + + const footer = screen.getByTestId('tweet-footer'); + expect(footer).toBeInTheDocument(); + }); + + it('should render text content as children', () => { + render(Text content only); + + const footer = screen.getByTestId('tweet-footer'); + expect(footer).toHaveTextContent('Text content only'); + }); + + it('should render as a div element', () => { + render( + + Content + + ); + + const footer = screen.getByTestId('tweet-footer'); + expect(footer.tagName).toBe('DIV'); + }); +}); diff --git a/src/features/timeline/tests/TweetImages.test.tsx b/src/features/timeline/tests/TweetImages.test.tsx new file mode 100644 index 00000000..d2fbbd13 --- /dev/null +++ b/src/features/timeline/tests/TweetImages.test.tsx @@ -0,0 +1,55 @@ +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 AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useMedia: () => [], + useActions: () => ({ + addMedia: vi.fn(), + removeMedia: vi.fn(), + }), + })), +})); + +// Mock Icon +vi.mock('@/components/ui/home/Icon', () => ({ + default: (props: any) =>
{props.title}
, +})); + +// Mock toasterMessage +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn(), +})); + +import TweetImages from '../components/TweetImages'; + +describe('TweetImages', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render media import input', () => { + render(); + expect(screen.getByTestId('media-import')).toBeInTheDocument(); + }); + + it('should render icon for media', () => { + render(); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); + + it('should have file input with multiple attribute', () => { + render(); + const input = screen.getByTestId('media-import'); + expect(input).toHaveAttribute('multiple'); + }); + + it('should have file input with correct type', () => { + render(); + const input = screen.getByTestId('media-import'); + expect(input).toHaveAttribute('type', 'file'); + }); +}); diff --git a/src/features/timeline/tests/TweetList.test.tsx b/src/features/timeline/tests/TweetList.test.tsx new file mode 100644 index 00000000..1f7eb23b --- /dev/null +++ b/src/features/timeline/tests/TweetList.test.tsx @@ -0,0 +1,85 @@ +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 useTimelineFeed +vi.mock('../hooks/timelineQueries', () => ({ + useTimelineFeed: vi.fn(() => ({ + data: { + pages: [ + { + data: { + posts: [ + { userId: 1, postId: 1, date: '2024-01-01', text: 'Test tweet' }, + ], + }, + }, + ], + }, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + isError: false, + error: null, + })), +})); + +// Mock Tweet component +vi.mock('@/features/tweets/components/Tweet', () => ({ + default: ({ data }: any) => ( +
{data?.text || 'Tweet'}
+ ), +})); + +// Mock useTweetStore +vi.mock('@/features/tweets/store/tweetStore', () => ({ + useTweetStore: vi.fn(() => ({})), +})); + +// Mock InfiniteScroll +vi.mock('@/components/ui/home/InfiniteScroll', () => ({ + default: ({ children }: any) => ( +
{children}
+ ), +})); + +// Mock Loader +vi.mock('@/components/generic/Loader', () => ({ + default: () =>
Loading...
, +})); + +// Mock toasterMessage +vi.mock('@/components/ui/home/ToasterMessage', () => ({ + default: vi.fn(), +})); + +import TweetList from '../components/TweetList'; + +describe('TweetList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render tweet list container', () => { + render(); + expect(screen.getByTestId('tweet-list-container')).toBeInTheDocument(); + }); + + it('should render infinite scroll component', () => { + render(); + expect(screen.getByTestId('infinite-scroll')).toBeInTheDocument(); + }); + + it('should render render-tweet-list', () => { + render(); + expect(screen.getByTestId('render-tweet-list')).toBeInTheDocument(); + }); + + it('should render tweets when data available', () => { + render(); + expect(screen.getByTestId('tweet')).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/TweetOptionsBar.test.tsx b/src/features/timeline/tests/TweetOptionsBar.test.tsx new file mode 100644 index 00000000..99f1f2ee --- /dev/null +++ b/src/features/timeline/tests/TweetOptionsBar.test.tsx @@ -0,0 +1,93 @@ +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 AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useMedia: () => [], + useGifVisibility: () => false, + useActions: () => ({ + addMedia: vi.fn(), + addGifs: vi.fn(), + setEmoji: vi.fn(), + open: vi.fn(), + close: vi.fn(), + }), + })), +})); + +// 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) => ( + + ), +})); + +// Mock TweetImages +vi.mock('./TweetImages', () => ({ + default: () =>
TweetImages
, +})); + +vi.mock('../components/TweetImages', () => ({ + default: () =>
TweetImages
, +})); + +// Mock Emoji +vi.mock('@/features/media/components/Emoji', () => ({ + default: () =>
Emoji
, +})); + +import TweetOptionsBar from '../components/TweetOptionsBar'; + +describe('TweetOptionsBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render options bar', () => { + render(); + expect(screen.getByTestId('tweet-options-bar')).toBeInTheDocument(); + }); + + it('should render GIF icon', () => { + render(); + // The icon renders with title "GIF" + expect(screen.getByText('GIF')).toBeInTheDocument(); + }); + + it('should render TweetImages component', () => { + render(); + expect(screen.getByTestId('tweet-images')).toBeInTheDocument(); + }); + + it('should render Emoji component', () => { + render(); + 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(); + }); +}); diff --git a/src/features/timeline/tests/TweetReplySettings.test.tsx b/src/features/timeline/tests/TweetReplySettings.test.tsx new file mode 100644 index 00000000..f900f546 --- /dev/null +++ b/src/features/timeline/tests/TweetReplySettings.test.tsx @@ -0,0 +1,71 @@ +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 AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useSelectedReplyOption: () => 1, + useActions: () => ({ + updateReplyOption: vi.fn(), + }), + })), +})); + +// Mock Icon +vi.mock('../../../components/ui/home/Icon', () => ({ + default: function Icon() { + return
Icon
; + }, +})); + +// Mock XMenu +vi.mock('@/components/ui/home/XMenu', () => { + const XMenu = function XMenu({ children }: { children: React.ReactNode }) { + return
{children}
; + }; + XMenu.Button = function XMenuButton({ + children, + }: { + children: React.ReactNode; + }) { + return
{children}
; + }; + XMenu.List = function XMenuList({ children }: { children: React.ReactNode }) { + return
{children}
; + }; + XMenu.Items = function XMenuItems({ + children, + }: { + children: React.ReactNode; + }) { + return
{children}
; + }; + return { default: XMenu, onClose: vi.fn() }; +}); + +import TweetReplySettings from '../components/TweetReplySettings'; + +describe('TweetReplySettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render reply settings', () => { + render(); + expect(screen.getByTestId('tweet-reply-settings')).toBeInTheDocument(); + }); + + it('should show reply option text', () => { + render(); + expect(screen.getByTestId('selected-reply')).toBeInTheDocument(); + }); + + it('should render button for menu', () => { + render(); + expect( + screen.getByTestId('tweet-reply-settings-button') + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/timeline/tests/TweetSubmitSection.test.tsx b/src/features/timeline/tests/TweetSubmitSection.test.tsx new file mode 100644 index 00000000..d0fd050b --- /dev/null +++ b/src/features/timeline/tests/TweetSubmitSection.test.tsx @@ -0,0 +1,236 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import TweetSubmitSection from '../components/TweetSubmitSection'; + +vi.mock('@/components/ui/home/Button', () => ({ + default: ({ + label, + disabled, + onClick, + 'data-testid': testId, + height, + width, + size, + }: any) => ( + + ), +})); + +vi.mock('./TypingProgressCircle', () => ({ + default: () =>
Progress
, +})); + +vi.mock('../components/TypingProgressCircle', () => ({ + default: () =>
Progress
, +})); + +describe('TweetSubmitSection Component', () => { + const mockHandleAddTweet = vi.fn(); + + beforeEach(() => { + mockHandleAddTweet.mockClear(); + }); + + it('should render the submit section container', () => { + render( + + ); + + const container = screen.getByTestId('tweet-submit-section'); + expect(container).toBeInTheDocument(); + }); + + it('should render the submit button with correct label', () => { + render( + + ); + + const button = screen.getByTestId('tweet-post-button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Post'); + }); + + it('should render button with "Reply" label', () => { + render( + + ); + + const button = screen.getByTestId('tweet-post-button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Reply'); + }); + + it('should disable button when enableAddTweet is false', () => { + render( + + ); + + const button = screen.getByTestId('tweet-post-button'); + expect(button).toBeDisabled(); + }); + + it('should enable button when enableAddTweet is true', () => { + render( + + ); + + const button = screen.getByTestId('tweet-post-button'); + expect(button).not.toBeDisabled(); + }); + + it('should call handleAddTweet when button is clicked', () => { + render( + + ); + + const button = screen.getByTestId('tweet-post-button'); + fireEvent.click(button); + + expect(mockHandleAddTweet).toHaveBeenCalledTimes(1); + }); + + it('should not call handleAddTweet when button is disabled', () => { + render( + + ); + + const button = screen.getByTestId('tweet-post-button'); + fireEvent.click(button); + + expect(mockHandleAddTweet).not.toHaveBeenCalled(); + }); + + it('should not show progress section when enableSection is false', () => { + render( + + ); + + // The border divider should not be present + const container = screen.getByTestId('tweet-submit-section'); + const borderElement = container.querySelector('.border-l-2'); + expect(borderElement).not.toBeInTheDocument(); + }); + + it('should show progress section when enableSection is true', () => { + render( + + ); + + // The border divider should be present + const container = screen.getByTestId('tweet-submit-section'); + const borderElement = container.querySelector('.border-l-2'); + expect(borderElement).toBeInTheDocument(); + }); + + it('should have correct styling classes', () => { + render( + + ); + + const container = screen.getByTestId('tweet-submit-section'); + expect(container).toHaveClass('flex'); + expect(container).toHaveClass('flex-row-reverse'); + expect(container).toHaveClass('items-center'); + expect(container).toHaveClass('mt-2'); + }); + + it('should render with different labels', () => { + const { rerender } = render( + + ); + + expect(screen.getByTestId('tweet-post-button')).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByTestId('tweet-post-button')).toBeInTheDocument(); + }); + + it('should handle multiple clicks when enabled', () => { + render( + + ); + + const button = screen.getByTestId('tweet-post-button'); + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button); + + expect(mockHandleAddTweet).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/features/timeline/tests/TweetText.test.tsx b/src/features/timeline/tests/TweetText.test.tsx new file mode 100644 index 00000000..98472785 --- /dev/null +++ b/src/features/timeline/tests/TweetText.test.tsx @@ -0,0 +1,112 @@ +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 the AddPostContext +const mockSetTweetText = vi.fn(); +const mockSetEmoji = vi.fn(); +const mockSetMention = vi.fn(); +const mockSetIsOpen = vi.fn(); +const mockSetIsDone = vi.fn(); +const mockSetKeyDown = vi.fn(); +const mockClearEmoji = vi.fn(); +const mockSetMentions = vi.fn(); + +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useTweetText: () => '', + useMedia: () => [], + useEmoji: () => '', + useMention: () => '', + useIsOpen: () => false, + useIsSuccess: () => false, + useMentionIsDone: () => '', + useCurrentKey: () => '', + usePlaceHolder: () => "What's happening?", + useActions: () => ({ + setTweetText: mockSetTweetText, + setEmoji: mockSetEmoji, + setMention: mockSetMention, + setIsOpen: mockSetIsOpen, + setIsDone: mockSetIsDone, + setKeyDown: mockSetKeyDown, + clearEmoji: mockClearEmoji, + setMentions: mockSetMentions, + }), + })), +})); + +vi.mock('./Mention', () => ({ + default: () =>
Mention
, +})); + +vi.mock('../components/Mention', () => ({ + default: () =>
Mention
, +})); + +// Mock useCheckValidUser +vi.mock('../hooks/timelineQueries', () => ({ + useCheckValidUser: vi.fn(() => ({ + data: null, + isLoading: false, + })), +})); + +import TweetText from '../components/TweetText'; + +describe('TweetText', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render textarea', () => { + render(); + expect(screen.getByTestId('tweet-text-input')).toBeInTheDocument(); + }); + + it('should render with default placeholder', () => { + render(); + expect(screen.getByTestId('tweet-text-display')).toBeInTheDocument(); + }); + + it('should render tweet text container', () => { + render(); + expect(screen.getByTestId('tweet-text-container')).toBeInTheDocument(); + }); + + it('should display placeholder text', () => { + render(); + expect(screen.getByText("What's happening?")).toBeInTheDocument(); + }); + + it('should have content editable input', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + expect(input).toHaveAttribute('contenteditable', 'plaintext-only'); + }); + + it('should render with custom placeholder', () => { + render(); + expect(screen.getByText('Post your reply')).toBeInTheDocument(); + }); + + it('should render mention component when needed', () => { + const { container } = render(); + expect(container).toBeDefined(); + }); + + it('should focus on input when clicked', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + fireEvent.click(input); + expect(input).toBeDefined(); + }); + + it('should handle keyboard events', () => { + render(); + const input = screen.getByTestId('tweet-text-input'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(input).toBeDefined(); + }); +}); diff --git a/src/features/timeline/tests/TweetsOptimistics.test.tsx b/src/features/timeline/tests/TweetsOptimistics.test.tsx new file mode 100644 index 00000000..5f9fa97b --- /dev/null +++ b/src/features/timeline/tests/TweetsOptimistics.test.tsx @@ -0,0 +1,437 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +// Mock react-query +const mockQueryClient = { + getQueryData: vi.fn(), + setQueryData: vi.fn(), + cancelQueries: vi.fn(), + refetchQueries: vi.fn(), + invalidateQueries: vi.fn(), +}; + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => mockQueryClient, +})); + +// Mock useTimelineStore +vi.mock('../store/useTimelineStore', () => ({ + useSelectedTab: vi.fn(() => 'For you'), + useParentId: vi.fn(() => 1), +})); + +// Mock useExploreStore +vi.mock('@/features/explore/store/useExploreStore', () => ({ + useSearch: vi.fn(() => ''), + useSelectedSearchTab: vi.fn(() => 'top'), + useInterest: vi.fn(() => ''), + useSelectedInterestTab: vi.fn(() => 'top'), +})); + +// Mock profile store +vi.mock('@/features/profile/store/profileStore', () => ({ + useSelectedTab: vi.fn(() => 'Posts'), + useActions: vi.fn(() => ({ + setCurrentProfile: vi.fn(), + })), +})); + +vi.mock('@/features/profile', () => ({ + useProfileStore: vi.fn((selector) => selector({ currentProfile: null })), + PROFILE_QUERY_KEYS: { + profilePosts: (id: number) => ['profile', 'posts', id], + profileReplies: (id: number) => ['profile', 'replies', id], + profileLikes: (id: number) => ['profile', 'likes', id], + profileMentions: (id: number) => ['profile', 'mentions', id], + profileMedia: (id: number) => ['profile', 'media', id], + }, +})); + +// Mock auth +vi.mock('@/features/authentication/hooks', () => ({ + useAuth: vi.fn(() => ({ + user: { id: 1, username: 'testuser' }, + })), +})); + +// Mock navigation +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/home'), + useRouter: vi.fn(() => ({ + push: vi.fn(), + })), +})); + +// Mock tweet store +vi.mock('@/features/tweets/store/tweetStore', () => ({ + useTweetStore: vi.fn((selector) => { + if (typeof selector === 'function') { + return selector({ + setCurrentTweet: vi.fn(), + currentTweet: null, + }); + } + return { setCurrentTweet: vi.fn(), currentTweet: null }; + }), +})); + +// Mock tweet queries +vi.mock('@/features/tweets/hooks/tweetQueries', () => ({ + TWEET_QUERY_KEYS: { + tweetById: (id: number) => ['tweet', id], + getRepliesByTweetId: (id: number) => ['tweet', 'replies', id], + }, +})); + +// Mock explore queries +vi.mock('@/features/explore/hooks/exploreQueries', () => ({ + EXPLORE_QUERY_KEYS: { + EXPLORE_FEED_FOR_YOU: ['explore', 'for-you'], + EXPLORE_FEED_SEARCH_TOP: (search: string) => [ + 'explore', + 'search', + 'top', + search, + ], + EXPLORE_FEED_SEARCH_LATEST: (search: string) => [ + 'explore', + 'search', + 'latest', + search, + ], + EXPLORE_FEED_INTEREST: (interest: string, tab: string) => [ + 'explore', + 'interest', + interest, + tab, + ], + }, +})); + +// Import after mocks +import { + useTimelineQueryKey, + useProfileQueryKey, + useExploreQueryKey, + useInterestQueryKey, + useOptimisticTweet, +} from '../optimistics/Tweets'; + +describe('Tweets optimistics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useTimelineQueryKey', () => { + it('should return timeline query key', () => { + const { result } = renderHook(() => useTimelineQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useProfileQueryKey', () => { + it('should return profile query key', () => { + const { result } = renderHook(() => useProfileQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useExploreQueryKey', () => { + it('should return explore query key', () => { + const { result } = renderHook(() => useExploreQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useInterestQueryKey', () => { + it('should return interest query key', () => { + const { result } = renderHook(() => useInterestQueryKey()); + expect(result.current).toBeDefined(); + }); + }); + + describe('useOptimisticTweet', () => { + it('should return onMutate function', () => { + const { result } = renderHook(() => useOptimisticTweet()); + expect(result.current.onMutate).toBeDefined(); + expect(typeof result.current.onMutate).toBe('function'); + }); + + it('should return handleErrorOptimisticTweet function', () => { + const { result } = renderHook(() => useOptimisticTweet()); + expect(result.current.handleErrorOptimisticTweet).toBeDefined(); + expect(typeof result.current.handleErrorOptimisticTweet).toBe('function'); + }); + + it('should call onMutate with LIKE type', async () => { + const { result } = renderHook(() => useOptimisticTweet()); + + 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); + + expect(response).toBeDefined(); + }); + + it('should call onMutate with BLOCK type', async () => { + const { result } = renderHook(() => useOptimisticTweet()); + + 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); + + expect(response).toBeDefined(); + }); + + it('should call handleErrorOptimisticTweet with valid context', () => { + const { result } = renderHook(() => useOptimisticTweet()); + + const context = { + previousFeeds: [ + { queryKey: ['timeline', 'forYou'], previousFeed: { pages: [] } }, + ], + oldTweet: null, + }; + + // 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 () => { + const { result } = renderHook(() => useOptimisticTweet()); + + const response = await result.current.onMutate('FOLLOW', 1, 1); + + expect(response).toBeDefined(); + expect(response).toHaveProperty('previousFeeds'); + }); + + it('should call onMutate with MUTE type', async () => { + const { result } = renderHook(() => useOptimisticTweet()); + + 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); + + expect(response).toBeDefined(); + }); + + it('should call onMutate with REPLY type', async () => { + const { result } = renderHook(() => useOptimisticTweet()); + + 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); + + 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 }, + }; + + 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 }, + ], + oldTweet: null, + }; + + 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: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + likesCount: 5, + isLikedByMe: false, + isRepost: false, + }, + ], + }, + }, + ], + }; + mockQueryClient.getQueryData.mockReturnValue(mockFeed); + + const { result } = renderHook(() => useOptimisticTweet()); + + const response = await result.current.onMutate('LIKE', 1, 1); + + expect(response.previousFeeds).toBeDefined(); + expect(mockQueryClient.cancelQueries).toHaveBeenCalled(); + }); + + it('should process REPOST mutation with existing feed data', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 1, + retweetsCount: 3, + isRepostedByMe: false, + 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 process DELETE mutation with existing feed data', 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('DELETE', 1, 1); + + expect(response.previousFeeds).toBeDefined(); + }); + + it('should process FOLLOW mutation with user posts', async () => { + const mockFeed = { + pages: [ + { + data: { + posts: [ + { + postId: 1, + userId: 2, + isFollowedByMe: false, + 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 repost with original post data', 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', 2, 2); + + expect(response).toBeDefined(); + }); + }); +}); diff --git a/src/features/timeline/tests/TypingProgressCircle.test.tsx b/src/features/timeline/tests/TypingProgressCircle.test.tsx new file mode 100644 index 00000000..c0fe6cc8 --- /dev/null +++ b/src/features/timeline/tests/TypingProgressCircle.test.tsx @@ -0,0 +1,221 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { vi, describe, it, expect } from 'vitest'; +import TypingProgressCircle from '../components/TypingProgressCircle'; +import { AddPostStoreContext } from '../store/AddPostContext'; +import { + MAX_TWEET_LENGTH, + MAX_WARNING_TWEET_LENGTH, + MAX_RED_PROGRESS_STEPS, +} from '../constants/tweetConstants'; + +// Create mock selectors +const createMockSelectors = (tweetText: string) => ({ + useTweetText: () => tweetText, + useMedia: () => [], + useMentions: () => [], + useIsSending: () => false, + useIsSuccess: () => false, + useError: () => '', + useEmoji: () => '', + useMention: () => '', + useCurrentKey: () => '', + useMentionIsDone: () => '', + useIsOpen: () => false, + usePlaceHolder: () => "What's happening?", + useGifVisibility: () => false, + useGifsSearch: () => '', + useParentId: () => -1, + useSelectedReplyOption: () => -1, + useActions: () => ({ + setTweetText: vi.fn(), + addMedia: vi.fn(), + removeMedia: vi.fn(), + clearMedia: vi.fn(), + setEmoji: vi.fn(), + clearEmoji: vi.fn(), + setMention: vi.fn(), + setIsOpen: vi.fn(), + setIsDone: vi.fn(), + setKeyDown: vi.fn(), + setPlaceHolder: vi.fn(), + open: vi.fn(), + close: vi.fn(), + setSearch: vi.fn(), + setParentId: vi.fn(), + updateReplyOption: vi.fn(), + startSending: vi.fn(), + onSuccess: vi.fn(), + seterror: vi.fn(), + setMentions: vi.fn(), + addGifs: vi.fn(), + }), +}); + +const renderWithContext = (tweetText: string) => { + const mockSelectors = createMockSelectors(tweetText); + + return render( + + + + ); +}; + +describe('TypingProgressCircle Component', () => { + it('should render the progress circle container', () => { + const { container } = renderWithContext(''); + + const circleContainer = container.querySelector('.relative.w-8.h-8'); + expect(circleContainer).toBeInTheDocument(); + }); + + it('should render SVG element', () => { + const { container } = renderWithContext('Hello'); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should show primary color for text under MAX_TWEET_LENGTH', () => { + const { container } = renderWithContext('Short text'); + + const circle = container.querySelector('circle.stroke-primary'); + expect(circle).toBeInTheDocument(); + }); + + it('should show warning color when text exceeds MAX_TWEET_LENGTH but under limit', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + 5); + const { container } = renderWithContext(text); + + const circle = container.querySelector('circle.stroke-warning'); + expect(circle).toBeInTheDocument(); + }); + + it('should show error color when text exceeds warning limit', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH + 1); + const { container } = renderWithContext(text); + + const circle = container.querySelector('circle.stroke-error'); + expect(circle).toBeInTheDocument(); + }); + + it('should not show any progress circle when over max steps', () => { + const text = 'a'.repeat( + MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH + MAX_RED_PROGRESS_STEPS + 1 + ); + const { container } = renderWithContext(text); + + // Should show transparent stroke + const primaryCircle = container.querySelector('circle.stroke-primary'); + const warningCircle = container.querySelector('circle.stroke-warning'); + // Also check error circle exists + container.querySelector('circle.stroke-error'); + + // At this length, circle should be transparent + expect(primaryCircle).not.toBeInTheDocument(); + expect(warningCircle).not.toBeInTheDocument(); + }); + + it('should not show character count for short text', () => { + renderWithContext('Short'); + + const countElement = screen.queryByTestId('words-count'); + expect(countElement).not.toBeInTheDocument(); + }); + + it('should show character count when text exceeds MAX_TWEET_LENGTH', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + 5); + renderWithContext(text); + + const countElement = screen.getByTestId('words-count'); + expect(countElement).toBeInTheDocument(); + expect(countElement).toHaveTextContent( + `${MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH - text.length}` + ); + }); + + it('should show negative count when over limit', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH + 5); + renderWithContext(text); + + const countElement = screen.getByTestId('words-count'); + expect(countElement).toBeInTheDocument(); + expect(countElement).toHaveTextContent('-5'); + }); + + it('should use smaller radius for text under MAX_TWEET_LENGTH', () => { + const { container } = renderWithContext('Short'); + + const circle = container.querySelector('circle'); + expect(circle).toHaveAttribute('r', '10'); + }); + + it('should use larger radius for text over MAX_TWEET_LENGTH', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + 5); + const { container } = renderWithContext(text); + + const circles = container.querySelectorAll('circle'); + // Should have circles with radius 13 + const hasLargeCircle = Array.from(circles).some( + (c) => c.getAttribute('r') === '13' + ); + expect(hasLargeCircle).toBe(true); + }); + + it('should render empty text correctly', () => { + const { container } = renderWithContext(''); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should handle exact MAX_TWEET_LENGTH', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH); + renderWithContext(text); + + // At exactly MAX_TWEET_LENGTH, should show count + const countElement = screen.getByTestId('words-count'); + expect(countElement).toBeInTheDocument(); + expect(countElement).toHaveTextContent(`${MAX_WARNING_TWEET_LENGTH}`); + }); + + it('should handle exact MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH); + renderWithContext(text); + + const countElement = screen.getByTestId('words-count'); + expect(countElement).toBeInTheDocument(); + expect(countElement).toHaveTextContent('0'); + }); + + it('should apply inactive color class for warning text count', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + 5); + renderWithContext(text); + + const countElement = screen.getByTestId('words-count'); + expect(countElement).toHaveClass('text-text-inactive'); + }); + + it('should apply error color class for overflow text count', () => { + const text = 'a'.repeat(MAX_TWEET_LENGTH + MAX_WARNING_TWEET_LENGTH + 5); + renderWithContext(text); + + const countElement = screen.getByTestId('words-count'); + expect(countElement).toHaveClass('text-error'); + }); + + it('should render border circle under warning level', () => { + const { container } = renderWithContext('Short text'); + + const borderCircle = container.querySelector('circle.stroke-border'); + expect(borderCircle).toBeInTheDocument(); + }); + + it('should have correct SVG viewBox', () => { + const { container } = renderWithContext('Test'); + + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('viewBox', '0 0 30 30'); + }); +}); diff --git a/src/features/timeline/tests/replyRegistry.test.tsx b/src/features/timeline/tests/replyRegistry.test.tsx new file mode 100644 index 00000000..2d7fff5d --- /dev/null +++ b/src/features/timeline/tests/replyRegistry.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getAddReplyStore, clearReplyStore } from '../store/replyRegistry'; + +describe('replyRegistry', () => { + beforeEach(() => { + // Clear the registry between tests by clearing known keys + clearReplyStore(1); + clearReplyStore(2); + clearReplyStore(3); + }); + + describe('getAddReplyStore', () => { + it('should create a new store for a new key', () => { + const store = getAddReplyStore(1); + expect(store).toBeDefined(); + }); + + it('should return the same store for the same key', () => { + const store1 = getAddReplyStore(1); + const store2 = getAddReplyStore(1); + expect(store1).toBe(store2); + }); + + it('should return different stores for different keys', () => { + const store1 = getAddReplyStore(1); + const store2 = getAddReplyStore(2); + expect(store1).not.toBe(store2); + }); + + it('should return a store that is a function', () => { + const store = getAddReplyStore(1); + expect(typeof store).toBe('function'); + }); + + it('should create store with initial state', () => { + const store = getAddReplyStore(1); + const state = store?.getState(); + expect(state).toBeDefined(); + expect(state?.tweetText).toBe(''); + expect(state?.media).toEqual([]); + }); + }); + + describe('clearReplyStore', () => { + it('should clear a specific store from the registry', () => { + const store1 = getAddReplyStore(1); + clearReplyStore(1); + const store2 = getAddReplyStore(1); + // After clearing, a new store should be created + expect(store1).not.toBe(store2); + }); + + it('should not affect other stores when clearing one', () => { + getAddReplyStore(1); // Create store 1 + const store2 = getAddReplyStore(2); + clearReplyStore(1); + const store2After = getAddReplyStore(2); + expect(store2).toBe(store2After); + }); + + it('should not throw when clearing non-existent key', () => { + expect(() => clearReplyStore(999)).not.toThrow(); + }); + }); + + describe('store functionality', () => { + it('should allow setting tweet text', () => { + const store = getAddReplyStore(1); + store?.getState().actions.setTweetText('Hello world'); + expect(store?.getState().tweetText).toBe('Hello world'); + }); + + it('should allow adding media', () => { + const store = getAddReplyStore(1); + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + store?.getState().actions.addMedia([mockFile]); + expect(store?.getState().media.length).toBe(1); + }); + + it('should isolate state between different stores', () => { + const store1 = getAddReplyStore(1); + const store2 = getAddReplyStore(2); + + store1?.getState().actions.setTweetText('Hello from store 1'); + store2?.getState().actions.setTweetText('Hello from store 2'); + + expect(store1?.getState().tweetText).toBe('Hello from store 1'); + expect(store2?.getState().tweetText).toBe('Hello from store 2'); + }); + }); +}); diff --git a/src/features/timeline/tests/timelineQueries.test.tsx b/src/features/timeline/tests/timelineQueries.test.tsx new file mode 100644 index 00000000..96f0db45 --- /dev/null +++ b/src/features/timeline/tests/timelineQueries.test.tsx @@ -0,0 +1,229 @@ +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, + }, +})); + +// 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(), + }, +})); + +// Mock useTimelineStore +vi.mock('../store/useTimelineStore', () => ({ + useSelectedTab: vi.fn(() => 'For you'), + useNewTweets: vi.fn(() => []), + useFetchAvatars: vi.fn(() => false), + useSearch: vi.fn(() => ''), +})); + +// Mock AddPostContext +vi.mock('../store/AddPostContext', () => ({ + useAddPostContext: vi.fn(() => ({ + useActions: () => ({ + onSuccess: vi.fn(), + startSending: vi.fn(), + seterror: vi.fn(), + 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(), + })), +})); + +// Mock useAuth +vi.mock('@/features/authentication/hooks', () => ({ + useAuth: vi.fn(() => ({ + user: { id: 1, username: 'testuser' }, + })), +})); + +// Mock profile +vi.mock('@/features/profile', () => ({ + PROFILE_QUERY_KEYS: { + profilePosts: (id: number) => ['profile', 'posts', id], + profileReplies: (id: number) => ['profile', 'replies', id], + profileMedia: (id: number) => ['profile', 'media', id], + }, + profileApi: { + getProfileByUsername: vi.fn(), + }, +})); + +// Mock useDebounce +vi.mock('../hooks/useDebounce', () => ({ + default: vi.fn((value) => value), +})); + +// Import after mocks +import { + TIMELINE_QUERY_KEYS, + useTimelineFeed, + useSearchProfile, + useSearchHashtag, + useCheckValidUser, + useAvatarsPopUp, +} from '../hooks/timelineQueries'; + +describe('timelineQueries', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('TIMELINE_QUERY_KEYS', () => { + it('should have ADD_TWEET key', () => { + expect(TIMELINE_QUERY_KEYS.ADD_TWEET).toEqual(['tweet']); + }); + + it('should have TIMELINE_FEED_FOR_YOU key', () => { + expect(TIMELINE_QUERY_KEYS.TIMELINE_FEED_FOR_YOU).toEqual([ + 'timeline', + 'forYou', + ]); + }); + + it('should have TIMELINE_FEED_FOR_YOU_POPUP key', () => { + expect(TIMELINE_QUERY_KEYS.TIMELINE_FEED_FOR_YOU_POPUP).toEqual([ + 'timeline', + 'forYou', + 'popup', + ]); + }); + + it('should have TIMELINE_FEED_FOLLOWING key', () => { + expect(TIMELINE_QUERY_KEYS.TIMELINE_FEED_FOLLOWING).toEqual([ + 'timeline', + 'following', + ]); + }); + + it('should have TIMELINE_FEED_FOLLOWING_POPUP key', () => { + expect(TIMELINE_QUERY_KEYS.TIMELINE_FEED_FOLLOWING_POPUP).toEqual([ + 'timeline', + 'following', + 'popup', + ]); + }); + + it('should have PROFILE_SEARCH function', () => { + expect(TIMELINE_QUERY_KEYS.PROFILE_SEARCH('testuser')).toEqual([ + 'profile', + 'testuser', + ]); + }); + + it('should have HASHTAG_SEARCH function', () => { + expect(TIMELINE_QUERY_KEYS.HASHTAG_SEARCH('test')).toEqual([ + 'hashtag', + 'test', + ]); + }); + + it('should have VALID_USER function', () => { + expect(TIMELINE_QUERY_KEYS.VALID_USER('testuser')).toEqual([ + 'mention', + 'testuser', + ]); + }); + }); + + describe('useTimelineFeed', () => { + it('should return timeline feed data', () => { + const { result } = renderHook(() => useTimelineFeed()); + + expect(result.current).toBeDefined(); + expect(result.current.data).toBeDefined(); + }); + }); + + describe('useSearchProfile', () => { + it('should return search profile data', () => { + const { result } = renderHook(() => useSearchProfile('testuser')); + + expect(result.current).toBeDefined(); + }); + }); + + describe('useSearchHashtag', () => { + it('should return search hashtag data', () => { + const { result } = renderHook(() => useSearchHashtag()); + + expect(result.current).toBeDefined(); + }); + }); + + describe('useCheckValidUser', () => { + it('should return valid user data', () => { + const { result } = renderHook(() => useCheckValidUser('testuser')); + + expect(result.current).toBeDefined(); + }); + }); + + describe('useAvatarsPopUp', () => { + it('should return avatars popup data', () => { + const { result } = renderHook(() => useAvatarsPopUp()); + + expect(result.current).toBeDefined(); + }); + }); +}); diff --git a/src/features/timeline/tests/useAddPostStore.test.tsx b/src/features/timeline/tests/useAddPostStore.test.tsx new file mode 100644 index 00000000..47c23ab9 --- /dev/null +++ b/src/features/timeline/tests/useAddPostStore.test.tsx @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { + createAddTweetStore, + createAddTweetSelectors, +} from '../store/useAddPostStore'; + +describe('useAddPostStore', () => { + describe('createAddTweetStore', () => { + it('should create a new store instance', () => { + const store = createAddTweetStore(); + expect(store).toBeDefined(); + expect(typeof store).toBe('function'); + }); + + it('should create independent store instances', () => { + const store1 = createAddTweetStore(); + const store2 = createAddTweetStore(); + + // They should be different instances + expect(store1).not.toBe(store2); + }); + + it('should have correct initial state', () => { + const store = createAddTweetStore(); + const state = store.getState(); + + expect(state.tweetText).toBe(''); + expect(state.isSending).toBe(false); + expect(state.error).toBe(''); + expect(state.isSuccess).toBe(false); + expect(state.mentions).toEqual([]); + expect(state.media).toEqual([]); + expect(state.emoji).toBe(''); + expect(state.mention).toBe(''); + expect(state.isOpen).toBe(false); + expect(state.mentionIsdone).toBe(''); + expect(state.currentKey).toBe(''); + expect(state.placeHolder).toBe("What's happening?"); + expect(state.isGifOpen).toBe(false); + expect(state.search).toBe(''); + expect(state.parentId).toBe(-1); + expect(state.selectedReplyOption).toBe(-1); + }); + }); + + describe('store actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createAddTweetStore(); + }); + + it('actions.setTweetText should update tweet text', () => { + act(() => { + store.getState().actions.setTweetText('Hello World'); + }); + + expect(store.getState().tweetText).toBe('Hello World'); + }); + + it('actions.startSending should update sending state', () => { + act(() => { + store.getState().actions.startSending(); + }); + + expect(store.getState().isSending).toBe(true); + expect(store.getState().error).toBe(''); + expect(store.getState().isSuccess).toBe(false); + }); + + it('actions.onSuccess should reset state', () => { + // Set some state first + act(() => { + store.getState().actions.setTweetText('Test'); + store.getState().actions.startSending(); + }); + + act(() => { + store.getState().actions.onSuccess(); + }); + + expect(store.getState().isSending).toBe(false); + expect(store.getState().isSuccess).toBe(true); + expect(store.getState().tweetText).toBe(''); + expect(store.getState().mentions).toEqual([]); + expect(store.getState().media).toEqual([]); + }); + + it('actions.seterror should set error message', () => { + act(() => { + store.getState().actions.seterror('Something went wrong'); + }); + + expect(store.getState().error).toBe('Something went wrong'); + expect(store.getState().isSending).toBe(false); + expect(store.getState().isSuccess).toBe(false); + }); + + it('actions.addMedia should add media files', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + + act(() => { + store.getState().actions.addMedia([mockFile]); + }); + + expect(store.getState().media.length).toBe(1); + expect(store.getState().media[0].data).toBe(mockFile); + expect(store.getState().media[0].type).toBe('localMedia'); + }); + + it('actions.removeMedia should remove media by id', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + + act(() => { + store.getState().actions.addMedia([mockFile]); + }); + + const mediaId = store.getState().media[0].id; + + act(() => { + store.getState().actions.removeMedia(mediaId); + }); + + expect(store.getState().media.length).toBe(0); + }); + + it('actions.clearMedia should clear all media', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + + act(() => { + store.getState().actions.addMedia([mockFile]); + store.getState().actions.clearMedia(); + }); + + expect(store.getState().media).toEqual([]); + }); + + it('actions.setEmoji should set emoji', () => { + act(() => { + store.getState().actions.setEmoji('😀'); + }); + + expect(store.getState().emoji).toBe('😀'); + }); + + it('actions.clearEmoji should clear emoji', () => { + act(() => { + store.getState().actions.setEmoji('😀'); + store.getState().actions.clearEmoji(); + }); + + expect(store.getState().emoji).toBe(''); + }); + + it('actions.setMention should set mention', () => { + act(() => { + store.getState().actions.setMention('@user'); + }); + + expect(store.getState().mention).toBe('@user'); + }); + + it('actions.setIsOpen should set isOpen', () => { + act(() => { + store.getState().actions.setIsOpen(true); + }); + + expect(store.getState().isOpen).toBe(true); + }); + + it('actions.setIsDone should set mentionIsdone', () => { + act(() => { + store.getState().actions.setIsDone('username 123'); + }); + + expect(store.getState().mentionIsdone).toBe('username 123'); + }); + + it('actions.setKeyDown should set currentKey', () => { + act(() => { + store.getState().actions.setKeyDown('ArrowDown'); + }); + + expect(store.getState().currentKey).toBe('ArrowDown'); + }); + + it('actions.setPlaceHolder should set placeHolder', () => { + act(() => { + store.getState().actions.setPlaceHolder('Custom placeholder'); + }); + + expect(store.getState().placeHolder).toBe('Custom placeholder'); + }); + + it('actions.open should open gif modal', () => { + act(() => { + store.getState().actions.open(); + }); + + expect(store.getState().isGifOpen).toBe(true); + }); + + it('actions.close should close gif modal', () => { + act(() => { + store.getState().actions.open(); + store.getState().actions.close(); + }); + + expect(store.getState().isGifOpen).toBe(false); + }); + + it('actions.setSearch should set search', () => { + act(() => { + store.getState().actions.setSearch('cat gifs'); + }); + + expect(store.getState().search).toBe('cat gifs'); + }); + + it('actions.setParentId should set parentId', () => { + act(() => { + store.getState().actions.setParentId(123); + }); + + expect(store.getState().parentId).toBe(123); + }); + + it('actions.updateReplyOption should update selectedReplyOption', () => { + act(() => { + store.getState().actions.updateReplyOption(2); + }); + + expect(store.getState().selectedReplyOption).toBe(2); + }); + + it('actions.setMentions should set mentions', () => { + const mockMentions = [ + { indx: 0, username: '@user1', checked: true, id: 1 }, + ]; + + act(() => { + store.getState().actions.setMentions(mockMentions); + }); + + expect(store.getState().mentions).toEqual(mockMentions); + }); + + it('actions.addGifs should add gif media', () => { + const mockGif = { + id: 'gif123', + title: 'Test Gif', + images: { + original: { url: 'http://example.com/gif.gif' }, + fixed_height: { url: 'http://example.com/gif-small.gif' }, + }, + }; + + act(() => { + store.getState().actions.addGifs(mockGif as any); + }); + + expect(store.getState().media.length).toBe(1); + expect(store.getState().media[0].type).toBe('externalGif'); + }); + }); + + describe('createAddTweetSelectors', () => { + let store: ReturnType; + let selectors: ReturnType; + + beforeEach(() => { + store = createAddTweetStore(); + selectors = createAddTweetSelectors(store); + }); + + it('should create selectors from store', () => { + expect(selectors).toBeDefined(); + expect(selectors.useTweetText).toBeDefined(); + expect(selectors.useMedia).toBeDefined(); + expect(selectors.useMentions).toBeDefined(); + expect(selectors.useIsSending).toBeDefined(); + expect(selectors.useIsSuccess).toBeDefined(); + expect(selectors.useError).toBeDefined(); + expect(selectors.useEmoji).toBeDefined(); + expect(selectors.useMention).toBeDefined(); + expect(selectors.useCurrentKey).toBeDefined(); + expect(selectors.useMentionIsDone).toBeDefined(); + expect(selectors.useIsOpen).toBeDefined(); + expect(selectors.usePlaceHolder).toBeDefined(); + expect(selectors.useGifVisibility).toBeDefined(); + expect(selectors.useGifsSearch).toBeDefined(); + expect(selectors.useParentId).toBeDefined(); + expect(selectors.useSelectedReplyOption).toBeDefined(); + expect(selectors.useActions).toBeDefined(); + }); + + it('useTweetText should return tweet text', () => { + act(() => { + store.getState().actions.setTweetText('Test tweet'); + }); + + const { result } = renderHook(() => selectors.useTweetText()); + expect(result.current).toBe('Test tweet'); + }); + + it('useIsSending should return sending state', () => { + act(() => { + store.getState().actions.startSending(); + }); + + const { result } = renderHook(() => selectors.useIsSending()); + expect(result.current).toBe(true); + }); + + it('useMedia should return media array', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + + act(() => { + store.getState().actions.addMedia([mockFile]); + }); + + const { result } = renderHook(() => selectors.useMedia()); + expect(result.current.length).toBe(1); + }); + + it('useActions should return actions', () => { + const { result } = renderHook(() => selectors.useActions()); + + expect(result.current.setTweetText).toBeDefined(); + expect(result.current.addMedia).toBeDefined(); + expect(result.current.startSending).toBeDefined(); + }); + + it('useGifVisibility should return gif modal visibility', () => { + act(() => { + store.getState().actions.open(); + }); + + const { result } = renderHook(() => selectors.useGifVisibility()); + expect(result.current).toBe(true); + }); + + it('useParentId should return parent id', () => { + act(() => { + store.getState().actions.setParentId(42); + }); + + const { result } = renderHook(() => selectors.useParentId()); + expect(result.current).toBe(42); + }); + + it('useSelectedReplyOption should return selected option', () => { + act(() => { + store.getState().actions.updateReplyOption(3); + }); + + const { result } = renderHook(() => selectors.useSelectedReplyOption()); + expect(result.current).toBe(3); + }); + }); +}); diff --git a/src/features/timeline/tests/useDebounce.test.tsx b/src/features/timeline/tests/useDebounce.test.tsx new file mode 100644 index 00000000..89073eb0 --- /dev/null +++ b/src/features/timeline/tests/useDebounce.test.tsx @@ -0,0 +1,240 @@ +import { renderHook, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import useDebounce from '../hooks/useDebounce'; + +describe('useDebounce Hook', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return initial value immediately', () => { + const { result } = renderHook(() => useDebounce('initial', 300)); + expect(result.current).toBe('initial'); + }); + + it('should debounce value changes with default delay', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 300 } } + ); + + expect(result.current).toBe('initial'); + + // Change the value + rerender({ value: 'updated', delay: 300 }); + + // Value should not have changed yet + expect(result.current).toBe('initial'); + + // Fast forward time + act(() => { + vi.advanceTimersByTime(300); + }); + + // Now value should be updated + expect(result.current).toBe('updated'); + }); + + it('should use default delay of 300ms when not specified', async () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value), { + initialProps: { value: 'initial' }, + }); + + expect(result.current).toBe('initial'); + + rerender({ value: 'updated' }); + + // Value should not change before 300ms + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe('initial'); + + // Value should change after 300ms total + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe('updated'); + }); + + it('should cancel previous timeout when value changes rapidly', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 300 } } + ); + + expect(result.current).toBe('initial'); + + // Change value multiple times rapidly + rerender({ value: 'first', delay: 300 }); + act(() => { + vi.advanceTimersByTime(100); + }); + + rerender({ value: 'second', delay: 300 }); + act(() => { + vi.advanceTimersByTime(100); + }); + + rerender({ value: 'third', delay: 300 }); + + // Still should be initial because timeout resets + expect(result.current).toBe('initial'); + + // Fast forward past debounce time + act(() => { + vi.advanceTimersByTime(300); + }); + + // Should be the final value + expect(result.current).toBe('third'); + }); + + it('should handle custom delay values', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 500 } } + ); + + rerender({ value: 'updated', delay: 500 }); + + // Should not update before 500ms + act(() => { + vi.advanceTimersByTime(400); + }); + expect(result.current).toBe('initial'); + + // Should update after 500ms + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe('updated'); + }); + + it('should handle empty string values', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: '', delay: 300 } } + ); + + expect(result.current).toBe(''); + + rerender({ value: 'test', delay: 300 }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe('test'); + + rerender({ value: '', delay: 300 }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe(''); + }); + + it('should handle delay change', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 300 } } + ); + + rerender({ value: 'updated', delay: 100 }); + + // Should respect new delay + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe('updated'); + }); + + it('should handle special characters in string', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: '@user', delay: 300 } } + ); + + expect(result.current).toBe('@user'); + + rerender({ value: '#hashtag', delay: 300 }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe('#hashtag'); + }); + + it('should handle very short delay', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 1 } } + ); + + rerender({ value: 'updated', delay: 1 }); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current).toBe('updated'); + }); + + it('should handle zero delay', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 0 } } + ); + + rerender({ value: 'updated', delay: 0 }); + act(() => { + vi.advanceTimersByTime(0); + }); + expect(result.current).toBe('updated'); + }); + + it('should cleanup timeout on unmount', async () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + const { unmount, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 300 } } + ); + + rerender({ value: 'updated', delay: 300 }); + + unmount(); + + // clearTimeout should have been called on unmount + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); + + it('should handle long strings', async () => { + const longString = 'a'.repeat(1000); + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: '', delay: 300 } } + ); + + rerender({ value: longString, delay: 300 }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe(longString); + expect(result.current.length).toBe(1000); + }); + + it('should handle unicode characters', async () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: '', delay: 300 } } + ); + + rerender({ value: '你好世界 🌍 مرحبا', delay: 300 }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe('你好世界 🌍 مرحبا'); + }); +}); diff --git a/src/features/timeline/tests/useOnScreen.test.tsx b/src/features/timeline/tests/useOnScreen.test.tsx new file mode 100644 index 00000000..d77a45ee --- /dev/null +++ b/src/features/timeline/tests/useOnScreen.test.tsx @@ -0,0 +1,311 @@ +import { renderHook, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import useOnScreen from '../hooks/useOnScreen'; + +// Mock IntersectionObserver +const mockIntersectionObserver = vi.fn(); +const mockObserve = vi.fn(); +const mockUnobserve = vi.fn(); +const mockDisconnect = vi.fn(); + +describe('useOnScreen Hook', () => { + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; + + beforeEach(() => { + mockObserve.mockClear(); + mockUnobserve.mockClear(); + mockDisconnect.mockClear(); + mockIntersectionObserver.mockClear(); + + mockIntersectionObserver.mockImplementation((callback) => { + intersectionCallback = callback; + return { + observe: mockObserve, + unobserve: mockUnobserve, + disconnect: mockDisconnect, + }; + }); + + vi.stubGlobal('IntersectionObserver', mockIntersectionObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should return a ref and initial visibility as false', () => { + const { result } = renderHook(() => useOnScreen()); + + const [ref, isVisible] = result.current; + expect(ref).toBeDefined(); + expect(isVisible).toBe(false); + }); + + it('should create IntersectionObserver with default options', () => { + const { result } = renderHook(() => useOnScreen()); + + // Simulate ref being attached to an element + const mockElement = document.createElement('div'); + Object.defineProperty(result.current[0], 'current', { + value: mockElement, + writable: true, + }); + + // Re-render to trigger effect with element + const { rerender } = renderHook(() => useOnScreen()); + rerender(); + + expect(mockIntersectionObserver).toHaveBeenCalled(); + }); + + it('should create IntersectionObserver with custom options', () => { + const options: IntersectionObserverInit = { + root: null, + rootMargin: '10px', + threshold: 0.5, + }; + + renderHook(() => useOnScreen(options)); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + options + ); + }); + + it('should observe element when ref is attached', () => { + const { result } = renderHook(() => useOnScreen()); + + // Get the ref + const [ref] = result.current; + + // Create a mock element and attach to ref + const mockElement = document.createElement('div'); + + // Use act to update the ref + act(() => { + (ref as any).current = mockElement; + }); + + // Rerender to trigger the effect + const { rerender } = renderHook(() => useOnScreen()); + rerender(); + + // Should have attempted to observe + expect(mockIntersectionObserver).toHaveBeenCalled(); + }); + + it('should update visibility when element intersects', () => { + const { result } = renderHook(() => useOnScreen()); + + // Initially not visible + expect(result.current[1]).toBe(false); + + // Simulate intersection + act(() => { + intersectionCallback([ + { + isIntersecting: true, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 1, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: 0, + }, + ]); + }); + + expect(result.current[1]).toBe(true); + }); + + it('should update visibility to false when element leaves viewport', () => { + const { result } = renderHook(() => useOnScreen()); + + // Simulate entering viewport + act(() => { + intersectionCallback([ + { + isIntersecting: true, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 1, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: 0, + }, + ]); + }); + + expect(result.current[1]).toBe(true); + + // Simulate leaving viewport + act(() => { + intersectionCallback([ + { + isIntersecting: false, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 0, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: 0, + }, + ]); + }); + + expect(result.current[1]).toBe(false); + }); + + it('should pass custom threshold to IntersectionObserver', () => { + renderHook(() => useOnScreen({ threshold: 0.75 })); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + { threshold: 0.75 } + ); + }); + + it('should pass custom rootMargin to IntersectionObserver', () => { + renderHook(() => useOnScreen({ rootMargin: '20px' })); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + { rootMargin: '20px' } + ); + }); + + it('should handle multiple threshold values', () => { + renderHook(() => useOnScreen({ threshold: [0, 0.25, 0.5, 0.75, 1] })); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + { threshold: [0, 0.25, 0.5, 0.75, 1] } + ); + }); + + it('should handle undefined options', () => { + renderHook(() => useOnScreen(undefined)); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + undefined + ); + }); + + it('should handle empty options object', () => { + renderHook(() => useOnScreen({})); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + {} + ); + }); + + it('should toggle visibility correctly', () => { + const { result } = renderHook(() => useOnScreen()); + + // Start not visible + expect(result.current[1]).toBe(false); + + // Enter viewport + act(() => { + intersectionCallback([ + { + isIntersecting: true, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 1, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: 0, + }, + ]); + }); + expect(result.current[1]).toBe(true); + + // Leave viewport + act(() => { + intersectionCallback([ + { + isIntersecting: false, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 0, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: 0, + }, + ]); + }); + expect(result.current[1]).toBe(false); + + // Enter again + act(() => { + intersectionCallback([ + { + isIntersecting: true, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 0.5, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: 0, + }, + ]); + }); + expect(result.current[1]).toBe(true); + }); + + it('should return consistent ref across renders', () => { + const { result, rerender } = renderHook(() => useOnScreen()); + + const firstRef = result.current[0]; + + rerender(); + + const secondRef = result.current[0]; + + // The ref object itself should be the same + expect(firstRef).toBe(secondRef); + }); + + it('should handle root option', () => { + const rootElement = document.createElement('div'); + + renderHook(() => + useOnScreen({ + root: rootElement, + rootMargin: '0px', + threshold: 0, + }) + ); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + { + root: rootElement, + rootMargin: '0px', + threshold: 0, + } + ); + }); + + it('should handle negative rootMargin', () => { + renderHook(() => useOnScreen({ rootMargin: '-10px' })); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + { rootMargin: '-10px' } + ); + }); + + it('should handle complex rootMargin', () => { + renderHook(() => useOnScreen({ rootMargin: '10px 20px 30px 40px' })); + + expect(mockIntersectionObserver).toHaveBeenCalledWith( + expect.any(Function), + { rootMargin: '10px 20px 30px 40px' } + ); + }); +}); diff --git a/src/features/timeline/tests/usePollStore.test.tsx b/src/features/timeline/tests/usePollStore.test.tsx new file mode 100644 index 00000000..4aabb0dd --- /dev/null +++ b/src/features/timeline/tests/usePollStore.test.tsx @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import usePollStore from '../store/usePollStore'; + +describe('usePollStore', () => { + beforeEach(() => { + // Reset the store before each test + usePollStore.setState({ + choices: ['', '', '', ''], + time: [1, 0, 0], + shiftStartMinutes: 0, + isOpen: false, + buttonInputIndex: 2, + }); + }); + + describe('initial state', () => { + it('should have initial choices as empty strings', () => { + const { result } = renderHook(() => usePollStore()); + expect(result.current.choices).toEqual(['', '', '', '']); + }); + + it('should have initial time as [1, 0, 0]', () => { + const { result } = renderHook(() => usePollStore()); + expect(result.current.time).toEqual([1, 0, 0]); + }); + + it('should have initial shiftStartMinutes as 0', () => { + const { result } = renderHook(() => usePollStore()); + expect(result.current.shiftStartMinutes).toBe(0); + }); + + it('should have initial isOpen as false', () => { + const { result } = renderHook(() => usePollStore()); + expect(result.current.isOpen).toBe(false); + }); + + it('should have initial buttonInputIndex as 2', () => { + const { result } = renderHook(() => usePollStore()); + expect(result.current.buttonInputIndex).toBe(2); + }); + }); + + describe('setChoice', () => { + it('should update the first choice', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setChoice(1, 'Option A'); + }); + + expect(result.current.choices[0]).toBe('Option A'); + }); + + it('should update the second choice', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setChoice(2, 'Option B'); + }); + + expect(result.current.choices[1]).toBe('Option B'); + }); + + it('should update the third choice', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setChoice(3, 'Option C'); + }); + + expect(result.current.choices[2]).toBe('Option C'); + }); + + it('should update the fourth choice', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setChoice(4, 'Option D'); + }); + + expect(result.current.choices[3]).toBe('Option D'); + }); + + it('should not affect other choices when updating one', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setChoice(1, 'First'); + result.current.setChoice(2, 'Second'); + }); + + expect(result.current.choices).toEqual(['First', 'Second', '', '']); + }); + }); + + describe('setTime', () => { + it('should update days', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setTime(1, 5); + }); + + expect(result.current.time[0]).toBe(5); + }); + + it('should update hours', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setTime(2, 12); + }); + + expect(result.current.time[1]).toBe(12); + }); + + it('should update minutes', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setTime(3, 30); + }); + + expect(result.current.time[2]).toBe(30); + }); + + it('should set hours to 1 if all time values become 0 and days is set to 0', () => { + const { result } = renderHook(() => usePollStore()); + + // First set time to [0, 0, 0] + usePollStore.setState({ time: [0, 0, 0] }); + + act(() => { + result.current.setTime(1, 0); + }); + + expect(result.current.time[1]).toBe(1); + }); + + it('should set shiftStartMinutes to 5 when days and hours are 0', () => { + const { result } = renderHook(() => usePollStore()); + + // Set time to [0, 0, 30] + act(() => { + usePollStore.setState({ time: [0, 0, 30] }); + result.current.setTime(2, 0); + }); + + expect(result.current.shiftStartMinutes).toBe(5); + }); + + it('should reset shiftStartMinutes to 0 when days or hours are non-zero', () => { + const { result } = renderHook(() => usePollStore()); + + usePollStore.setState({ time: [0, 0, 30], shiftStartMinutes: 5 }); + + act(() => { + result.current.setTime(1, 1); + }); + + expect(result.current.shiftStartMinutes).toBe(0); + }); + + it('should set minutes to 5 if all time is 0 and minutes is 0', () => { + const { result } = renderHook(() => usePollStore()); + + usePollStore.setState({ time: [0, 0, 0] }); + + act(() => { + result.current.setTime(3, 0); + }); + + // When days and hours are 0, minutes should be at least 5 + expect(result.current.time[2]).toBeGreaterThanOrEqual(0); + }); + }); + + describe('open and close', () => { + it('should open the poll', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.open(); + }); + + expect(result.current.isOpen).toBe(true); + }); + + it('should close the poll', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.open(); + result.current.close(); + }); + + expect(result.current.isOpen).toBe(false); + }); + + it('should reorder choices when closing and remove empty ones', () => { + const { result } = renderHook(() => usePollStore()); + + usePollStore.setState({ + isOpen: true, + buttonInputIndex: 4, + choices: ['First', '', 'Third', ''], + }); + + act(() => { + result.current.close(); + }); + + expect(result.current.choices[0]).toBe('First'); + expect(result.current.choices[1]).toBe('Third'); + }); + + it('should update buttonInputIndex based on non-empty choices count', () => { + const { result } = renderHook(() => usePollStore()); + + usePollStore.setState({ + isOpen: true, + buttonInputIndex: 4, + choices: ['First', 'Second', 'Third', ''], + }); + + act(() => { + result.current.close(); + }); + + expect(result.current.buttonInputIndex).toBe(3); + }); + + it('should set buttonInputIndex to 2 if less than 3 non-empty choices', () => { + const { result } = renderHook(() => usePollStore()); + + usePollStore.setState({ + isOpen: true, + buttonInputIndex: 4, + choices: ['First', '', '', ''], + }); + + act(() => { + result.current.close(); + }); + + expect(result.current.buttonInputIndex).toBe(2); + }); + }); + + describe('reset', () => { + it('should reset all values to initial state', () => { + const { result } = renderHook(() => usePollStore()); + + // Modify state + act(() => { + result.current.setChoice(1, 'Modified'); + result.current.setTime(1, 5); + result.current.open(); + result.current.setButtonInputIndex(4); + }); + + // Reset + act(() => { + result.current.reset(); + }); + + expect(result.current.choices).toEqual(['', '', '', '']); + expect(result.current.time).toEqual([1, 0, 0]); + expect(result.current.isOpen).toBe(false); + expect(result.current.buttonInputIndex).toBe(2); + }); + }); + + describe('setButtonInputIndex', () => { + it('should update buttonInputIndex', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setButtonInputIndex(3); + }); + + expect(result.current.buttonInputIndex).toBe(3); + }); + + it('should allow setting buttonInputIndex to 4', () => { + const { result } = renderHook(() => usePollStore()); + + act(() => { + result.current.setButtonInputIndex(4); + }); + + expect(result.current.buttonInputIndex).toBe(4); + }); + }); +}); diff --git a/src/features/timeline/tests/useRealTimeTweets.test.tsx b/src/features/timeline/tests/useRealTimeTweets.test.tsx new file mode 100644 index 00000000..e6c65703 --- /dev/null +++ b/src/features/timeline/tests/useRealTimeTweets.test.tsx @@ -0,0 +1,357 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +// Mock the socket +const mockSocket = { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + connected: true, +}; + +// Mock socket service +vi.mock('@/features/messages/services/socket', () => ({ + getSocket: () => 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, + })), +})); + +// Import after mocks +import { useRealTimeTweets } from '../hooks/useRealTimeTweets'; + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const createWrapper = () => { + const queryClient = createTestQueryClient(); + const Wrapper = function Wrapper({ + children, + }: { + children: React.ReactNode; + }) { + return ( + {children} + ); + }; + return Wrapper; +}; + +describe('useRealTimeTweets', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSocket.on.mockReset(); + mockSocket.off.mockReset(); + mockSocket.emit.mockReset(); + mockSocket.connected = true; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should return joinPost, leavePost, and usePostUpdates', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + + expect(result.current.joinPost).toBeDefined(); + expect(result.current.leavePost).toBeDefined(); + expect(result.current.usePostUpdates).toBeDefined(); + }); + + describe('joinPost', () => { + it('should emit join post event when socket is connected', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + + act(() => { + result.current.joinPost(123); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'joinPost', + 123, + expect.any(Function) + ); + }); + + it('should call callback on success response', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + const callback = vi.fn(); + + mockSocket.emit.mockImplementation((event, postId, cb) => { + cb({ status: 'success' }); + }); + + act(() => { + result.current.joinPost(123, callback); + }); + + expect(callback).toHaveBeenCalledWith({ status: 'success' }); + }); + + it('should not emit when socket is disconnected', () => { + mockSocket.connected = false; + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + + act(() => { + result.current.joinPost(123); + }); + + expect(mockSocket.emit).not.toHaveBeenCalled(); + }); + }); + + describe('leavePost', () => { + it('should emit leave post event when socket is connected', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + + act(() => { + result.current.leavePost(123); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'leavePost', + 123, + expect.any(Function) + ); + }); + + it('should call callback on success response', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + const callback = vi.fn(); + + mockSocket.emit.mockImplementation((event, postId, cb) => { + cb({ status: 'success' }); + }); + + act(() => { + result.current.leavePost(123, callback); + }); + + expect(callback).toHaveBeenCalledWith({ status: 'success' }); + }); + + it('should not emit when socket is disconnected', () => { + mockSocket.connected = false; + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + + act(() => { + result.current.leavePost(123); + }); + + expect(mockSocket.emit).not.toHaveBeenCalled(); + }); + }); + + describe('usePostUpdates', () => { + it('should be a function', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useRealTimeTweets(), { wrapper }); + + expect(typeof result.current.usePostUpdates).toBe('function'); + }); + + it('should setup socket listeners for post updates', () => { + const wrapper = createWrapper(); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + // Should register multiple socket listeners (like, comment, repost) + expect(mockSocket.on).toHaveBeenCalled(); + }); + + it('should not setup listeners when postId is null', () => { + const wrapper = createWrapper(); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(null, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + // Should not register socket listeners for null postId + expect(mockSocket.on).not.toHaveBeenCalled(); + }); + + it('should cleanup listeners on unmount', () => { + const wrapper = createWrapper(); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + const { unmount } = render(, { wrapper }); + unmount(); + + expect(mockSocket.off).toHaveBeenCalled(); + }); + }); +}); + +describe('useRealTimeTweets socket event handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSocket.on.mockReset(); + mockSocket.off.mockReset(); + mockSocket.emit.mockReset(); + mockSocket.connected = true; + }); + + it('should handle like update event', () => { + const wrapper = createWrapper(); + let likeHandler: ((...args: unknown[]) => void) | undefined; + + mockSocket.on.mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === 'post:like:update') { + likeHandler = handler; + } + } + ); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + if (likeHandler) { + act(() => { + likeHandler({ postId: 123, count: 10 }); + }); + + expect(mockRealTimeOnMutate).toHaveBeenCalled(); + } + }); + + it('should handle comment update event', () => { + const wrapper = createWrapper(); + let commentHandler: ((...args: unknown[]) => void) | undefined; + + mockSocket.on.mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === 'post:comment:update') { + commentHandler = handler; + } + } + ); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + if (commentHandler) { + act(() => { + commentHandler({ postId: 123, count: 5 }); + }); + + expect(mockRealTimeOnMutate).toHaveBeenCalled(); + } + }); + + it('should handle repost update event', () => { + const wrapper = createWrapper(); + let repostHandler: ((...args: unknown[]) => void) | undefined; + + mockSocket.on.mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === 'post:repost:update') { + repostHandler = handler; + } + } + ); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + if (repostHandler) { + act(() => { + repostHandler({ postId: 123, count: 3 }); + }); + + expect(mockRealTimeOnMutate).toHaveBeenCalled(); + } + }); + + it('should not call onMutate for different postId', () => { + const wrapper = createWrapper(); + let likeHandler: ((...args: unknown[]) => void) | undefined; + + mockSocket.on.mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === 'post:like:update') { + likeHandler = handler; + } + } + ); + + const TestComponent = () => { + const { usePostUpdates } = useRealTimeTweets(); + usePostUpdates(123, 456, 'Post', -1); + return null; + }; + + render(, { wrapper }); + + if (likeHandler) { + act(() => { + // Different postId + likeHandler({ postId: 999, count: 10 }); + }); + + expect(mockRealTimeOnMutate).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/features/timeline/tests/useTimelineComposer.test.tsx b/src/features/timeline/tests/useTimelineComposer.test.tsx new file mode 100644 index 00000000..88d5cce8 --- /dev/null +++ b/src/features/timeline/tests/useTimelineComposer.test.tsx @@ -0,0 +1,511 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { + useTimelineComposerStore, + timelineComposerSelectors, +} from '../store/useTimelineComposer'; + +describe('useTimelineComposerStore', () => { + beforeEach(() => { + // Reset the store before each test + useTimelineComposerStore.setState({ + tweetText: '', + isSending: false, + error: '', + isSuccess: false, + mentions: [], + media: [], + emoji: '', + mention: '', + isOpen: false, + mentionIsdone: '', + currentKey: '', + placeHolder: "What's happening?", + isGifOpen: false, + search: '', + parentId: -1, + selectedReplyOption: -1, + }); + }); + + describe('initial state', () => { + it('should have empty tweetText', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.tweetText).toBe(''); + }); + + it('should have isSending as false', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.isSending).toBe(false); + }); + + it('should have empty error', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.error).toBe(''); + }); + + it('should have isSuccess as false', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.isSuccess).toBe(false); + }); + + it('should have empty mentions', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.mentions).toEqual([]); + }); + + it('should have empty media', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.media).toEqual([]); + }); + + it('should have default placeHolder', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.placeHolder).toBe("What's happening?"); + }); + + it('should have isGifOpen as false', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.isGifOpen).toBe(false); + }); + + it('should have parentId as -1', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.parentId).toBe(-1); + }); + + it('should have selectedReplyOption as -1', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + expect(result.current.selectedReplyOption).toBe(-1); + }); + }); + + describe('actions.setTweetText', () => { + it('should update tweet text', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setTweetText('Hello World'); + }); + + expect(result.current.tweetText).toBe('Hello World'); + }); + }); + + describe('actions.startSending', () => { + it('should set isSending to true and clear error', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + useTimelineComposerStore.setState({ error: 'Previous error' }); + + act(() => { + result.current.actions.startSending(); + }); + + expect(result.current.isSending).toBe(true); + expect(result.current.error).toBe(''); + expect(result.current.isSuccess).toBe(false); + }); + }); + + describe('actions.onSuccess', () => { + it('should reset state on success', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + // Set some state + act(() => { + result.current.actions.setTweetText('Test'); + result.current.actions.startSending(); + }); + + act(() => { + result.current.actions.onSuccess(); + }); + + expect(result.current.isSending).toBe(false); + expect(result.current.isSuccess).toBe(true); + expect(result.current.tweetText).toBe(''); + expect(result.current.mentions).toEqual([]); + expect(result.current.media).toEqual([]); + }); + }); + + describe('actions.seterror', () => { + it('should set error message and stop sending', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.startSending(); + }); + + act(() => { + result.current.actions.seterror('Something went wrong'); + }); + + expect(result.current.error).toBe('Something went wrong'); + expect(result.current.isSending).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); + }); + + describe('actions.addMedia', () => { + it('should add media files', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + + act(() => { + result.current.actions.addMedia([mockFile]); + }); + + expect(result.current.media.length).toBe(1); + expect(result.current.media[0].data).toBe(mockFile); + }); + + it('should add multiple media files', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + const mockFile1 = new File(['test1'], 'test1.png', { type: 'image/png' }); + const mockFile2 = new File(['test2'], 'test2.png', { type: 'image/png' }); + + act(() => { + result.current.actions.addMedia([mockFile1, mockFile2]); + }); + + expect(result.current.media.length).toBe(2); + }); + + it('should generate unique IDs for media', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + const mockFile1 = new File(['test1'], 'test1.png', { type: 'image/png' }); + const mockFile2 = new File(['test2'], 'test2.png', { type: 'image/png' }); + + act(() => { + result.current.actions.addMedia([mockFile1, mockFile2]); + }); + + expect(result.current.media[0].id).not.toBe(result.current.media[1].id); + }); + }); + + describe('actions.removeMedia', () => { + it('should remove media by id', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + + act(() => { + result.current.actions.addMedia([mockFile]); + }); + + const mediaId = result.current.media[0].id; + + act(() => { + result.current.actions.removeMedia(mediaId); + }); + + expect(result.current.media.length).toBe(0); + }); + }); + + describe('actions.clearMedia', () => { + it('should clear all media', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + + act(() => { + result.current.actions.addMedia([mockFile]); + result.current.actions.clearMedia(); + }); + + expect(result.current.media).toEqual([]); + }); + }); + + describe('actions.setEmoji and clearEmoji', () => { + it('should set emoji', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setEmoji('😀'); + }); + + expect(result.current.emoji).toBe('😀'); + }); + + it('should clear emoji', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setEmoji('😀'); + result.current.actions.clearEmoji(); + }); + + expect(result.current.emoji).toBe(''); + }); + }); + + describe('actions.setMention', () => { + it('should set mention', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setMention('@user'); + }); + + expect(result.current.mention).toBe('@user'); + }); + }); + + describe('actions.setIsOpen', () => { + it('should set isOpen', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setIsOpen(true); + }); + + expect(result.current.isOpen).toBe(true); + }); + }); + + describe('actions.setIsDone', () => { + it('should set mentionIsdone', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setIsDone('username 123'); + }); + + expect(result.current.mentionIsdone).toBe('username 123'); + }); + }); + + describe('actions.setKeyDown', () => { + it('should set currentKey', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setKeyDown('ArrowDown'); + }); + + expect(result.current.currentKey).toBe('ArrowDown'); + }); + }); + + describe('actions.setPlaceHolder', () => { + it('should set placeHolder', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setPlaceHolder('Post your reply'); + }); + + expect(result.current.placeHolder).toBe('Post your reply'); + }); + }); + + describe('actions.open and close (gif)', () => { + it('should open gif modal', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.open(); + }); + + expect(result.current.isGifOpen).toBe(true); + }); + + it('should close gif modal', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.open(); + result.current.actions.close(); + }); + + expect(result.current.isGifOpen).toBe(false); + }); + }); + + describe('actions.setSearch', () => { + it('should set search', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setSearch('cat gifs'); + }); + + expect(result.current.search).toBe('cat gifs'); + }); + }); + + describe('actions.setParentId', () => { + it('should set parentId', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.setParentId(123); + }); + + expect(result.current.parentId).toBe(123); + }); + }); + + describe('actions.updateReplyOption', () => { + it('should update selectedReplyOption', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + + act(() => { + result.current.actions.updateReplyOption(2); + }); + + expect(result.current.selectedReplyOption).toBe(2); + }); + }); + + describe('actions.setMentions', () => { + it('should set mentions', () => { + const { result } = renderHook(() => useTimelineComposerStore()); + const mockMentions = [ + { indx: 0, username: '@user1', checked: true, id: 1 }, + ]; + + act(() => { + result.current.actions.setMentions(mockMentions); + }); + + expect(result.current.mentions).toEqual(mockMentions); + }); + }); +}); + +describe('timelineComposerSelectors', () => { + beforeEach(() => { + useTimelineComposerStore.setState({ + tweetText: 'Test tweet', + isSending: true, + error: 'Test error', + isSuccess: true, + mentions: [{ indx: 0, username: 'user', checked: true, id: 1 }], + media: [], + emoji: '😀', + mention: '@test', + isOpen: true, + mentionIsdone: 'done', + currentKey: 'Enter', + placeHolder: 'Custom placeholder', + isGifOpen: true, + search: 'search term', + parentId: 42, + selectedReplyOption: 3, + }); + }); + + it('useTweetText should return tweet text', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useTweetText() + ); + expect(result.current).toBe('Test tweet'); + }); + + it('useIsSending should return sending state', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useIsSending() + ); + expect(result.current).toBe(true); + }); + + it('useError should return error', () => { + const { result } = renderHook(() => timelineComposerSelectors.useError()); + expect(result.current).toBe('Test error'); + }); + + it('useIsSuccess should return success state', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useIsSuccess() + ); + expect(result.current).toBe(true); + }); + + it('useMentions should return mentions', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useMentions() + ); + expect(result.current).toHaveLength(1); + }); + + it('useMedia should return media', () => { + const { result } = renderHook(() => timelineComposerSelectors.useMedia()); + expect(result.current).toEqual([]); + }); + + it('useEmoji should return emoji', () => { + const { result } = renderHook(() => timelineComposerSelectors.useEmoji()); + expect(result.current).toBe('😀'); + }); + + it('useMention should return mention', () => { + const { result } = renderHook(() => timelineComposerSelectors.useMention()); + expect(result.current).toBe('@test'); + }); + + it('useIsOpen should return isOpen', () => { + const { result } = renderHook(() => timelineComposerSelectors.useIsOpen()); + expect(result.current).toBe(true); + }); + + it('useMentionIsDone should return mentionIsdone', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useMentionIsDone() + ); + expect(result.current).toBe('done'); + }); + + it('useCurrentKey should return currentKey', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useCurrentKey() + ); + expect(result.current).toBe('Enter'); + }); + + it('usePlaceHolder should return placeHolder', () => { + const { result } = renderHook(() => + timelineComposerSelectors.usePlaceHolder() + ); + expect(result.current).toBe('Custom placeholder'); + }); + + it('useGifVisibility should return isGifOpen', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useGifVisibility() + ); + expect(result.current).toBe(true); + }); + + it('useGifsSearch should return search', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useGifsSearch() + ); + expect(result.current).toBe('search term'); + }); + + it('useParentId should return parentId', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useParentId() + ); + expect(result.current).toBe(42); + }); + + it('useSelectedReplyOption should return selectedReplyOption', () => { + const { result } = renderHook(() => + timelineComposerSelectors.useSelectedReplyOption() + ); + expect(result.current).toBe(3); + }); + + it('useActions should return actions object', () => { + const { result } = renderHook(() => timelineComposerSelectors.useActions()); + expect(result.current).toHaveProperty('setTweetText'); + expect(result.current).toHaveProperty('addMedia'); + expect(result.current).toHaveProperty('startSending'); + expect(result.current).toHaveProperty('onSuccess'); + }); +}); diff --git a/src/features/timeline/tests/useTimelineStore.test.tsx b/src/features/timeline/tests/useTimelineStore.test.tsx new file mode 100644 index 00000000..6ffaebf7 --- /dev/null +++ b/src/features/timeline/tests/useTimelineStore.test.tsx @@ -0,0 +1,330 @@ +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', () => ({ + usePathname: vi.fn(() => '/home'), +})); + +// Mock explore store +vi.mock('@/features/explore/store/useExploreStore', () => ({ + useSearchExplore: vi.fn(() => ''), + useActions: vi.fn(() => ({ + setSearchQuery: vi.fn(), + })), +})); + +// Import after mocking +import { + useSelectedTab, + useActions, + useSearchUser, + useSearchIsopen, + useNewTweets, + usePopUpAvatars, + useFetchAvatars, + useTabsScroll, + useParentId, + usePostType, + useShowCheckModal, +} from '../store/useTimelineStore'; +import { FOLLOWING_TAB, FOR_YOU_TAB } from '../constants/menuName'; +import { ADD_TWEET } from '../constants/tweetConstants'; +import { TimelineFeed } from '../types/api'; + +const mockTweet: TimelineFeed = { + userId: 1, + username: 'testuser', + verified: false, + name: 'Test User', + avatar: null, + postId: 1, + date: '2025-01-01T00:00:00.000Z', + likesCount: 0, + retweetsCount: 0, + commentsCount: 0, + isLikedByMe: false, + isFollowedByMe: false, + isRepostedByMe: false, + text: 'Test tweet', + mentions: [], + media: [], + isRepost: false, + isQuote: false, +}; + +describe('useTimelineStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state hooks', () => { + it('should have selectedTab hook', () => { + const { result } = renderHook(() => useSelectedTab()); + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('string'); + }); + + it('should have searchUser hook', () => { + const { result } = renderHook(() => useSearchUser()); + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('string'); + }); + + it('should have searchIsOpen hook', () => { + const { result } = renderHook(() => useSearchIsopen()); + expect(typeof result.current).toBe('boolean'); + }); + + it('should have newTweets hook', () => { + const { result } = renderHook(() => useNewTweets()); + expect(Array.isArray(result.current)).toBe(true); + }); + + it('should have popUpAvatars hook', () => { + const { result } = renderHook(() => usePopUpAvatars()); + expect(Array.isArray(result.current)).toBe(true); + }); + + it('should have fetchAvatars hook', () => { + const { result } = renderHook(() => useFetchAvatars()); + expect(typeof result.current).toBe('boolean'); + }); + + it('should have tabsScroll hook', () => { + const { result } = renderHook(() => useTabsScroll()); + expect(Array.isArray(result.current)).toBe(true); + }); + + it('should have parentId hook', () => { + const { result } = renderHook(() => useParentId()); + expect(typeof result.current).toBe('number'); + }); + + it('should have postType hook', () => { + const { result } = renderHook(() => usePostType()); + expect(typeof result.current).toBe('string'); + }); + + it('should have showCheckModal hook', () => { + const { result } = renderHook(() => useShowCheckModal()); + expect(typeof result.current).toBe('boolean'); + }); + }); + + describe('useActions hook', () => { + it('should return actions object', () => { + const { result } = renderHook(() => useActions()); + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('object'); + }); + + it('should have selectTab action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.selectTab).toBe('function'); + }); + + it('should have setSearchUser action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setSearchUser).toBe('function'); + }); + + it('should have setSearchIsOpen action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setSearchIsOpen).toBe('function'); + }); + + it('should have setNewTweets action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setNewTweets).toBe('function'); + }); + + it('should have setPopUpAvatars action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setPopUpAvatars).toBe('function'); + }); + + it('should have setFetchAvatars action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setFetchAvatars).toBe('function'); + }); + + it('should have setTabsScroll action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setTabsScroll).toBe('function'); + }); + + it('should have setParentId action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setParentId).toBe('function'); + }); + + it('should have setPostType action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setPostType).toBe('function'); + }); + + it('should have setShowCheckModal action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.setShowCheckModal).toBe('function'); + }); + + it('should have addVisibleTweet action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.addVisibleTweet).toBe('function'); + }); + + it('should have removeVisibleTweet action', () => { + const { result } = renderHook(() => useActions()); + expect(typeof result.current.removeVisibleTweet).toBe('function'); + }); + }); + + describe('action functionality', () => { + it('selectTab should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.selectTab(FOR_YOU_TAB); + }); + }).not.toThrow(); + }); + + it('setSearchUser should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setSearchUser('testuser'); + }); + }).not.toThrow(); + }); + + it('setSearchIsOpen should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setSearchIsOpen(true); + }); + }).not.toThrow(); + }); + + it('setNewTweets should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setNewTweets([mockTweet]); + }); + }).not.toThrow(); + }); + + it('setPopUpAvatars should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setPopUpAvatars([ + { avatar: '/test.jpg', name: 'Test' }, + ]); + }); + }).not.toThrow(); + }); + + it('setFetchAvatars should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setFetchAvatars(true); + }); + }).not.toThrow(); + }); + + it('setTabsScroll should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setTabsScroll([100, 200]); + }); + }).not.toThrow(); + }); + + it('setParentId should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setParentId(123); + }); + }).not.toThrow(); + }); + + it('setPostType should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setPostType(ADD_TWEET.REPLY); + }); + }).not.toThrow(); + }); + + it('setShowCheckModal should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.setShowCheckModal(false); + }); + }).not.toThrow(); + }); + + it('addVisibleTweet should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.addVisibleTweet(mockTweet); + }); + }).not.toThrow(); + }); + + it('removeVisibleTweet should be callable', () => { + const { result } = renderHook(() => useActions()); + + expect(() => { + act(() => { + result.current.removeVisibleTweet(mockTweet); + }); + }).not.toThrow(); + }); + }); + + describe('constants integration', () => { + it('should use FOR_YOU_TAB constant', () => { + expect(FOR_YOU_TAB).toBeDefined(); + expect(typeof FOR_YOU_TAB).toBe('string'); + }); + + it('should use FOLLOWING_TAB constant', () => { + expect(FOLLOWING_TAB).toBeDefined(); + expect(typeof FOLLOWING_TAB).toBe('string'); + }); + + it('should use ADD_TWEET constants', () => { + expect(ADD_TWEET).toBeDefined(); + expect(ADD_TWEET.POST).toBeDefined(); + expect(ADD_TWEET.REPLY).toBeDefined(); + expect(ADD_TWEET.QUOTE).toBeDefined(); + }); + }); +}); From cf762c7f3ec8394fcb423d84dc5b7e7f9316efdb Mon Sep 17 00:00:00 2001 From: ahmedfathy0-0 Date: Mon, 15 Dec 2025 23:52:38 +0200 Subject: [PATCH 3/9] fix: add tests for mute functionality and utility functions with some warinings fix --- src/app/auth-debug/page.tsx | 185 ---- src/app/auth-demo/page.tsx | 223 ----- src/app/cookie-check/page.tsx | 71 -- src/app/demo/auth-forms/page.tsx | 5 - src/app/demo/buttons/auth/page.tsx | 5 - src/app/demo/buttons/general/page.tsx | 5 - src/app/demo/buttons/page.tsx | 107 --- src/app/demo/components/AuthButtonsDemo.tsx | 386 -------- src/app/demo/components/ButtonsDemo.tsx | 307 ------- .../demo/components/GenericAuthFormDemo.tsx | 835 ------------------ src/app/demo/components/InputFieldsDemo.tsx | 503 ----------- src/app/demo/components/UserCardDemo.tsx | 262 ------ src/app/demo/components/XModalDemo.tsx | 471 ---------- src/app/demo/inputs/page.tsx | 5 - src/app/demo/modals/page.tsx | 5 - src/app/demo/page.tsx | 278 ------ src/app/demo/usercard/page.tsx | 6 - src/app/explore/layout.tsx | 2 +- src/app/explore/tabs/[tab]/page.tsx | 6 +- src/app/home/[full-tweet]/page.tsx | 5 - src/app/home/layout.tsx | 8 +- src/app/i/foundmedia/search/page.tsx | 4 +- src/app/interests/[interest]/[tab]/page.tsx | 2 +- src/app/interests/[interest]/page.tsx | 2 +- src/app/layout.tsx | 3 +- src/app/messages/layout.tsx | 2 +- src/app/notifications/layout.tsx | 2 +- src/app/settings/layout.tsx | 2 +- src/app/test-backend/page.tsx | 141 --- src/app/tweet/page.tsx | 46 - src/components/generic/Avatar.tsx | 18 +- src/components/generic/Dropdown.tsx | 40 +- src/components/generic/GenericUserList.tsx | 4 +- src/components/generic/ImageModal.tsx | 33 +- src/components/generic/Tabs.tsx | 10 +- .../generic/__tests__/Avatar.test.tsx | 136 +++ .../generic/__tests__/BlockBtn.test.tsx | 164 ++++ .../generic/__tests__/Cover.test.tsx | 70 ++ .../generic/__tests__/Dropdown.test.tsx | 169 ++++ .../__tests__/EditProfileAvatar.test.tsx | 117 +++ .../__tests__/EditProfileCover.test.tsx | 192 ++++ .../__tests__/EditProfileForm.test.tsx | 401 +++++++++ .../generic/__tests__/FollowBtn.test.tsx | 147 +++ .../__tests__/GenericUserList.test.tsx | 252 ++++++ .../generic/__tests__/ImageModal.test.tsx | 249 ++++++ .../InfiniteScrollContainer.test.tsx | 178 ++++ .../generic/__tests__/Loader.test.tsx | 40 + .../generic/__tests__/MuteBtn.test.tsx | 121 +++ .../generic/__tests__/Tabs.test.tsx | 152 ++++ .../generic/__tests__/XLoader.test.tsx | 49 + .../interactions/__tests__/useBlock.test.tsx | 471 ++++++++++ .../interactions/__tests__/useFollow.test.tsx | 621 +++++++++++++ .../__tests__/useInteractions.test.tsx | 82 ++ .../interactions/__tests__/useMute.test.tsx | 515 +++++++++++ src/lib/utils/__tests__/index.test.ts | 128 +++ src/utils/__tests__/index.test.ts | 299 +++++++ src/utils/__tests__/profileValidation.test.ts | 289 ++++++ vitest.config.ts | 1 + 58 files changed, 4927 insertions(+), 3905 deletions(-) delete mode 100644 src/app/auth-debug/page.tsx delete mode 100644 src/app/auth-demo/page.tsx delete mode 100644 src/app/cookie-check/page.tsx delete mode 100644 src/app/demo/auth-forms/page.tsx delete mode 100644 src/app/demo/buttons/auth/page.tsx delete mode 100644 src/app/demo/buttons/general/page.tsx delete mode 100644 src/app/demo/buttons/page.tsx delete mode 100644 src/app/demo/components/AuthButtonsDemo.tsx delete mode 100644 src/app/demo/components/ButtonsDemo.tsx delete mode 100644 src/app/demo/components/GenericAuthFormDemo.tsx delete mode 100644 src/app/demo/components/InputFieldsDemo.tsx delete mode 100644 src/app/demo/components/UserCardDemo.tsx delete mode 100644 src/app/demo/components/XModalDemo.tsx delete mode 100644 src/app/demo/inputs/page.tsx delete mode 100644 src/app/demo/modals/page.tsx delete mode 100644 src/app/demo/page.tsx delete mode 100644 src/app/demo/usercard/page.tsx delete mode 100644 src/app/test-backend/page.tsx delete mode 100644 src/app/tweet/page.tsx create mode 100644 src/components/generic/__tests__/Avatar.test.tsx create mode 100644 src/components/generic/__tests__/BlockBtn.test.tsx create mode 100644 src/components/generic/__tests__/Cover.test.tsx create mode 100644 src/components/generic/__tests__/Dropdown.test.tsx create mode 100644 src/components/generic/__tests__/EditProfileAvatar.test.tsx create mode 100644 src/components/generic/__tests__/EditProfileCover.test.tsx create mode 100644 src/components/generic/__tests__/EditProfileForm.test.tsx create mode 100644 src/components/generic/__tests__/FollowBtn.test.tsx create mode 100644 src/components/generic/__tests__/GenericUserList.test.tsx create mode 100644 src/components/generic/__tests__/ImageModal.test.tsx create mode 100644 src/components/generic/__tests__/InfiniteScrollContainer.test.tsx create mode 100644 src/components/generic/__tests__/Loader.test.tsx create mode 100644 src/components/generic/__tests__/MuteBtn.test.tsx create mode 100644 src/components/generic/__tests__/Tabs.test.tsx create mode 100644 src/components/generic/__tests__/XLoader.test.tsx create mode 100644 src/hooks/interactions/__tests__/useBlock.test.tsx create mode 100644 src/hooks/interactions/__tests__/useFollow.test.tsx create mode 100644 src/hooks/interactions/__tests__/useInteractions.test.tsx create mode 100644 src/hooks/interactions/__tests__/useMute.test.tsx create mode 100644 src/lib/utils/__tests__/index.test.ts create mode 100644 src/utils/__tests__/index.test.ts create mode 100644 src/utils/__tests__/profileValidation.test.ts diff --git a/src/app/auth-debug/page.tsx b/src/app/auth-debug/page.tsx deleted file mode 100644 index c5a85c07..00000000 --- a/src/app/auth-debug/page.tsx +++ /dev/null @@ -1,185 +0,0 @@ -'use client'; -import { useEffect, useState } from 'react'; -import { useAuthStore } from '@/features/authentication/store/authStore'; - -export default function AuthDebugPage() { - const user = useAuthStore((s) => s.user); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - const [cookieInfo, setCookieInfo] = useState(''); - - useEffect(() => { - // Check cookies - const cookies = document.cookie; - setCookieInfo(cookies || 'No cookies found'); - }, []); - - const testBackend = async () => { - try { - const response = await fetch( - 'https://api.hankers.myaddr.tools/api/v1.0/auth/me', - { - credentials: 'include', - } - ); - const data = await response.json(); - console.log('Backend /me response:', data); - alert(JSON.stringify(data, null, 2)); - } catch (error) { - console.error('Error calling /me:', error); - alert('Error: ' + error); - } - }; - - return ( -
-
-

Authentication Debug Page

- -
- {/* Frontend Auth State */} -
-

- Frontend Auth State (Zustand) -

-
-
- Is Authenticated: - - {isAuthenticated ? '✅ Yes' : '❌ No'} - -
-
- User ID: - - {(user as any)?.id || 'null'} - -
-
- Username: - - {(user as any)?.username || 'null'} - -
-
- Email: - - {(user as any)?.email || 'null'} - -
-
-
- - Full User Object - -
-                {JSON.stringify(user, null, 2)}
-              
-
-
- - {/* Browser Cookies */} -
-

Browser Cookies

-
-
- Has access_token: - - {cookieInfo.includes('access_token') ? '✅ Yes' : '❌ No'} - -
-
- - View All Cookies - -
-                  {cookieInfo}
-                
-
-
-
- - {/* Backend Test */} -
-

- Backend Authentication Test -

-

- Click the button below to check if the backend recognizes you as - authenticated. -

- -

- This will call GET /api/v1.0/auth/me and show the response -

-
- - {/* Instructions */} -
-

🔍 Debugging Steps

-
    -
  1. Check if "Is Authenticated" shows ✅ Yes
  2. -
  3. Check if "User ID" is a number (not null)
  4. -
  5. Check if "access_token" cookie exists
  6. -
  7. - Click "Test Backend /auth/me" to verify backend - recognizes you -
  8. -
  9. If backend returns 401, you need to login again
  10. -
-
- - {/* Common Issues */} -
-

❌ Common Issues

-
-
-
- Issue: User ID is null -
-
- → Backend is not returning user ID in the response. Check - backend /auth/login response. -
-
-
-
- Issue: Multiple users logging in -
-
- → Backend allows only one session per user. Second login kicks - out first session. -
→ Solution: Use different browsers or contact backend - team to allow multiple sessions. -
-
-
-
- Issue: No access_token cookie -
-
- → You're not logged in. Go to login page and - authenticate. -
-
-
-
-
-
-
- ); -} diff --git a/src/app/auth-demo/page.tsx b/src/app/auth-demo/page.tsx deleted file mode 100644 index b8a54f04..00000000 --- a/src/app/auth-demo/page.tsx +++ /dev/null @@ -1,223 +0,0 @@ -'use client'; - -import { useAuth } from '@/features/authentication/hooks'; -import { useAuthHandlers } from '@/features/authentication/hooks'; -import { useAuthModals } from '@/features/authentication/hooks'; -import { CheckIcon, CloseXIcon } from '@/components/ui/icons'; -import { useSearchParams } from 'next/navigation'; -import { useEffect, useState, Suspense } from 'react'; -import XLoader from '@/components/ui/XLoader'; - -function AuthDemoContent() { - const searchParams = useSearchParams(); - const [showSuccessMessage, setShowSuccessMessage] = useState(false); - const auth = useAuth(); - const { formState, clearFormState } = useAuthHandlers(); - const { openModal } = useAuthModals(); - - // Safe getters to avoid TS errors when auth.user can be a loose Record - const getField = (key: string) => { - const u = auth.user as Record | null; - if (!u) return 'N/A'; - const v = u[key]; - return typeof v === 'string' && v.length > 0 ? v : 'N/A'; - }; - - const getDateField = (key: string) => { - const u = auth.user as Record | null; - if (!u) return 'N/A'; - const v = u[key]; - if (typeof v === 'string' || typeof v === 'number') { - const d = new Date(v as string | number); - if (isNaN(d.getTime())) return 'N/A'; - return d.toLocaleDateString(); - } - return 'N/A'; - }; - - const userDisplay = (() => { - const name = getField('name'); - if (name !== 'N/A') return name; - const email = getField('email'); - if (email !== 'N/A') return email; - const username = getField('username'); - if (username !== 'N/A') return username; - return ''; - })(); - - useEffect(() => { - if (!searchParams) return; - - const loginSuccess = searchParams.get('login'); - const registerSuccess = searchParams.get('register'); - - if (loginSuccess === 'success') { - setShowSuccessMessage(true); - // Hide success message after 5 seconds - setTimeout(() => { - setShowSuccessMessage(false); - }, 5000); - } else if (registerSuccess === 'success') { - setShowSuccessMessage(true); - // Hide success message after 5 seconds - setTimeout(() => { - setShowSuccessMessage(false); - }, 5000); - } - }, [searchParams]); - - return ( -
-
-

Authentication Demo

- - {/* Success Message */} - {showSuccessMessage && ( -
-
-
- -
-
- {/*

- {searchParams?.get('login') === 'success' - ? `🎉 Login successful! Welcome back, ${auth.user?.name || auth.user?.email}!` - : searchParams?.get('register') === 'success' - ? `🎉 Registration successful! Welcome, ${auth.user?.name || auth.user?.email}!` - : '🎉 Success!'} -

*/} -
-
- -
-
-
- )} - - {/* Authentication Status */} -
-

Authentication Status

- {auth.isAuthenticated ? ( -
-

✅ Authenticated

-
-

- Name: {userDisplay || 'N/A'} -

-

- Username: {getField('username')} -

-

- Email: {getField('email')} -

-

- Role: {getField('role')} -

-

- Birth Date: {getDateField('birthDate')} -

-

- Location: {getField('location')} -

-

- Created At: {getDateField('createdAt')} -

-
- -
- ) : ( -
-

❌ Not authenticated

-
- - -
-
- )} -
- - {/* Form State */} -
-

Form State

-
-

- Loading: {formState.isLoading ? 'Yes' : 'No'} -

-

- Errors:{' '} - {Object.keys(formState.errors).length > 0 - ? JSON.stringify(formState.errors) - : 'None'} -

-

- Success: {formState.success ? 'Yes' : 'No'} -

-
- {Object.keys(formState.errors).length > 0 && ( - - )} -
- - {/* API Test */} -
-

API Integration

-
-
-

Backend Endpoints:

-
    -
  • POST /api/v1.0/auth/register - Register new user
  • -
  • POST /api/v1.0/auth/login - Login with email/password
  • -
  • GET /api/v1.0/auth/test - Test authentication
  • -
-
-
-

Features:

-
    -
  • ✅ React Query for API state management
  • -
  • ✅ Zustand for client state management
  • -
  • ✅ HTTPOnly cookie authentication
  • -
  • ✅ TypeScript types from OpenAPI spec
  • -
  • ✅ Error handling and loading states
  • -
  • ✅ Persistent authentication state
  • -
-
-
-
-
-
- ); -} - -export default function AuthDemoPage() { - return ( - }> - - - ); -} diff --git a/src/app/cookie-check/page.tsx b/src/app/cookie-check/page.tsx deleted file mode 100644 index 67dc5533..00000000 --- a/src/app/cookie-check/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import Link from 'next/link'; - -export default function CookieCheckPage() { - const [cookies, setCookies] = useState(''); - const [accessToken, setAccessToken] = useState(null); - - useEffect(() => { - // Get all cookies - const allCookies = document.cookie; - setCookies(allCookies || 'No cookies found'); - - // Get access_token specifically - const getCookie = (name: string): string | undefined => { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift(); - }; - - const token = getCookie('access_token'); - setAccessToken(token || null); - }, []); - - return ( -
-
-

🍪 Cookie Check

- -
-
-

Access Token

- {accessToken ? ( -
-

✅ Found!

-
- {accessToken} -
-
- ) : ( -

❌ Not found - You need to login!

- )} -
- -
-

All Cookies

-
- {cookies} -
-
-
- -
-

💡 Instructions:

-
    -
  1. Open this page in BOTH browsers you want to test
  2. -
  3. - If access_token is missing, go to{' '} - - Login Page - -
  4. -
  5. After login, refresh this page to see the token
  6. -
  7. Once BOTH browsers show the token, you can test messaging
  8. -
-
-
-
- ); -} diff --git a/src/app/demo/auth-forms/page.tsx b/src/app/demo/auth-forms/page.tsx deleted file mode 100644 index 636b8133..00000000 --- a/src/app/demo/auth-forms/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GenericAuthFormDemo } from '../components/GenericAuthFormDemo'; - -export default function GenericAuthFormDemoPage() { - return ; -} diff --git a/src/app/demo/buttons/auth/page.tsx b/src/app/demo/buttons/auth/page.tsx deleted file mode 100644 index d12b30e5..00000000 --- a/src/app/demo/buttons/auth/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AuthButtonsDemo } from '../../components/AuthButtonsDemo'; - -export default function AuthButtonDemoPage() { - return ; -} diff --git a/src/app/demo/buttons/general/page.tsx b/src/app/demo/buttons/general/page.tsx deleted file mode 100644 index 43653ede..00000000 --- a/src/app/demo/buttons/general/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ButtonsDemo } from '../../components/ButtonsDemo'; - -export default function GeneralButtonsPage() { - return ; -} diff --git a/src/app/demo/buttons/page.tsx b/src/app/demo/buttons/page.tsx deleted file mode 100644 index 5abac6d0..00000000 --- a/src/app/demo/buttons/page.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import Link from 'next/link'; -import { XLogo } from '@/components/ui/icons'; - -export default function ButtonsIndexPage() { - return ( -
-
-
-
- -
-

- Button Components -

-

- Explore our button components including general-purpose buttons and - authentication buttons. -

-
- -
-
- {/* General Buttons Card */} - -
-
- - - -
-

- General Buttons -

-

- Primary, secondary, outline, and ghost button variants with - different sizes and states. -

-
- - View Examples → - -
-
- - - {/* Auth Buttons Card */} - -
-
- - - -
-

- Auth Buttons -

-

- Social login buttons for Google, Apple, Facebook, GitHub, and - more authentication providers. -

-
- - View Examples → - -
-
- -
-
- - {/* Navigation */} -
-
- - ← Back to Demos - - - Input Components → - -
-
-
-
- ); -} diff --git a/src/app/demo/components/AuthButtonsDemo.tsx b/src/app/demo/components/AuthButtonsDemo.tsx deleted file mode 100644 index 1a3f721f..00000000 --- a/src/app/demo/components/AuthButtonsDemo.tsx +++ /dev/null @@ -1,386 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import Link from 'next/link'; -import { AuthButton } from '@/components/ui/AuthButton'; -import { Divider } from '@/components/ui/Divider'; -import { - GoogleIcon, - AppleIcon, - GrokIcon, - FacebookIcon, - GitHubIcon, - XLogo, -} from '@/components/ui/icons'; - -export function AuthButtonsDemo() { - const [loading, setLoading] = useState(null); - - const handleButtonClick = (buttonName: string) => { - setLoading(buttonName); - setTimeout(() => setLoading(null), 2000); - }; - - return ( -
-
-
-
- -
-

- Button Components Demo -

-

- Explore all button variants with different states, sizes, and use - cases. All buttons follow X/Twitter design patterns and are fully - interactive. -

-
- - {/* Social Login Buttons Section */} -
-

- Social Login Buttons -

-
- } - className="w-full" - onClick={() => handleButtonClick('google')} - loading={loading === 'google'} - > - Sign in with Google - - - } - className="w-full" - onClick={() => handleButtonClick('apple')} - loading={loading === 'apple'} - > - Sign in with Apple - - - } - className="w-full" - onClick={() => handleButtonClick('grok')} - loading={loading === 'grok'} - > - Sign in with Grok - - - } - className="w-full" - onClick={() => handleButtonClick('facebook')} - loading={loading === 'facebook'} - > - Sign in with Facebook - - - } - className="w-full" - onClick={() => handleButtonClick('github')} - loading={loading === 'github'} - > - Sign in with GitHub - -
-
- - - - {/* Primary Buttons Section */} -
-

- Primary Buttons -

-
- handleButtonClick('primary-lg')} - loading={loading === 'primary-lg'} - > - Create Account - - - handleButtonClick('primary-md')} - loading={loading === 'primary-md'} - > - Sign In - - - handleButtonClick('primary-sm')} - loading={loading === 'primary-sm'} - > - Continue - -
-
- - - - {/* Secondary Buttons Section */} -
-

- Secondary Buttons -

-
- handleButtonClick('secondary-lg')} - loading={loading === 'secondary-lg'} - > - Learn More - - - handleButtonClick('secondary-md')} - loading={loading === 'secondary-md'} - > - Get Started - - - handleButtonClick('secondary-sm')} - loading={loading === 'secondary-sm'} - > - Explore - -
-
- - - - {/* Outline Buttons Section */} -
-

- Outline Buttons -

-
- handleButtonClick('outline-lg')} - loading={loading === 'outline-lg'} - > - View Profile - - - handleButtonClick('outline-md')} - loading={loading === 'outline-md'} - > - Edit Settings - - - handleButtonClick('outline-sm')} - loading={loading === 'outline-sm'} - > - Share - -
-
- - - - {/* Ghost Buttons Section */} -
-

- Ghost Buttons -

-
- handleButtonClick('ghost-lg')} - loading={loading === 'ghost-lg'} - > - Follow - - - handleButtonClick('ghost-md')} - loading={loading === 'ghost-md'} - > - Message - - - handleButtonClick('ghost-sm')} - loading={loading === 'ghost-sm'} - > - Like - -
-
- - - - {/* Button States Section */} -
-

- Button States -

-
- - Disabled Button - - - - Loading Button - - - } - onClick={() => handleButtonClick('with-icon')} - loading={loading === 'with-icon'} - > - Button with Icon - -
-
- - {/* Usage Examples */} -
-

- Usage Examples -

-
-
-
-

Form Actions

-
- handleButtonClick('submit')} - loading={loading === 'submit'} - > - Submit Form - - handleButtonClick('cancel')} - loading={loading === 'cancel'} - > - Cancel - -
-
- -
-

- Social Actions -

-
- handleButtonClick('follow')} - loading={loading === 'follow'} - > - Follow @username - - handleButtonClick('message')} - loading={loading === 'message'} - > - Send Message - -
-
-
-
-
- - {/* Navigation */} -
-
- - ← Back to Demos - - - ← Buttons Home - - - ← General Buttons - -
-
-
-
- ); -} diff --git a/src/app/demo/components/ButtonsDemo.tsx b/src/app/demo/components/ButtonsDemo.tsx deleted file mode 100644 index afa4f50c..00000000 --- a/src/app/demo/components/ButtonsDemo.tsx +++ /dev/null @@ -1,307 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import Link from 'next/link'; -import Button from '@/components/ui/Button'; -import { Divider } from '@/components/ui/Divider'; -import { XLogo } from '@/components/ui/icons'; - -export function ButtonsDemo() { - const [loading, setLoading] = useState(null); - - const handleButtonClick = (buttonName: string) => { - setLoading(buttonName); - setTimeout(() => setLoading(null), 2000); - }; - - return ( -
-
-
-
- -
-

- General Button Components -

-

- Explore all button variants including primary, secondary, outline, - and ghost buttons with different states and sizes. -

-
- - {/* Primary Buttons Section */} -
-

- Primary Buttons -

-
- - - - - -
-
- - - - {/* Secondary Buttons Section */} -
-

- Secondary Buttons -

-
- - - - - -
-
- - - - {/* Outline Buttons Section */} -
-

- Outline Buttons -

-
- - - - - -
-
- - - - {/* Ghost Buttons Section */} -
-

- Ghost Buttons -

-
- - - - - -
-
- - - - {/* Button States Section */} -
-

- Button States -

-
- - - - - -
-
- - - - {/* Usage Examples */} -
-

- Usage Examples -

-
-
-
-

Form Actions

-
- - -
-
- -
-

- Multiple Buttons -

-
- - - -
-
-
-
-
- - {/* Navigation */} -
-
- - ← Back to Demos - - - ← Buttons Home - - - Auth Buttons → - -
-
-
-
- ); -} diff --git a/src/app/demo/components/GenericAuthFormDemo.tsx b/src/app/demo/components/GenericAuthFormDemo.tsx deleted file mode 100644 index 863d648f..00000000 --- a/src/app/demo/components/GenericAuthFormDemo.tsx +++ /dev/null @@ -1,835 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import Link from 'next/link'; -import { FormContainer, authFormConfigs } from '@/components/ui/forms'; -import { AuthButton } from '@/components/ui/AuthButton'; -import { Divider } from '@/components/ui/Divider'; -import { - XLogo, - LoginIcon, - UserIcon, - KeyIcon, - MailIcon, - ChatIcon, - ListIcon, -} from '@/components/ui/icons'; - -type FormType = - | 'login' - | 'register' - | 'forgotPassword' - | 'contact' - | 'newsletter' - | 'feedback' - | 'survey' - | 'support' - | 'newsletter-signup' - | 'user-profile' - | null; - -export function GenericAuthFormDemo() { - const [activeForm, setActiveForm] = useState(null); - const [submittedData, setSubmittedData] = useState | null>(null); - const [formState, setFormState] = useState({ - isLoading: false, - errors: {}, - success: false, - }); - - const handleSubmit = (data: Record) => { - console.log('Form submitted:', data); - setFormState({ isLoading: true, errors: {}, success: false }); - - // Simulate API call - setTimeout(() => { - setFormState({ isLoading: false, errors: {}, success: true }); - setSubmittedData(data); - - setTimeout(() => { - setSubmittedData(null); - setActiveForm(null); - setFormState({ isLoading: false, errors: {}, success: false }); - }, 2000); - }, 1000); - }; - - const handleSocialAuth = (providerId: string) => { - console.log('Social login:', providerId); - alert(`Social login with: ${providerId}`); - setActiveForm(null); - }; - - const handleForgotPassword = () => { - console.log('Forgot password clicked'); - setActiveForm('forgotPassword'); - }; - - const closeModal = () => { - setActiveForm(null); - setFormState({ isLoading: false, errors: {}, success: false }); - }; - - const clearFormState = () => { - setFormState({ isLoading: false, errors: {}, success: false }); - }; - - return ( -
-
-
-
- -
-

- Generic Forms Demo -

-

- Explore our comprehensive collection of responsive forms built with - generic components. All forms automatically adapt to screen size - with modal/full-page modes and follow X/Twitter design patterns. -

-
- - {/* Success Message */} - {submittedData && ( -
-

Form Submitted Successfully!

-
-              {JSON.stringify(submittedData, null, 2)}
-            
-
- )} - - {/* Authentication Forms Section */} -
-

- Authentication Forms -

-
- {/* Login Form */} -
-
- -
-

- Sign In -

-

- Login with email and password or social providers -

- setActiveForm('login')} - > - Try Login Form - -
- - {/* Register Form */} -
-
- -
-

- Sign Up -

-

- Create account with email, password, and date of birth -

- setActiveForm('register')} - > - Try Register Form - -
- - {/* Forgot Password Form */} -
-
- -
-

- Reset Password -

-

- Recover your account -

- setActiveForm('forgotPassword')} - > - Try Reset Form - -
-
-
- - - - {/* Generic Forms Section */} -
-

- Generic Forms -

-
- {/* Contact Form */} -
-
- -
-

- Contact -

-

- Get in touch with us -

- setActiveForm('contact')} - > - Try Contact Form - -
- - {/* Newsletter Form */} -
-
- -
-

- Newsletter -

-

- Subscribe to updates -

- setActiveForm('newsletter')} - > - Try Newsletter Form - -
- - {/* Feedback Form */} -
-
- -
-

- Feedback -

-

- Share your thoughts -

- setActiveForm('feedback')} - > - Try Feedback Form - -
- - {/* Survey Form */} -
-
- -
-

Survey

-

Help us improve

- setActiveForm('survey')} - > - Try Survey Form - -
-
-
- - - - {/* Advanced Forms Section */} -
-

- Advanced Forms -

-
- {/* Support Form */} -
-
- -
-

- Support -

-

- Technical support request -

- setActiveForm('support')} - > - Try Support Form - -
- - {/* Newsletter Signup */} -
-
- -
-

- Newsletter Signup -

-

- Simple email subscription -

- setActiveForm('newsletter-signup')} - > - Try Signup Form - -
- - {/* User Profile */} -
-
- -
-

- User Profile -

-

- Update profile information -

- setActiveForm('user-profile')} - > - Try Profile Form - -
-
-
- - {/* Form Features Section */} -
-

- Form Features -

-
-
-
-
- - - -
-

- Responsive Design -

-

- Forms automatically adapt to screen size with modal/full-page - modes -

-
- -
-
- - - -
-

- Form Validation -

-

- Built-in validation with real-time error feedback and field - validation -

-
- -
-
- - - -
-

- Social Login -

-

- Integrated social login buttons with customizable providers -

-
-
-
-
- - {/* Navigation */} -
-
- - ← Back to Demos - - - ← Button Components - - - Input Components → - -
-
- - {/* Form Modals */} - {activeForm === 'login' && ( - - )} - - {activeForm === 'register' && ( - - )} - - {activeForm === 'forgotPassword' && ( - - )} - - {activeForm === 'contact' && ( - - )} - - {activeForm === 'newsletter' && ( - - )} - - {activeForm === 'feedback' && ( - - )} - - {activeForm === 'survey' && ( - - )} - - {activeForm === 'support' && ( - - )} - - {activeForm === 'newsletter-signup' && ( - - )} - - {activeForm === 'user-profile' && ( - - )} -
-
- ); -} diff --git a/src/app/demo/components/InputFieldsDemo.tsx b/src/app/demo/components/InputFieldsDemo.tsx deleted file mode 100644 index bbbe4a0d..00000000 --- a/src/app/demo/components/InputFieldsDemo.tsx +++ /dev/null @@ -1,503 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import Link from 'next/link'; -import { InputField, SearchInput } from '@/components/ui/input'; -import { SelectField } from '@/components/ui/SelectField'; -import { Divider } from '@/components/ui/Divider'; -import { XLogo, EyeIcon } from '@/components/ui/icons'; - -export function InputFieldsDemo() { - const [formData, setFormData] = useState({ - basic: '', - email: '', - password: '', - withIcon: '', - withCounter: '', - withError: '', - disabled: 'This field is disabled', - required: '', - withPlaceholder: '', - maxLengthField: '', - number: '', - tel: '', - url: '', - }); - - const [searchQuery, setSearchQuery] = useState(''); - - const [selectData, setSelectData] = useState({ - basic: '', - withError: '', - required: '', - disabled: 'option2', - }); - - const [errors, setErrors] = useState({ - withError: 'This field has an error message', - required: '', - email: '', - selectWithError: 'Please select a valid option', - }); - - const handleInputChange = - (field: string) => - ( - e: React.ChangeEvent< - HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement - > - ) => { - setFormData((prev) => ({ - ...prev, - [field]: e.target.value, - })); - - // Clear error when user starts typing - if (errors[field as keyof typeof errors]) { - setErrors((prev) => ({ - ...prev, - [field]: '', - })); - } - }; - - const handleSelectChange = - (field: string) => (e: React.ChangeEvent) => { - setSelectData((prev) => ({ - ...prev, - [field]: e.target.value, - })); - - // Clear error when user selects - if (errors[`select${field}` as keyof typeof errors]) { - setErrors((prev) => ({ - ...prev, - [`select${field}`]: '', - })); - } - }; - - const handleBlur = (field: string) => () => { - // Simple validation examples - if (field === 'email' && formData.email && !formData.email.includes('@')) { - setErrors((prev) => ({ - ...prev, - email: 'Please enter a valid email', - })); - } - }; - - const selectOptions = [ - { value: '', label: 'Select an option' }, - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, - { value: 'option3', label: 'Option 3' }, - ]; - - const countryOptions = [ - { value: '', label: 'Select a country' }, - { value: 'us', label: 'United States' }, - { value: 'ca', label: 'Canada' }, - { value: 'uk', label: 'United Kingdom' }, - { value: 'de', label: 'Germany' }, - { value: 'fr', label: 'France' }, - ]; - - return ( -
-
-
-
- -
-

- Input Components Demo -

-

- Explore all input field variants including floating labels, - validation states, character counters, and different input types. - All components are fully interactive. -

-
- - {/* Basic Input Fields */} -
-

- Basic Input Fields -

-
- - - - - - - - - - - -
-
- - - - {/* Input Field States */} -
-

- Input Field States -

-
- - - - - - - -
-
- - - - {/* Input Field Features */} -
-

- Input Field Features -

-
- } - /> - - - - -
-
- - - - {/* Search Input */} -
-

- Search Input -

-
- - - - -
- Current search: {searchQuery || '(empty)'} -
-
-
- - - - {/* Select Fields */} -
-

- Select Fields -

-
- - - - - - - - - -
-
- - - - {/* Form Examples */} -
-

- Form Examples -

-
-
-
-

Contact Form

-
- - - -
-
- -
-

User Profile

-
- - - -
-
-
-
-
- - {/* Component Features */} -
-

- Component Features -

-
-
-
-
- - - -
-

- Floating Labels -

-

- Labels float above the input when focused or filled -

-
- -
-
- - - -
-

- Validation States -

-

- Built-in error handling and validation feedback -

-
- -
-
- - - -
-

- Character Counter -

-

- Real-time character counting with max length support -

-
-
-
-
- - {/* Navigation */} -
-
- - ← Back to Demos - - - ← Button Components - - - Auth Forms → - -
-
-
-
- ); -} diff --git a/src/app/demo/components/UserCardDemo.tsx b/src/app/demo/components/UserCardDemo.tsx deleted file mode 100644 index 0078797b..00000000 --- a/src/app/demo/components/UserCardDemo.tsx +++ /dev/null @@ -1,262 +0,0 @@ -// 'use client'; - -// import React, { useState } from 'react'; -// import Link from 'next/link'; -// import UserCard from '@/components/ui/UserCard'; -// import { Divider } from '@/components/ui/Divider'; -// import { XLogo } from '@/components/ui/icons'; - -// export function UserCardDemo() { -// const [loadingStates, setLoadingStates] = useState>( -// {} -// ); - -// const handleAction = (actionId: string, userName: string) => { -// setLoadingStates((prev) => ({ ...prev, [actionId]: true })); -// console.log(`Action: ${actionId} for ${userName}`); - -// // Simulate API call -// setTimeout(() => { -// setLoadingStates((prev) => ({ ...prev, [actionId]: false })); -// }, 1500); -// }; - -// return ( -//
-//
-//
-//
-// -//
-//

-// User Card Component -//

-//

-// A flexible user card component with customizable actions for -// different use cases. -//

-//
- -// {/* Follow Actions */} -//
-//

-// Follow/Unfollow Actions -//

-//
-// handleAction('follow-1', 'Bassem Youssef'), -// variant: 'secondary', -// loading: loadingStates['follow-1'], -// }} -// /> -// handleAction('unfollow-1', 'Ahmed Fathy'), -// variant: 'outline', -// loading: loadingStates['unfollow-1'], -// }} -// /> -//
-//
- -// - -// {/* Block Actions */} -//
-//

-// Block/Unblock Actions -//

-//
-// handleAction('block-1', 'Spam Account'), -// variant: 'outline', -// loading: loadingStates['block-1'], -// }} -// /> -// handleAction('unblock-1', 'Previously Blocked'), -// variant: 'secondary', -// loading: loadingStates['unblock-1'], -// }} -// /> -//
-//
- -// - -// {/* Mute Actions */} -//
-//

-// Mute/Unmute Actions -//

-//
-// handleAction('mute-1', 'Noisy User'), -// variant: 'outline', -// loading: loadingStates['mute-1'], -// }} -// /> -// handleAction('unmute-1', 'Previously Muted'), -// variant: 'secondary', -// loading: loadingStates['unmute-1'], -// }} -// /> -//
-//
- -// - -// {/* Different Button Variants */} -//
-//

-// Button Variants -//

-//
-// handleAction('primary-1', 'Primary Button'), -// variant: 'primary', -// loading: loadingStates['primary-1'], -// }} -// /> -// handleAction('secondary-1', 'Secondary Button'), -// variant: 'secondary', -// loading: loadingStates['secondary-1'], -// }} -// /> -// handleAction('outline-1', 'Outline Button'), -// variant: 'outline', -// loading: loadingStates['outline-1'], -// }} -// /> -// handleAction('ghost-1', 'Ghost Button'), -// variant: 'ghost', -// loading: loadingStates['ghost-1'], -// }} -// /> -//
-//
- -// - -// {/* With Avatar Images (placeholder) */} -//
-//

-// With Avatar Images -//

-//
-// handleAction('avatar-1', 'User With Avatar'), -// variant: 'secondary', -// loading: loadingStates['avatar-1'], -// }} -// /> -// handleAction('avatar-2', 'Another User'), -// variant: 'outline', -// loading: loadingStates['avatar-2'], -// }} -// /> -//
-//
- -// {/* Usage Code Example */} -//
-//

Usage Example

-//
-//
-//               {`import UserCard from '@/components/ui/UserCard';
-
-//  handleFollow(),
-//     variant: 'secondary',
-//     loading: isLoading,
-//   }}
-// />`}
-//             
-//
-//
- -// {/* Navigation */} -//
-// -// ← Back to Demos -// -//
-//
-//
-// ); -// } diff --git a/src/app/demo/components/XModalDemo.tsx b/src/app/demo/components/XModalDemo.tsx deleted file mode 100644 index 528aa26a..00000000 --- a/src/app/demo/components/XModalDemo.tsx +++ /dev/null @@ -1,471 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import Link from 'next/link'; -import XModal from '@/components/ui/hoc/XModal'; -import Button from '@/components/ui/Button'; -import { InputField } from '@/components/ui/input'; -import { Divider } from '@/components/ui/Divider'; -import { XLogo } from '@/components/ui/icons'; - -export function XModalDemo() { - const [activeModal, setActiveModal] = useState(null); - const [formData, setFormData] = useState({ name: '', email: '' }); - - const openModal = (modalType: string) => setActiveModal(modalType); - const closeModal = () => setActiveModal(null); - - return ( -
-
-
-
- -
-

- XModal Components Demo -

-

- Explore all modal variants including confirmation, form, info, - error, success, and more. All modals are fully functional and - interactive. -

-
- - {/* Confirmation Modals */} -
-

- Confirmation Modals -

-
-
- - - -
-
-
- - - - {/* Info & Alert Modals */} -
-

- Info & Alert Modals -

-
-
- - - - -
-
-
- - - - {/* Form Modals */} -
-

- Form Modals -

-
-
- - -
-
-
- - - - {/* Size Variations */} -
-

- Size Variations -

-
-
- - - - -
-
-
- - {/* Delete Confirmation Modal */} - -

- This can't be undone and it will be removed from your profile, - the timeline of any accounts that follow you, and from search - results. -

-
- - -
-
- - {/* Logout Confirmation Modal */} - -

- You can always log back in at any time. If you just want to switch - accounts, you can do that by adding an existing account. -

-
- - -
-
- - {/* Discard Changes Modal */} - -

- This can't be undone and you'll lose your changes. -

-
- - -
-
- - {/* Success Modal */} - -
-
- - - -
-

- Your changes have been saved successfully! -

- -
-
- - {/* Error Modal */} - -
-
- - - -
-

- Something went wrong. Please try again later. -

- -
-
- - {/* Warning Modal */} - -
-
- - - -
-

- This action requires your attention. Please review before - proceeding. -

-
- - -
-
-
- - {/* Info Modal */} - -
-
- - - -
-

- This is an informational modal. It can be used to display - important information, tips, or announcements to users. -

- -
-
- - {/* Contact Form Modal */} - -
- - setFormData({ ...formData, name: e.target.value }) - } - /> - - setFormData({ ...formData, email: e.target.value }) - } - /> -
- - -
-
-
- - {/* Edit Profile Modal */} - -
- {}} - maxLength={50} - showCharCount - /> - {}} - maxLength={160} - showCharCount - /> - {}} - /> -
- - -
-
-
- - {/* Size Variations Modals */} - -

- This is a small modal (max-w-sm). Perfect for simple confirmations - and alerts. -

- -
- - -

- This is a medium modal (max-w-md). Great for forms and moderate - content. -

- -
- - -

- This is a large modal (max-w-lg). Suitable for detailed forms and - extensive content. -

- -
- - -

- This is an extra large modal (max-w-xl). Best for complex forms and - rich content displays. -

- -
- - {/* Navigation */} -
-
- - ← Back to Demos - - - Button Components - - - Input Components - -
-
-
-
- ); -} diff --git a/src/app/demo/inputs/page.tsx b/src/app/demo/inputs/page.tsx deleted file mode 100644 index b290f67c..00000000 --- a/src/app/demo/inputs/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { InputFieldsDemo } from '../components/InputFieldsDemo'; - -export default function InputFieldsDemoPage() { - return ; -} diff --git a/src/app/demo/modals/page.tsx b/src/app/demo/modals/page.tsx deleted file mode 100644 index 7a46114e..00000000 --- a/src/app/demo/modals/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { XModalDemo } from '../components/XModalDemo'; - -export default function ModalDemoPage() { - return ; -} diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx deleted file mode 100644 index 3522747d..00000000 --- a/src/app/demo/page.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import Link from 'next/link'; -import { XLogo } from '@/components/ui/icons'; - -export default function DemoIndexPage() { - return ( -
-
-
-
- -
-

- Component Library Demo -

-

- Explore our comprehensive collection of reusable UI components built - with clean architecture principles. All components follow X/Twitter - design patterns and are fully responsive. -

-
- -
- {/* Generic Auth Forms */} - -
-
- - - -
-
-

- Generic Auth Forms -

-

- Responsive authentication forms with modal/full-page modes. - Includes login, register, password reset, and custom form - examples. -

-
- Explore Forms → -
- - - {/* Button Components */} - -
-
- - - -
-
-

- Button Components -

-

- All button variants including social login, primary, secondary, - outline, and ghost styles with loading states. -

-
- View Buttons → -
- - - {/* Input Components */} - -
-
- - - - -
-
-

- Input Components -

-

- Input fields, select dropdowns, floating labels, password toggles, - character counters, and validation states. -

-
- View Inputs → -
- - - {/* Modal Components */} - -
-
- - - -
-
-

- Modal Components -

-

- Confirmation, info, error, success, warning, and form modals with - multiple size variations and interactive examples. -

-
- View Modals → -
- - - {/* User Card Component */} - -
-
- - - -
-
-

- User Card Component -

-

- Flexible user cards with customizable actions: Follow, Unfollow, - Block, Unblock, Mute, Unmute with avatar support. -

-
- View User Cards → -
- -
- - {/* Features Section */} -
-

- Key Features -

-
-
-
- - - -
-

- Responsive Design -

-

- Components automatically adapt to screen size with - modal/full-page modes -

-
-
-
- - - -
-

TypeScript

-

- Fully typed components with comprehensive interfaces and type - safety -

-
-
-
- - - -
-

Reusable

-

- Generic components that can be used anywhere in your project -

-
-
-
- - {/* Navigation */} -
-
- - Login Page - - - Register Page - - - Home - -
-
-
-
- ); -} diff --git a/src/app/demo/usercard/page.tsx b/src/app/demo/usercard/page.tsx deleted file mode 100644 index 1c1b7376..00000000 --- a/src/app/demo/usercard/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -// import { UserCardDemo } from '../components/UserCardDemo'; - -export default function UserCardPage() { - return
User Card Demo Page
; - // return ; -} diff --git a/src/app/explore/layout.tsx b/src/app/explore/layout.tsx index e23c2fea..30d44169 100644 --- a/src/app/explore/layout.tsx +++ b/src/app/explore/layout.tsx @@ -3,7 +3,7 @@ import LayoutWrapper from '@/features/layout/components/LayoutWrapper'; import { ReactNode } from 'react'; import { usePathname } from 'next/navigation'; -export default function Layout({ children }: { children: ReactNode }) { +export default function Layout({ children }: { readonly children: ReactNode }) { const pathname = usePathname(); const isOnExploreTabs = pathname?.startsWith('/explore/tabs'); diff --git a/src/app/explore/tabs/[tab]/page.tsx b/src/app/explore/tabs/[tab]/page.tsx index 18ad3572..2a6a556b 100644 --- a/src/app/explore/tabs/[tab]/page.tsx +++ b/src/app/explore/tabs/[tab]/page.tsx @@ -5,7 +5,11 @@ import { exploreTabs, FOR_YOU_TAB } from '@/features/explore/constants/tabs'; import { useActions } from '@/features/explore/store/useExploreStore'; import React, { useEffect } from 'react'; -export default function Page({ params }: { params: Promise<{ tab: string }> }) { +export default function Page({ + params, +}: { + readonly params: Promise<{ tab: string }>; +}) { const { tab } = React.use(params); const { selectTab, setSearchQuery } = useActions(); diff --git a/src/app/home/[full-tweet]/page.tsx b/src/app/home/[full-tweet]/page.tsx index f7c00c97..bb6d128b 100644 --- a/src/app/home/[full-tweet]/page.tsx +++ b/src/app/home/[full-tweet]/page.tsx @@ -3,7 +3,6 @@ import React from 'react'; import FullTweet from '@/features/tweets/components/FullTweet'; import { useParams } from 'next/navigation'; import { useTweetById } from '@/features/tweets/hooks/tweetQueries'; -import Loader from '@/components/generic/Loader'; function Page() { const id = Number(useParams()?.['full-tweet']); @@ -11,9 +10,6 @@ function Page() { const tweet = tweetQuery.data?.data[0] || null; const isError = tweetQuery.isError; - // if (tweetQuery.isLoading) { - // return ; - // } if (isError) return (
@@ -24,7 +20,6 @@ function Page() { return ( <> - {/* */} ); } diff --git a/src/app/home/layout.tsx b/src/app/home/layout.tsx index 4eaa4f6d..9362c116 100644 --- a/src/app/home/layout.tsx +++ b/src/app/home/layout.tsx @@ -1,12 +1,6 @@ import LayoutWrapper from '@/features/layout/components/LayoutWrapper'; import { ReactNode } from 'react'; -export default function layout({ - children, -}: { - children: ReactNode; - // compose: ReactNode; -}) { - // return <>{children}; +export default function layout({ children }: { children: ReactNode }) { return {children}; } diff --git a/src/app/i/foundmedia/search/page.tsx b/src/app/i/foundmedia/search/page.tsx index 1cbdb887..04d3f7a8 100644 --- a/src/app/i/foundmedia/search/page.tsx +++ b/src/app/i/foundmedia/search/page.tsx @@ -7,7 +7,6 @@ import { useEffect, useState } from 'react'; import Loader from '@/components/generic/Loader'; import LayoutWrapper from '@/features/layout/components/LayoutWrapper'; export default function Page() { - // const { open } = useGifACtions(); const router = useRouter(); const [isLoading, setIsLoading] = useState(true); useEffect( @@ -19,12 +18,11 @@ export default function Page() { 'navigation' )[0] as PerformanceNavigationTiming; if (navEntry?.type === 'reload') { - // setIsReload(true); handleOpenSchedule(); setIsLoading(false); } else router.push('/home'); }, - [open, router] + [router] ); if (isLoading) return ( diff --git a/src/app/interests/[interest]/[tab]/page.tsx b/src/app/interests/[interest]/[tab]/page.tsx index 927d12cf..49a9b41e 100644 --- a/src/app/interests/[interest]/[tab]/page.tsx +++ b/src/app/interests/[interest]/[tab]/page.tsx @@ -8,7 +8,7 @@ import React, { useEffect } from 'react'; export default function Page({ params, }: { - params: Promise<{ interest: string; tab: string }>; + readonly params: Promise<{ interest: string; tab: string }>; }) { const { tab, interest } = React.use(params); const { selectInterestTab, setInterest } = useActions(); diff --git a/src/app/interests/[interest]/page.tsx b/src/app/interests/[interest]/page.tsx index 30403337..b3200bad 100644 --- a/src/app/interests/[interest]/page.tsx +++ b/src/app/interests/[interest]/page.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; export default function Page({ params, }: { - params: Promise<{ interest: string }>; + readonly params: Promise<{ interest: string }>; }) { const { selectInterestTab, setInterest } = useActions(); const { interest } = React.use(params); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 074d5ebf..10e4949d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; -// import './globals.css'; import { Providers } from '@/lib/providers'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); @@ -31,7 +30,7 @@ export const metadata: Metadata = { export default function RootLayout({ children, }: { - children: React.ReactNode; + readonly children: React.ReactNode; }) { return ( diff --git a/src/app/messages/layout.tsx b/src/app/messages/layout.tsx index a6e934dc..f84f48a4 100644 --- a/src/app/messages/layout.tsx +++ b/src/app/messages/layout.tsx @@ -5,7 +5,7 @@ import LayoutWrapper from '@/features/layout/components/LayoutWrapper'; export default function MessagesRootLayout({ children, }: { - children: React.ReactNode; + readonly children: React.ReactNode; }) { const pathname = usePathname(); const isConversationView = pathname !== '/messages'; diff --git a/src/app/notifications/layout.tsx b/src/app/notifications/layout.tsx index b641428e..f8944b99 100644 --- a/src/app/notifications/layout.tsx +++ b/src/app/notifications/layout.tsx @@ -4,7 +4,7 @@ import { ReactNode } from 'react'; export default function NotificationsLayout({ children, }: { - children: ReactNode; + readonly children: ReactNode; }) { return {children}; } diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx index 95ef2315..6f79103d 100644 --- a/src/app/settings/layout.tsx +++ b/src/app/settings/layout.tsx @@ -4,7 +4,7 @@ import { SettingsLayout } from '@/features/settings/components'; export default function SettingsRootLayout({ children, }: { - children: React.ReactNode; + readonly children: React.ReactNode; }) { return ( diff --git a/src/app/test-backend/page.tsx b/src/app/test-backend/page.tsx deleted file mode 100644 index 923e806d..00000000 --- a/src/app/test-backend/page.tsx +++ /dev/null @@ -1,141 +0,0 @@ -'use client'; -import { useState } from 'react'; -import { - fetchConversations, - fetchMessages, - createMessage, -} from '@/features/messages/api/messages'; - -export default function BackendTestPage() { - const [results, setResults] = useState({}); - const [loading, setLoading] = useState(null); - - const runTest = async (testName: string, testFn: () => Promise) => { - setLoading(testName); - try { - const result = await testFn(); - setResults((prev: any) => ({ - ...prev, - [testName]: { status: 'success', data: result }, - })); - } catch (error: any) { - setResults((prev: any) => ({ - ...prev, - [testName]: { status: 'error', error: error.message }, - })); - } finally { - setLoading(null); - } - }; - - const tests = [ - { - name: 'GET /conversations', - description: 'Fetch all conversations', - fn: () => fetchConversations(), - }, - { - name: 'GET /conversations/1/messages', - description: 'Fetch messages for conversation 1', - fn: () => fetchMessages(1), - }, - { - name: 'POST /conversations/1/messages', - description: 'Send a test message', - fn: () => createMessage(1, 'Test message from frontend test page'), - }, - ]; - - return ( -
-
-

Backend API Test Page

-

- Test all messaging endpoints to verify backend fixes -

- -
- {tests.map((test) => ( -
-
-
-

{test.name}

-

{test.description}

-
- -
- - {results[test.name] && ( -
-
- - {results[test.name].status === 'success' - ? '✅ SUCCESS' - : '❌ FAILED'} - -
-
-                    {JSON.stringify(
-                      results[test.name].status === 'success'
-                        ? results[test.name].data
-                        : { error: results[test.name].error },
-                      null,
-                      2
-                    )}
-                  
-
- )} -
- ))} -
- -
-

✅ What Should Work:

-
    -
  • • GET /conversations → Returns array of conversations
  • -
  • • GET /conversations/1/messages → Returns array of messages
  • -
  • - • POST /conversations/1/messages → Creates and returns new message -
  • -
-
- -
-

⚠️ Common Issues:

-
    -
  • • 404 Error → Endpoint not implemented yet
  • -
  • • 401 Error → Not authenticated (login first)
  • -
  • • 500 Error → Backend server error
  • -
  • • CORS Error → Backend CORS config still has issues
  • -
-
- -
-

🧪 How to Use:

-
    -
  1. Make sure you're logged in (user ID 6)
  2. -
  3. Click "Run Test" on each endpoint
  4. -
  5. Check if status is SUCCESS (green) or FAILED (red)
  6. -
  7. Review the response data
  8. -
  9. Share results with backend team if any fail
  10. -
-
-
-
- ); -} diff --git a/src/app/tweet/page.tsx b/src/app/tweet/page.tsx deleted file mode 100644 index 5758e2c4..00000000 --- a/src/app/tweet/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import Tweet from '../../features/tweets/components/Tweet'; -function Page() { - const time = new Date(Date.now() - 250 * 30 * 10 * 100 * 1000 * 60); - const data = { - id: '1', - content: { - text: ' tweet to demonstrate the layout.', - image: '/Personal photo.jpeg', - }, - user: { - name: 'Omda Hancker', - username: '@mohamedemad', - avatar: '/apple.png', - bio: 'Developer at XYZ. Love coding and coffee.', - following: 150, - followers: '2.5K', - isVerified: true, - isFollowed: false, - }, - time: time, - Actions: { - replies: 2, - retweets: 4, - likes: 24, - bookmarks: 10, - views: '1.5K', - booked: true, - shared: false, - liked: false, - reposted: false, - }, - }; - return ( - <> - {/* */} - {/* - - */} - - ); -} - -export default Page; - -// merged with fulltweet branch diff --git a/src/components/generic/Avatar.tsx b/src/components/generic/Avatar.tsx index f9e6181c..9365514b 100644 --- a/src/components/generic/Avatar.tsx +++ b/src/components/generic/Avatar.tsx @@ -49,16 +49,20 @@ const Avatar = ({ const initial = getInitial(); - const positionStyle = - position === 'absolute' && !customPosition - ? { left: '12px', top: '80px', zIndex: 1 } - : position === 'absolute' - ? { zIndex: 1 } - : {}; + let positionStyle: React.CSSProperties = {}; + if (position === 'absolute' && !customPosition) { + positionStyle = { left: '12px', top: '80px', zIndex: 1 }; + } else if (position === 'absolute') { + positionStyle = { zIndex: 1 }; + } + + const borderClass = className.includes('border-') + ? className + : `border-2 sm:border-4 ${className}`; return (
void; - testId?: string; - menuClassName?: string; - triggerClassName?: string; - showBackdrop?: boolean; + readonly children: ReactNode; + readonly items: readonly DropdownItemType[]; + readonly onOpened?: (opened: boolean) => void; + readonly testId?: string; + readonly menuClassName?: string; + readonly triggerClassName?: string; + readonly showBackdrop?: boolean; }; export default function GenericDropdown({ @@ -52,16 +51,24 @@ export default function GenericDropdown({ }; return ( -
+
{/* Backdrop to prevent clicks from propagating */} {isOpened && showBackdrop && (
{ e.stopPropagation(); setIsOpened(false); }} + onKeyDown={(e) => { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + setIsOpened(false); + } + }} className="fixed inset-0 bg-transparent z-40 cursor-default pointer-events-auto" - style={{ pointerEvents: isOpened ? 'auto' : 'none' }} + aria-label="Close dropdown" /> )} { e.stopPropagation(); setIsOpened(true); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + setIsOpened(true); + } + }} + aria-haspopup="menu" + aria-expanded={isOpened} > {children} diff --git a/src/components/generic/GenericUserList.tsx b/src/components/generic/GenericUserList.tsx index 0525dcb1..94638058 100644 --- a/src/components/generic/GenericUserList.tsx +++ b/src/components/generic/GenericUserList.tsx @@ -16,8 +16,8 @@ interface UserListItem { } interface GenericUserListProps { - query: any; - 'data-testid'?: string; + readonly query: any; + readonly 'data-testid'?: string; } export default function GenericUserList({ diff --git a/src/components/generic/ImageModal.tsx b/src/components/generic/ImageModal.tsx index bc287674..79811b56 100644 --- a/src/components/generic/ImageModal.tsx +++ b/src/components/generic/ImageModal.tsx @@ -5,18 +5,18 @@ import Icon from '@/components/ui/home/Icon'; type MediaItem = { url: string; - type: string | 'image' | 'video'; + type: 'image' | 'video'; }; interface ImageModalProps { - isOpen: boolean; - onClose: (e: React.MouseEvent) => void; - media: MediaItem[]; - currentIndex: number; - onNext?: (e: React.MouseEvent) => void; - onPrev?: (e: React.MouseEvent) => void; - showNavigation?: boolean; - showCounter?: boolean; + readonly isOpen: boolean; + readonly onClose: (e: React.MouseEvent) => void; + readonly media: MediaItem[]; + readonly currentIndex: number; + readonly onNext?: (e: React.MouseEvent) => void; + readonly onPrev?: (e: React.MouseEvent) => void; + readonly showNavigation?: boolean; + readonly showCounter?: boolean; } export default function ImageModal({ @@ -38,8 +38,17 @@ export default function ImageModal({ return (
{ + if (e.key === 'Escape') { + onClose(e as any); + } + }} + aria-label="Image viewer" data-testid="image-modal" > {/* Close button */} @@ -95,8 +104,10 @@ export default function ImageModal({ {/* Image container */}
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} data-testid="image-modal-content" > {currentMedia?.type.toLowerCase() === 'image' ? ( @@ -122,7 +133,9 @@ export default function ImageModal({ src={currentMedia?.url} onClick={(e) => e.stopPropagation()} data-testid="image-modal-video" - /> + > + + )}
diff --git a/src/components/generic/Tabs.tsx b/src/components/generic/Tabs.tsx index 7ea9c845..bf784b8d 100644 --- a/src/components/generic/Tabs.tsx +++ b/src/components/generic/Tabs.tsx @@ -7,11 +7,11 @@ interface TabItem { } interface TabsProps { - tabs: TabItem[]; - selectedValue: string | number; - onClick: (value: string) => void; - height: string; - 'data-testid'?: string; + readonly tabs: TabItem[]; + readonly selectedValue: string | number; + readonly onClick: (value: string) => void; + readonly height: string; + readonly 'data-testid'?: string; } export default function Tabs({ diff --git a/src/components/generic/__tests__/Avatar.test.tsx b/src/components/generic/__tests__/Avatar.test.tsx new file mode 100644 index 00000000..a7dbee2b --- /dev/null +++ b/src/components/generic/__tests__/Avatar.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Avatar from '../Avatar'; + +describe('Avatar Component', () => { + it('should render avatar with image', () => { + render( + + ); + + const image = screen.getByAltText('Test User'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src'); + }); + + it('should render initial when no image is provided', () => { + render(); + + expect(screen.getByText('T')).toBeInTheDocument(); + }); + + it('should not render initial when name is not provided', () => { + const { container } = render(); + + expect(container.querySelector('span')).not.toBeInTheDocument(); + }); + + it('should apply correct size classes for xs size', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv).toHaveClass('w-[38px]', 'h-[38px]'); + }); + + it('should apply correct size classes for lg size', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv).toHaveClass('w-[100px]', 'h-[100px]'); + }); + + it('should apply absolute positioning by default', () => { + const { container } = render(); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv).toHaveClass('absolute'); + }); + + it('should apply relative positioning when specified', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv).toHaveClass('relative'); + }); + + it('should apply custom className', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv).toHaveClass('custom-border', 'border-8'); + }); + + it('should render children', () => { + render( + +
Child Content
+
+ ); + + expect(screen.getByTestId('child-element')).toBeInTheDocument(); + expect(screen.getByText('Child Content')).toBeInTheDocument(); + }); + + it('should apply default border classes when className does not include border', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv.className).toContain('border-2'); + expect(avatarDiv.className).toContain('sm:border-4'); + }); + + it('should use provided border class when className includes border', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv).toHaveClass('border-8'); + expect(avatarDiv.className).not.toContain('border-2'); + }); + + it('should apply custom position style when customPosition is true', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv.style.left).toBe(''); + expect(avatarDiv.style.top).toBe(''); + }); + + it('should apply default position style when position is absolute and customPosition is false', () => { + const { container } = render( + + ); + + const avatarDiv = container.firstChild as HTMLElement; + expect(avatarDiv.style.left).toBe('12px'); + expect(avatarDiv.style.top).toBe('80px'); + expect(avatarDiv.style.zIndex).toBe('1'); + }); +}); diff --git a/src/components/generic/__tests__/BlockBtn.test.tsx b/src/components/generic/__tests__/BlockBtn.test.tsx new file mode 100644 index 00000000..81845dcd --- /dev/null +++ b/src/components/generic/__tests__/BlockBtn.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@/test/test-utils'; +import BlockBtn from '../buttons/BlockBtn'; + +vi.mock('@/hooks/useInteractions', () => ({ + useInteractions: () => ({ + blockUser: vi.fn().mockResolvedValue(undefined), + unblockUser: vi.fn().mockResolvedValue(undefined), + isBlockLoading: false, + }), +})); + +vi.mock('@/components/ui/hoc/ConfirmModal', () => ({ + default: ({ + isOpen, + onConfirm, + title, + }: { + isOpen: boolean; + onConfirm: () => void; + title: string; + }) => + isOpen ? ( +
+
{title}
+ +
+ ) : null, +})); + +describe('BlockBtn Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render block button when not blocked', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button.textContent).toBe('Block'); + }); + + it('should render blocked button when blocked', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button.textContent).toBe('Blocked'); + }); + + it('should show "Unblock" on hover when already blocked', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.mouseEnter(button); + + await waitFor(() => { + expect(button.textContent).toBe('Unblock'); + }); + }); + + it('should show "Blocked" when mouse leaves after hover', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.mouseEnter(button); + await waitFor(() => expect(button.textContent).toBe('Unblock')); + + fireEvent.mouseLeave(button); + await waitFor(() => { + expect(button.textContent).toBe('Blocked'); + }); + }); + + it('should show confirmation modal when clicking block button', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument(); + expect(screen.getByText('Block user?')).toBeInTheDocument(); + }); + }); + + it('should show confirmation modal when clicking unblock button', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument(); + expect(screen.getByText('Unblock user?')).toBeInTheDocument(); + }); + }); + + it('should block user when confirmation is accepted', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument(); + }); + + const confirmButton = screen.getByTestId('confirm-button'); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(button.textContent).toBe('Blocked'); + }); + }); + + it('should unblock user when confirmation is accepted', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument(); + }); + + const confirmButton = screen.getByTestId('confirm-button'); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(button.textContent).toBe('Block'); + }); + }); + + it('should stop propagation when button is clicked', () => { + const mockParentClick = vi.fn(); + + render( +
+ +
+ ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(mockParentClick).not.toHaveBeenCalled(); + }); + + it('should update state when isBlocked prop changes', () => { + const { rerender } = render(); + + let button = screen.getByRole('button'); + expect(button.textContent).toBe('Block'); + + rerender(); + + button = screen.getByRole('button'); + expect(button.textContent).toBe('Blocked'); + }); +}); diff --git a/src/components/generic/__tests__/Cover.test.tsx b/src/components/generic/__tests__/Cover.test.tsx new file mode 100644 index 00000000..f4ee46db --- /dev/null +++ b/src/components/generic/__tests__/Cover.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Cover from '../Cover'; + +describe('Cover Component', () => { + it('should render with default styles when no cover image is provided', () => { + const { container } = render(); + + const coverDiv = container.firstChild as HTMLElement; + expect(coverDiv).toBeInTheDocument(); + expect(coverDiv.style.backgroundImage).toBe('none'); + expect(coverDiv.style.backgroundColor).toBe('rgb(51, 54, 57)'); + }); + + it('should render with cover image when provided', () => { + const imageUrl = 'https://example.com/cover.jpg'; + const { container } = render(); + + const coverDiv = container.firstChild as HTMLElement; + expect(coverDiv.style.backgroundImage).toBe(`url("${imageUrl}")`); + expect(coverDiv.style.backgroundSize).toBe('cover'); + expect(coverDiv.style.backgroundPosition).toBe('center center'); + }); + + it('should render children', () => { + render( + +
Child Content
+
+ ); + + expect(screen.getByTestId('child-element')).toBeInTheDocument(); + expect(screen.getByText('Child Content')).toBeInTheDocument(); + }); + + it('should apply custom className', () => { + const { container } = render(); + + const coverDiv = container.firstChild as HTMLElement; + expect(coverDiv).toHaveClass('custom-class'); + }); + + it('should have default height classes', () => { + const { container } = render(); + + const coverDiv = container.firstChild as HTMLElement; + expect(coverDiv).toHaveClass('h-[120px]', 'sm:h-[200px]'); + }); + + it('should have default padding classes', () => { + const { container } = render(); + + const coverDiv = container.firstChild as HTMLElement; + expect(coverDiv).toHaveClass('p-4', 'sm:p-8'); + }); + + it('should have full width', () => { + const { container } = render(); + + const coverDiv = container.firstChild as HTMLElement; + expect(coverDiv).toHaveClass('w-full'); + }); + + it('should be a relative positioned flex container', () => { + const { container } = render(); + + const coverDiv = container.firstChild as HTMLElement; + expect(coverDiv).toHaveClass('relative', 'flex', 'flex-row'); + }); +}); diff --git a/src/components/generic/__tests__/Dropdown.test.tsx b/src/components/generic/__tests__/Dropdown.test.tsx new file mode 100644 index 00000000..77327558 --- /dev/null +++ b/src/components/generic/__tests__/Dropdown.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import GenericDropdown, { DropdownItemType } from '../Dropdown'; + +// Mock @heroui/react components +vi.mock('@heroui/react', () => ({ + Dropdown: ({ children, onOpenChange }: any) => ( +
onOpenChange && onOpenChange(true)} + > + {children} +
+ ), + DropdownTrigger: ({ children }: any) => ( +
{children}
+ ), + DropdownMenu: ({ children, 'data-testid': testId }: any) => ( +
{children}
+ ), + DropdownItem: ({ children, onClick, 'data-testid': testId }: any) => ( + + ), +})); + +describe('GenericDropdown Component', () => { + const mockOnOpened = vi.fn(); + const mockItemClick = vi.fn(); + + const mockItems: DropdownItemType[] = [ + { + key: 'edit', + label: 'Edit', + onClick: mockItemClick, + }, + { + key: 'delete', + label: 'Delete', + color: 'danger', + onClick: mockItemClick, + }, + { + key: 'share', + label: 'Share', + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render trigger children', () => { + render( + + + + ); + + expect(screen.getByText('Open Menu')).toBeInTheDocument(); + }); + + it('should render all dropdown items', () => { + render( + + + + ); + + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + expect(screen.getByText('Share')).toBeInTheDocument(); + }); + + it('should call item onClick when item is clicked', () => { + render( + + + + ); + + fireEvent.click(screen.getByText('Edit')); + expect(mockItemClick).toHaveBeenCalled(); + }); + + it('should use custom testId when provided', () => { + render( + + + + ); + + expect(screen.getByTestId('custom-dropdown-menu')).toBeInTheDocument(); + }); + + it('should use default testId when not provided', () => { + render( + + + + ); + + expect(screen.getAllByTestId('dropdown').length).toBeGreaterThan(0); + }); + + it('should render item test ids correctly', () => { + render( + + + + ); + + expect(screen.getByTestId('test-dropdown-item-edit')).toBeInTheDocument(); + expect(screen.getByTestId('test-dropdown-item-delete')).toBeInTheDocument(); + expect(screen.getByTestId('test-dropdown-item-share')).toBeInTheDocument(); + }); + + it('should handle items without onClick', () => { + render( + + + + ); + + // Should not throw when clicking item without onClick + expect(() => { + fireEvent.click(screen.getByText('Share')); + }).not.toThrow(); + }); + + it('should apply custom menuClassName', () => { + render( + + + + ); + + expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument(); + }); + + it('should apply custom triggerClassName', () => { + const { container } = render( + + + + ); + + const spanElement = container.querySelector('span.custom-trigger-class'); + expect(spanElement).toBeDefined(); + expect(spanElement).not.toBeNull(); + }); +}); diff --git a/src/components/generic/__tests__/EditProfileAvatar.test.tsx b/src/components/generic/__tests__/EditProfileAvatar.test.tsx new file mode 100644 index 00000000..bd855bc2 --- /dev/null +++ b/src/components/generic/__tests__/EditProfileAvatar.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import EditProfileAvatar from '../components/EditProfileAvatar'; + +vi.mock('../Avatar', () => ({ + default: ({ + avatarImage, + className, + position, + customPosition, + children, + 'data-testid': testId, + }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock('@/components/ui/UploadImage', () => ({ + default: ({ + onFileSelect, + 'data-testid': testId, + }: { + onFileSelect: (file: File | null) => void; + 'data-testid': string; + }) => ( + + ), +})); + +describe('EditProfileAvatar Component', () => { + const mockOnFileSelect = vi.fn(); + + it('should render avatar with default props', () => { + render(); + + const avatar = screen.getByTestId('edit-profile-avatar'); + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('data-avatar-image', 'null'); + }); + + it('should render avatar with provided image', () => { + const avatarUrl = 'https://example.com/avatar.jpg'; + render( + + ); + + const avatar = screen.getByTestId('edit-profile-avatar'); + expect(avatar).toHaveAttribute('data-avatar-image', avatarUrl); + }); + + it('should render with correct avatar position', () => { + render(); + + const avatar = screen.getByTestId('edit-profile-avatar'); + expect(avatar).toHaveAttribute('data-position', 'absolute'); + expect(avatar).toHaveAttribute('data-custom-position', 'true'); + }); + + it('should render with correct avatar className', () => { + render(); + + const avatar = screen.getByTestId('edit-profile-avatar'); + expect(avatar).toHaveAttribute( + 'data-classname', + '-top-[66px] left-3 border-2' + ); + }); + + it('should render upload image button', () => { + render(); + + const uploadButton = screen.getByTestId('edit-profile-avatar-upload'); + expect(uploadButton).toBeInTheDocument(); + }); + + it('should call onFileSelect when file is selected', () => { + render(); + + const uploadButton = screen.getByTestId('edit-profile-avatar-upload'); + uploadButton.click(); + + expect(mockOnFileSelect).toHaveBeenCalled(); + const callArgs = mockOnFileSelect.mock.calls[0][0]; + expect(callArgs).toBeInstanceOf(File); + expect(callArgs.name).toBe('test.jpg'); + }); + + it('should render upload button inside centered div', () => { + const { container } = render( + + ); + + const centeredDiv = container.querySelector( + '.absolute.top-1\\/2.left-1\\/2.transform.-translate-x-1\\/2.-translate-y-1\\/2' + ); + expect(centeredDiv).toBeInTheDocument(); + }); +}); diff --git a/src/components/generic/__tests__/EditProfileCover.test.tsx b/src/components/generic/__tests__/EditProfileCover.test.tsx new file mode 100644 index 00000000..067e57f5 --- /dev/null +++ b/src/components/generic/__tests__/EditProfileCover.test.tsx @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import EditProfileCover from '../components/EditProfileCover'; + +vi.mock('../Cover', () => ({ + default: ({ + coverImage, + className, + children, + 'data-testid': testId, + }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock('@/components/ui/UploadImage', () => ({ + default: ({ + onFileSelect, + showClearButton, + onClear, + 'data-testid': testId, + }: { + onFileSelect: (file: File | null) => void; + showClearButton: boolean; + onClear: () => void; + 'data-testid': string; + }) => ( +
+ + {showClearButton && ( + + )} +
+ ), +})); + +describe('EditProfileCover Component', () => { + const mockOnFileSelect = vi.fn(); + const mockOnClear = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render cover with default props', () => { + render( + + ); + + const cover = screen.getByTestId('edit-profile-cover'); + expect(cover).toBeInTheDocument(); + expect(cover).toHaveAttribute('data-cover-image', 'none'); + }); + + it('should render cover with provided image', () => { + const coverUrl = 'https://example.com/cover.jpg'; + render( + + ); + + const cover = screen.getByTestId('edit-profile-cover'); + expect(cover).toHaveAttribute('data-cover-image', coverUrl); + }); + + it('should render with correct className', () => { + render( + + ); + + const cover = screen.getByTestId('edit-profile-cover'); + expect(cover).toHaveAttribute('data-classname', 'mt-4'); + }); + + it('should render upload image button', () => { + render( + + ); + + const uploadButton = screen.getByTestId('edit-profile-cover-upload'); + expect(uploadButton).toBeInTheDocument(); + }); + + it('should call onFileSelect when file is selected', () => { + render( + + ); + + const uploadButton = screen.getByTestId('edit-profile-cover-upload'); + fireEvent.click(uploadButton); + + expect(mockOnFileSelect).toHaveBeenCalled(); + const callArgs = mockOnFileSelect.mock.calls[0][0]; + expect(callArgs).toBeInstanceOf(File); + expect(callArgs.name).toBe('cover.jpg'); + }); + + it('should not show clear button when showClearButton is false', () => { + render( + + ); + + const clearButton = screen.queryByTestId('edit-profile-cover-upload-clear'); + expect(clearButton).not.toBeInTheDocument(); + }); + + it('should show clear button when showClearButton is true', () => { + render( + + ); + + const clearButton = screen.getByTestId('edit-profile-cover-upload-clear'); + expect(clearButton).toBeInTheDocument(); + }); + + it('should call onClear when clear button is clicked', () => { + render( + + ); + + const clearButton = screen.getByTestId('edit-profile-cover-upload-clear'); + fireEvent.click(clearButton); + + expect(mockOnClear).toHaveBeenCalled(); + }); + + it('should render upload button inside centered div', () => { + const { container } = render( + + ); + + const centeredDiv = container.querySelector( + '.absolute.top-1\\/2.left-1\\/2.transform.-translate-x-1\\/2.-translate-y-1\\/2' + ); + expect(centeredDiv).toBeInTheDocument(); + }); +}); diff --git a/src/components/generic/__tests__/EditProfileForm.test.tsx b/src/components/generic/__tests__/EditProfileForm.test.tsx new file mode 100644 index 00000000..00a54d3f --- /dev/null +++ b/src/components/generic/__tests__/EditProfileForm.test.tsx @@ -0,0 +1,401 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import EditProfileForm from '../components/EditProfileForm'; + +vi.mock('@/components/ui/input/InputField', () => ({ + InputField: ({ + label, + value, + onChange, + maxLength, + showCharCount, + error, + type, + 'data-testid': testId, + }: any) => ( +
+ + {type === 'textarea' ? ( +