diff --git a/README.md b/README.md index e2a808508..c2c9a60aa 100644 --- a/README.md +++ b/README.md @@ -1 +1,261 @@ -# Cross Platform +
+ Raven Logo + +# Raven Mobile App + +**Caw Your Thoughts** + +A modern, cross-platform social media application built with React Native and Expo. + +[![React Native](https://img.shields.io/badge/React%20Native-0.81.5-61DAFB?logo=react)](https://reactnative.dev/) +[![Expo](https://img.shields.io/badge/Expo-SDK%2054-000020?logo=expo)](https://expo.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript)](https://www.typescriptlang.org/) + +
+ +## Features + +### Authentication & Security + +- **Email/Password Authentication** - Secure sign-up and sign-in with email verification +- **OAuth Integration** - Third-party authentication support +- **Password Recovery** - Forgot password flow with email-based reset +- **Multi-Account Support** - Account switcher for managing multiple profiles +- **Secure Token Storage** - Uses Expo Secure Store for sensitive data + +### Timeline & Feed + +- **Home Timeline** - Personalized feed with posts from followed users +- **Real-time Updates** - Live feed updates via Server-Sent Events (SSE) +- **Infinite Scrolling** - Cursor-based pagination for smooth scrolling +- **Pull-to-Refresh** - Refresh feed with pull gesture + +### Tweets/Posts + +- **Tweet Composer** - Rich text editor with support for: + - Hashtag and mention highlighting + - Emoji picker integration + - Media attachments (up to 4 images/videos per tweet) +- **Quote Tweets** - Retweet with your own commentary +- **Reply Threads** - Nested conversation threads +- **Tweet Actions** - Like, retweet, quote, and reply +- **Media Grid** - Responsive media display for images and videos + +### Direct Messaging + +- **Real-time Chat** - Instant messaging with Socket.IO +- **Message Reactions** - React to messages with emojis +- **Image Sharing** - Send and view images in conversations +- **Message Deletion** - Delete sent messages +- **Conversation List** - View all active conversations + +### Explore & Discovery + +- **For You** - Curated content discovery +- **Trending Topics** - Popular hashtags and trends +- **Category Sections** - Browse by category (News, Sports, Entertainment) +- **User Search** - Find users by name or username +- **Tweet Search** - Search content with filters + +### Search + +- **Advanced Filters** - Filter by users, tweets, hashtags +- **Recent Searches** - Quick access to search history +- **Real-time Results** - Instant search suggestions + +### Profile Management + +- **Profile Customization** - Edit display name, bio, location, website +- **Profile Picture** - Upload and crop profile images +- **Header Image** - Custom profile banner +- **Following/Followers** - View and manage connections +- **Profile Tabs** - View tweets, replies, media, and likes + +### Notifications + +- **Push Notifications** - Firebase Cloud Messaging integration +- **In-App Notifications** - Real-time notification feed +- **Notification Types** - Likes, retweets, mentions, follows, replies + +### Settings & Privacy + +- **Appearance** - Theme customization (light/dark mode) +- **Account Settings** - Manage account information +- **Privacy Controls**: + - Muted accounts management + - Blocked accounts management + - Content preferences + - Interest customization + +## Tech Stack + +### Core + +- **React Native** 0.81.5 +- **Expo** SDK 54 +- **TypeScript** 5.9 + +### State Management + +- **Zustand** - Lightweight state management +- **TanStack Query** (React Query) - Server state and caching + +### Styling + +- **NativeWind** 4 - TailwindCSS for React Native +- **TailwindCSS** 3.4 + +### Navigation + +- **React Navigation** 7 - Stack, Tab, Drawer, and Material Top Tabs + +### Real-time + +- **Socket.IO Client** - WebSocket-based real-time messaging +- **React Native SSE** - Server-Sent Events for live updates + +### Media + +- **Expo Image** - High-performance image component +- **Expo Image Picker** - Camera and gallery access +- **Expo Video** - Video playback with Picture-in-Picture +- **Expo Media Library** - Save and access device media + +### Testing + +- **Jest** - Unit testing framework +- **Testing Library** - React Native testing utilities +- **WebdriverIO** - End-to-end testing +- **Appium** - Mobile automation +- **MSW** - API mocking + +### Other Notable Libraries + +- **Shopify FlashList** - High-performance lists +- **Lucide Icons** - Modern icon set +- **React Native Gesture Handler** - Touch gestures +- **React Native Popover View** - Contextual menus +- **RN Emoji Keyboard** - Native emoji picker + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- pnpm +- Expo CLI +- Android Studio (for Android development) +- Xcode (for iOS development, macOS only) + +### Installation + +1. Clone the repository: + + ```bash + git clone https://github.com/Exo1i/raven-cross-platform + cd raven + ``` + +2. Install dependencies: + + ```bash + pnpm install + ``` + +3. Set up environment variables: + + ```bash + cp .env.example .env + ``` + + Edit `.env` with your configuration values. + +4. Start the development server: + + ```bash + pnpm start + ``` + +### Running on Devices + +```bash +# Android +pnpm android + +# iOS +pnpm ios +``` + +### Building + +Debug builds: + +```bash +./build-debug.sh +``` + +Release builds: + +```bash +./build-release.sh +``` + +## Scripts + +| Command | Description | +| --------------- | ------------------------------ | +| `pnpm start` | Start Expo development server | +| `pnpm android` | Run on Android device/emulator | +| `pnpm ios` | Run on iOS device/simulator | +| `pnpm web` | Run in web browser | +| `pnpm test` | Run tests in watch mode | +| `pnpm test:cov` | Run tests with coverage | +| `pnpm e2e` | Run end-to-end tests | + +## Configuration + +### EAS Build + +The project uses Expo Application Services (EAS) for building. Configuration is in `eas.json`: + +- **Development** - Debug builds for development +- **Preview** - Internal testing builds +- **Production** - Release builds for app stores + +### Firebase + +Push notifications require Firebase configuration: + +1. Create a Firebase project +2. Add `google-services.json` for Android +3. Set `GOOGLE_SERVICES_JSON` environment variable for CI/CD + +## Licenses + +### Fonts + +- **Inter** - The app uses the [Inter font family](https://fonts.google.com/specimen/Inter) by Rasmus Andersson, licensed under the [SIL Open Font License 1.1](https://scripts.sil.org/OFL). + +### Icons + +- **Lucide Icons** - Licensed under the [ISC License](https://github.com/lucide-icons/lucide/blob/main/LICENSE). +- **Ionicons** - Licensed under the [MIT License](https://github.com/ionic-team/ionicons/blob/main/LICENSE). +- **Expo Vector Icons** - A collection of icon sets, each with their respective licenses. + +### Images + +- App logo and splash screen images are original assets created for this project. +- User-uploaded content is subject to the platform's terms of service. + +### Code Quality + +The project enforces code quality with: + +- **ESLint** - Linting with Expo and TypeScript rules +- **Prettier** - Code formatting +- **Husky** - Git hooks for pre-commit checks +- **TypeScript** - Strict type checking + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/app.config.js b/app.config.js index c7932c61b..3012281ee 100644 --- a/app.config.js +++ b/app.config.js @@ -39,7 +39,6 @@ export default ({ config }) => ({ plugins: [ 'expo-secure-store', - 'expo-localization', [ 'expo-media-library', { diff --git a/babel.config.js b/babel.config.js index 7d507e111..657159fd6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,5 +2,10 @@ module.exports = function (api) { api.cache(true); return { presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'], + env: { + test: { + plugins: ['dynamic-import-node'], + }, + }, }; }; diff --git a/e2e/pageobjects/Explore/ExploreScreen.page.js b/e2e/pageobjects/Explore/ExploreScreen.page.js new file mode 100644 index 000000000..1ed0d6829 --- /dev/null +++ b/e2e/pageobjects/Explore/ExploreScreen.page.js @@ -0,0 +1,162 @@ +import { $, $$, driver } from '@wdio/globals'; + +class ExploreScreen { + // Tab navigation + get forYouTab() { + return $('~ForYouTab'); + } + + get trendingTab() { + return $('~TrendingTab'); + } + + get newsTab() { + return $('~NewsTab'); + } + + get sportsTab() { + return $('~SportsTab'); + } + + get entertainmentTab() { + return $('~EntertainmentTab'); + } + + // Search trigger from Explore header + get searchTrigger() { + return $('~explore-search-trigger'); + } + + // Lists + get forYouFlashList() { + return $('~for-you-explore-flashlist'); + } + + get trendingList() { + return $('~trending-list'); + } + + get newsList() { + return $('~news-list'); + } + + // Bottom tab bar + get exploreTabBar() { + return $('~ExploreTab'); + } + + get homeTabBar() { + return $('~HomeTab'); + } + + // Helper methods + async switchToForYouTab() { + await this.forYouTab.waitForDisplayed(); + await this.forYouTab.click(); + await driver.pause(500); + } + + async switchToTrendingTab() { + await this.trendingTab.waitForDisplayed(); + await this.trendingTab.click(); + await driver.pause(500); + } + + async switchToNewsTab() { + await this.newsTab.waitForDisplayed(); + await this.newsTab.click(); + await driver.pause(500); + } + + async switchToSportsTab() { + await this.sportsTab.waitForDisplayed(); + await this.sportsTab.click(); + await driver.pause(500); + } + + async switchToEntertainmentTab() { + await this.entertainmentTab.waitForDisplayed(); + await this.entertainmentTab.click(); + await driver.pause(500); + } + + async openSearch() { + await this.searchTrigger.waitForDisplayed(); + await this.searchTrigger.click(); + await driver.pause(500); + } + + async goToHome() { + await this.homeTabBar.waitForDisplayed(); + await this.homeTabBar.click(); + await driver.pause(300); + } + + async refresh() { + const element = await this.forYouFlashList; + await driver.execute('mobile: swipeGesture', { + elementId: element.elementId, + direction: 'down', + percent: 0.5, + }); + await driver.pause(1500); + } + + async getTrendCardByHashtag(hashtag) { + const normalizedHashtag = hashtag.replace('#', ''); + return $(`~trend-card-${normalizedHashtag}`); + } + + async clickTrendCard(hashtag) { + const card = await this.getTrendCardByHashtag(hashtag); + await card.waitForDisplayed(); + await card.click(); + await driver.pause(500); + } + + async isForYouTabDisplayed() { + try { + return await this.forYouTab.isDisplayed(); + } catch { + return false; + } + } + + async isTrendingTabDisplayed() { + try { + return await this.trendingTab.isDisplayed(); + } catch { + return false; + } + } + + async waitForContentToLoad() { + // Simple wait for content to load + await driver.pause(1500); + } + + async clickFirstTrendCard() { + try { + const trendCards = await $$('[accessibilityLabel^="trend-card-"]'); + if (trendCards.length > 0) { + await trendCards[0].click(); + await driver.pause(500); + return true; + } + } catch { + return false; + } + return false; + } + + async isTrendCardDisplayed(hashtag) { + try { + const card = await this.getTrendCardByHashtag(hashtag); + return await card.isDisplayed(); + } catch { + return false; + } + } +} + +export default new ExploreScreen(); diff --git a/e2e/pageobjects/Home pages/ForYouScreen.page.js b/e2e/pageobjects/Home pages/ForYouScreen.page.js deleted file mode 100644 index 13dfff81f..000000000 --- a/e2e/pageobjects/Home pages/ForYouScreen.page.js +++ /dev/null @@ -1,29 +0,0 @@ -import { $ } from '@wdio/globals'; - -class ForYouScreen { - get forYouTab() { - return $('android=new UiSelector().text("For You")'); - } - get followingTab() { - return $('android=new UiSelector().text("Following")'); - } - get sideMenuButton() { - return $('~open-drawer-button'); - } - - async openSideMenu() { - await this.sideMenuButton.waitForDisplayed(); - await this.sideMenuButton.click(); - } - - async switchToFollowingTab() { - await this.followingTab.waitForDisplayed(); - await this.followingTab.click(); - } - - async isDisplayed() { - return (await this.forYouTab.isDisplayed()) && (await this.followingTab.isDisplayed()); - } -} - -export default new ForYouScreen(); diff --git a/e2e/pageobjects/Home/FollowingScreen.page.js b/e2e/pageobjects/Home/FollowingScreen.page.js new file mode 100644 index 000000000..a2017ae35 --- /dev/null +++ b/e2e/pageobjects/Home/FollowingScreen.page.js @@ -0,0 +1,144 @@ +import { $, $$ } from '@wdio/globals'; + +class FollowingScreen { + get screenTitle() { + return $('android=new UiSelector().text("Following")'); + } + + get sideMenuButton() { + return $('~open-drawer-button'); + } + + get newTweetButton() { + return $('~new-tweet-button'); + } + + get aiSummaryButton() { + return $$('~ai-summary-button'); + } + + get tweetReplyButton() { + return $$('~reply-button'); + } + + get tweetRetweetButton() { + return $$('~retweet-button'); + } + + get tweetLikeButton() { + return $$('~like-button'); + } + + get tweetLikeCount() { + return $$('~like-count'); + } + + get tweetReplyCount() { + return $$('~reply-count'); + } + + get tweetRetweetCount() { + return $$('~retweet-count'); + } + + get tweetDrawerButton() { + return $$('~tweet-drawer-button'); + } + + get tweetCard() { + return $$('~tweet-card'); + } + + get aiSummaryText() { + return $$('~ai-summary-text'); + } + + get tweetContent() { + return $$('~tweet-content'); + } + + get tweetMention() { + return $$('~tweet-mention'); + } + + get tweetHashtag() { + return $$('~tweet-hashtag'); + } + + get tweetLink() { + return $$('~tweet-link'); + } + + get tweetDeleteButton() { + return $('android=new UiSelector().text("Delete post")'); + } + + get deleteTweetModal() { + return $('android=new UiSelector().text("Delete post?")'); + } + + get confirmDeleteTweetButton() { + return $('~block-button'); + } + + get cancelDeleteTweetButton() { + return $('~cancel-block-button'); + } + + get repostFromDrawerButton() { + return $('android=new UiSelector().text("Repost")'); + } + + get quoteFromDrawerButton() { + return $('android=new UiSelector().text("Quote")'); + } + + get undoRepostFromDrawerButton() { + return $('android=new UiSelector().text("Undo Repost")'); + } + + async chooseRepostFromDrawer() { + await this.repostFromDrawerButton.waitForDisplayed(); + await this.repostFromDrawerButton.click(); + } + + async chooseUndoRepostFromDrawer() { + await this.undoRepostFromDrawerButton.waitForDisplayed(); + await this.undoRepostFromDrawerButton.click(); + } + + async chooseQuoteFromDrawer() { + await this.quoteFromDrawerButton.waitForDisplayed(); + await this.quoteFromDrawerButton.click(); + } + + async clickNewTweetButton() { + await this.newTweetButton.waitForDisplayed(); + await this.newTweetButton.click(); + } + + async deleteTweet() { + await this.tweetDeleteButton.waitForDisplayed(); + await this.tweetDeleteButton.click(); + } + + async isDeleteTweetModalDisplayed() { + return this.deleteTweetModal.isDisplayed(); + } + + async confirmDeleteTweet() { + await this.confirmDeleteTweetButton.waitForDisplayed(); + await this.confirmDeleteTweetButton.click(); + } + + async cancelDeleteTweet() { + await this.cancelDeleteTweetButton.waitForDisplayed(); + await this.cancelDeleteTweetButton.click(); + } + + async isDisplayed() { + return this.screenTitle.isDisplayed(); + } +} + +export default new FollowingScreen(); diff --git a/e2e/pageobjects/Home/ForYouScreen.page.js b/e2e/pageobjects/Home/ForYouScreen.page.js new file mode 100644 index 000000000..834255cf1 --- /dev/null +++ b/e2e/pageobjects/Home/ForYouScreen.page.js @@ -0,0 +1,61 @@ +import { $ } from '@wdio/globals'; + +class ForYouScreen { + get forYouTab() { + return $('android=new UiSelector().text("For You")'); + } + get followingTab() { + return $('android=new UiSelector().text("Following")'); + } + get sideMenuButton() { + return $('~open-drawer-button'); + } + get notificationsTab() { + return $('~NotificationsTab'); + } + get messagingTab() { + return $('~MessagesTab'); + } + get exploreTab() { + return $('~ExploreTab'); + } + get newTweetButton() { + return $('~new-tweet-button'); + } + + async switchToNotificationsTab() { + await this.notificationsTab.waitForDisplayed(); + await this.notificationsTab.click(); + } + + async switchToMessagingTab() { + await this.messagingTab.waitForDisplayed(); + await this.messagingTab.click(); + } + + async openSideMenu() { + await this.sideMenuButton.waitForDisplayed(); + await this.sideMenuButton.click(); + } + + async switchToFollowingTab() { + await this.followingTab.waitForDisplayed(); + await this.followingTab.click(); + } + + async switchToExploreTab() { + await this.exploreTab.waitForDisplayed(); + await this.exploreTab.click(); + } + + async isDisplayed() { + return (await this.forYouTab.isDisplayed()) && (await this.followingTab.isDisplayed()); + } + + async clickNewTweetButton() { + await this.newTweetButton.waitForDisplayed(); + await this.newTweetButton.click(); + } +} + +export default new ForYouScreen(); diff --git a/e2e/pageobjects/Home pages/SidebarScreen.page.js b/e2e/pageobjects/Home/SidebarScreen.page.js similarity index 51% rename from e2e/pageobjects/Home pages/SidebarScreen.page.js rename to e2e/pageobjects/Home/SidebarScreen.page.js index 58771ae72..cee6a1ca2 100644 --- a/e2e/pageobjects/Home pages/SidebarScreen.page.js +++ b/e2e/pageobjects/Home/SidebarScreen.page.js @@ -1,4 +1,4 @@ -import { $ } from '@wdio/globals'; +import { $, driver } from '@wdio/globals'; class SidebarScreen { get logoutButton() { @@ -25,6 +25,55 @@ class SidebarScreen { get currentUsername() { return $('~sidebar-current-username'); } + get followingCount() { + return $('~sidebar-following-count'); + } + get followersCount() { + return $('~sidebar-followers-count'); + } + get themeSwitcherButton() { + return $('~theme-switcher-drawer'); + } + get darkThemeButton() { + return $('android=new UiSelector().text("On")'); + } + get lightThemeButton() { + return $('android=new UiSelector().text("Off")'); + } + get systemThemeButton() { + return $('android=new UiSelector().text("Use device settings")'); + } + + async openThemeSwitcher() { + await this.themeSwitcherButton.waitForDisplayed(); + await this.themeSwitcherButton.click(); + } + + async chooseDarkTheme() { + await this.darkThemeButton.waitForDisplayed(); + await this.darkThemeButton.click(); + } + + async chooseLightTheme() { + await this.lightThemeButton.waitForDisplayed(); + await this.lightThemeButton.click(); + } + + async chooseSystemTheme() { + await this.systemThemeButton.waitForDisplayed(); + await this.systemThemeButton.click(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + get followingButton() { + return $('~sidebar-following-button'); + } + get followersButton() { + return $('~sidebar-followers-button'); + } async logout() { await this.logoutButton.waitForDisplayed(); @@ -51,6 +100,23 @@ class SidebarScreen { await this.profileButton.click(); } + async goToChat() { + await this.chatButton.waitForDisplayed(); + await this.chatButton.click(); + } + + async goToFollowing() { + await this.followingButton.waitForDisplayed(); + await this.followingButton.click(); + await driver.pause(500); + } + + async goToFollowers() { + await this.followersButton.waitForDisplayed(); + await this.followersButton.click(); + await driver.pause(500); + } + async isDisplayed() { return ( (await this.logoutButton.isDisplayed()) && diff --git a/e2e/pageobjects/Login pages/LoginEmail.page.js b/e2e/pageobjects/Login/LoginEmail.page.js similarity index 100% rename from e2e/pageobjects/Login pages/LoginEmail.page.js rename to e2e/pageobjects/Login/LoginEmail.page.js diff --git a/e2e/pageobjects/Login pages/LoginPassword.page.js b/e2e/pageobjects/Login/LoginPassword.page.js similarity index 100% rename from e2e/pageobjects/Login pages/LoginPassword.page.js rename to e2e/pageobjects/Login/LoginPassword.page.js diff --git a/e2e/pageobjects/Messages/ChatScreen.page.js b/e2e/pageobjects/Messages/ChatScreen.page.js new file mode 100644 index 000000000..f78777199 --- /dev/null +++ b/e2e/pageobjects/Messages/ChatScreen.page.js @@ -0,0 +1,111 @@ +import { $, $$, browser, driver } from '@wdio/globals'; + +class ChatScreen { + get noMessagesText() { + return $('android=new UiSelector().text("No messages yet. Start the conversation.")'); + } + + get messageInput() { + return $('~chat-input'); + } + + get sendMessageButton() { + return $('~send-message-button'); + } + + get scrollBottomButton() { + return $('~scroll-bottom-chat'); + } + + get messageContent() { + return $$('~message-content'); + } + + get messageSent() { + return $('android=new UiSelector().textContains("Sent")'); + } + + async isNoMessagesYetDisplayed() { + return this.noMessagesText.isDisplayed(); + } + + async isCorrectUsernameDisplayed(username) { + const usernameSelector = $(`android=new UiSelector().text("${username}")`); + await usernameSelector.waitForDisplayed(); + return usernameSelector.isDisplayed(); + } + + async isCorrectDisplayNameDisplayed(displayName) { + const displayNameSelector = $(`android=new UiSelector().text("${displayName}")`); + await displayNameSelector.waitForDisplayed(); + return displayNameSelector.isDisplayed(); + } + + async typeMessage(message) { + await this.messageInput.waitForDisplayed(); + await this.messageInput.setValue(message); + } + + async sendMessage() { + await this.sendMessageButton.waitForDisplayed(); + await this.sendMessageButton.click(); + } + + async scrollToBottom() { + await this.scrollBottomButton.waitForDisplayed(); + await this.scrollBottomButton.click(); + } + + async isScrollBottomDisplayed() { + return this.scrollBottomButton.isDisplayed(); + } + + async isMessageDisplayed(message) { + const messageSelector = $(`android=new UiSelector().text("${message}")`); + await messageSelector.waitForDisplayed(); + return messageSelector.isDisplayed(); + } + + async isMessageSentDisplayed() { + await this.messageSent.waitForDisplayed(); + return this.messageSent.isDisplayed(); + } + + async goToUserProfile(displayName) { + const displayNameSelector = $(`android=new UiSelector().text("${displayName}")`); + await displayNameSelector.waitForDisplayed(); + await displayNameSelector.click(); + } + + async scrollUp() { + const { width, height } = await browser.getWindowRect(); + + const anchorX = width * 0.5; + const startY = height * 0.2; + const endY = height * 0.7; + + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: anchorX, y: startY }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 100 }, + { type: 'pointerMove', duration: 800, x: anchorX, y: endY }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + + await browser.pause(800); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } +} + +export default new ChatScreen(); diff --git a/e2e/pageobjects/Messages/MessagesScreen.page.js b/e2e/pageobjects/Messages/MessagesScreen.page.js new file mode 100644 index 000000000..8b0374f1b --- /dev/null +++ b/e2e/pageobjects/Messages/MessagesScreen.page.js @@ -0,0 +1,117 @@ +import { $, $$, browser, driver } from '@wdio/globals'; + +class MessagesScreen { + get screenTitle() { + return $('android=new UiSelector().text("Messages")'); + } + + get noConvosMessage() { + return $('android=new UiSelector().text("No conversations")'); + } + + get newMessageButton() { + return $('~new-message-button'); + } + + get cancelNewMessageButton() { + return $('android=new UiSelector().text("Cancel")'); + } + + get newMessageSearchInput() { + return $('~new-message-search-input'); + } + + get newMessageBackdrop() { + return $('~new-message-backdrop'); + } + + get newMessageRecipients() { + return $$('~new-message-recipient'); + } + + get recipientDisplayNames() { + return $$('~recipient-display-name'); + } + + get recipientUsernames() { + return $$('~recipient-username'); + } + + get sideMenuButton() { + return $('~open-drawer-button'); + } + + get noUsersFound() { + return $('android=new UiSelector().text("No users found")'); + } + + get conversations() { + return $$('~conversation-list-item'); + } + + get conversationUsernames() { + return $$('~conversation-username'); + } + + get conversationPreviews() { + return $$('~conversation-preview'); + } + + get conversationUnreadIndicator() { + return $$('~conversation-unread'); + } + + get badgeCount() { + return $('~badge-count'); + } + + async openSideMenu() { + await this.sideMenuButton.waitForDisplayed(); + await this.sideMenuButton.click(); + } + + async clickNewMessageButton() { + await this.newMessageButton.waitForDisplayed(); + await this.newMessageButton.click(); + } + + async cancelNewMessage() { + await this.cancelNewMessageButton.waitForDisplayed(); + await this.cancelNewMessageButton.click(); + } + + async searchFor(username) { + await this.newMessageSearchInput.waitForDisplayed(); + await this.newMessageSearchInput.setValue(username); + } + + async refresh() { + await browser.swipe({ + direction: 'down', + percent: 1, // 100% + }); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async isNewMessageDrawerDisplayed() { + return ( + this.newMessageBackdrop.isDisplayed() && + this.newMessageSearchInput.isDisplayed() && + this.cancelNewMessageButton.isDisplayed() + ); + } + + async isNoUsersFoundDisplayed() { + return this.noUsersFound.waitForDisplayed(); + } + + async isNoConversationsMessageDisplayed() { + return this.noConvosMessage.isDisplayed(); + } +} + +export default new MessagesScreen(); diff --git a/e2e/pageobjects/Notifications/AllNotificationsScreen.page.js b/e2e/pageobjects/Notifications/AllNotificationsScreen.page.js new file mode 100644 index 000000000..b0da20342 --- /dev/null +++ b/e2e/pageobjects/Notifications/AllNotificationsScreen.page.js @@ -0,0 +1,102 @@ +import { $, $$, browser } from '@wdio/globals'; + +class AllNotificationsScreen { + get screenTitle() { + return $('android=new UiSelector().text("Notifications")'); + } + + get replyNotifications() { + return $$('android=new UiSelector().textContains("replied")'); + } + + get quoteNotifications() { + return $$('android=new UiSelector().textContains("quoted")'); + } + + get retweetNotifications() { + return $$('android=new UiSelector().textContains("retweeted")'); + } + + get likeNotifications() { + return $$('android=new UiSelector().textContains("liked")'); + } + + get mentionNotifications() { + return $$('android=new UiSelector().textContains("mentioned")'); + } + + get followNotifications() { + return $$('android=new UiSelector().textContains("followed")'); + } + + get mentionsTab() { + return $('android=new UiSelector().text("Mentions")'); + } + + get noNotificationsMessage() { + return $('android=new UiSelector().text("You\'re all caught up")'); + } + + get followBackButton() { + return $$('android=new UiSelector().text("Follow back")'); + } + + get followingButton() { + return $$('android=new UiSelector().text("Following")'); + } + + get followNotifUserDisplayName() { + return $$('~follow-notification-user-displayname'); + } + + get replyIcon() { + return $$('~notification-reply-button'); + } + + get retweetIcon() { + return $$('~notification-retweet-button'); + } + + get likeIcon() { + return $$('~notification-like-button'); + } + + get replyCount() { + return $$('~notification-reply-count'); + } + + get retweetCount() { + return $$('~notification-retweet-count'); + } + + get likeCount() { + return $$('~notification-like-count'); + } + + get homeIcon() { + return $('~HomeTab'); + } + + async isNoNotificationsMessageDisplayed() { + return await this.noNotificationsMessage.isDisplayed(); + } + + async goToMentionsTab() { + await this.mentionsTab.waitForDisplayed(); + await this.mentionsTab.click(); + } + + async refresh() { + await browser.swipe({ + direction: 'down', + percent: 1, // 100% + }); + } + + async goToForYouScreen() { + await this.homeIcon.waitForDisplayed(); + await this.homeIcon.click(); + } +} + +export default new AllNotificationsScreen(); diff --git a/e2e/pageobjects/Notifications/MentionsScreen.page.js b/e2e/pageobjects/Notifications/MentionsScreen.page.js new file mode 100644 index 000000000..41dd31da3 --- /dev/null +++ b/e2e/pageobjects/Notifications/MentionsScreen.page.js @@ -0,0 +1,52 @@ +import { $, $$, browser } from '@wdio/globals'; + +class MentionsScreen { + get screenTitle() { + return $('android=new UiSelector().text("Notifications")'); + } + + get noNotificationsMessage() { + return $('android=new UiSelector().text("You\'re all caught up")'); + } + + get mentionNotifications() { + return $$('android=new UiSelector().textContains("mentioned")'); + } + + get replyIcon() { + return $$('~notification-reply-button'); + } + + get retweetIcon() { + return $$('~notification-retweet-button'); + } + + get likeIcon() { + return $$('~notification-like-button'); + } + + get replyCount() { + return $$('~notification-reply-count'); + } + + get retweetCount() { + return $$('~notification-retweet-count'); + } + + get likeCount() { + return $$('~notification-like-count'); + } + + async isNoNotificationsMessageDisplayed() { + return await this.noNotificationsMessage.isDisplayed(); + } + + async refresh() { + await browser.swipe({ + direction: 'down', + percent: 1, // 100% + }); + } +} + +export default new MentionsScreen(); diff --git a/e2e/pageobjects/Home pages/OnboardingBioScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingBioScreen.page.js similarity index 100% rename from e2e/pageobjects/Home pages/OnboardingBioScreen.page.js rename to e2e/pageobjects/Onboarding/OnboardingBioScreen.page.js diff --git a/e2e/pageobjects/Onboarding/OnboardingFollowScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingFollowScreen.page.js new file mode 100644 index 000000000..546794574 --- /dev/null +++ b/e2e/pageobjects/Onboarding/OnboardingFollowScreen.page.js @@ -0,0 +1,34 @@ +import { $, $$ } from '@wdio/globals'; + +class OnboardingFollowScreen { + get screenTitle() { + return $('android=new UiSelector().text("Follow 1 or more accounts")'); + } + + get interactionButtons() { + return $$('~onboarding-follow-button'); + } + + get nextButton() { + return $('android=new UiSelector().text("Next")'); + } + + get followButtons() { + return $$('android=new UiSelector().text("Follow")'); + } + + get followingButtons() { + return $$('android=new UiSelector().text("Following")'); + } + + async isNextButtonEnabled() { + return await this.nextButton.isEnabled(); + } + + async clickNext() { + await this.nextButton.waitForDisplayed(); + await this.nextButton.click(); + } +} + +export default new OnboardingFollowScreen(); diff --git a/e2e/pageobjects/Onboarding/OnboardingInterestsScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingInterestsScreen.page.js new file mode 100644 index 000000000..b1fb2b96e --- /dev/null +++ b/e2e/pageobjects/Onboarding/OnboardingInterestsScreen.page.js @@ -0,0 +1,48 @@ +import { $, driver } from '@wdio/globals'; + +class OnboardingInterestsScreen { + get screenTitle() { + return $('android=new UiSelector().text("What do you want to see on Raven?")'); + } + + get nextButton() { + return $('android=new UiSelector().text("Next")'); + } + + get noneSelectedMessage() { + return $('android=new UiSelector().text("0 of 1 selected")'); + } + + async clickNext() { + await this.nextButton.waitForDisplayed(); + await this.nextButton.click(); + } + + async clickInterests(interests) { + for (const interest of interests) { + const interestElement = await $(`android=new UiSelector().text("${interest}")`); + await interestElement.waitForDisplayed(); + await interestElement.click(); + } + } + + async isDisplayed() { + return (await this.screenTitle.isDisplayed()) && (await this.nextButton.isDisplayed()); + } + + async isNextButtonEnabled() { + await this.nextButton.waitForDisplayed(); + return this.nextButton.isEnabled(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async isNoneSelectedMessageDisplayed() { + return this.noneSelectedMessage.isDisplayed(); + } +} + +export default new OnboardingInterestsScreen(); diff --git a/e2e/pageobjects/Home pages/OnboardingProfilePicScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingProfilePicScreen.page.js similarity index 100% rename from e2e/pageobjects/Home pages/OnboardingProfilePicScreen.page.js rename to e2e/pageobjects/Onboarding/OnboardingProfilePicScreen.page.js diff --git a/e2e/pageobjects/Home pages/OnboardingUsernameScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingUsernameScreen.page.js similarity index 100% rename from e2e/pageobjects/Home pages/OnboardingUsernameScreen.page.js rename to e2e/pageobjects/Onboarding/OnboardingUsernameScreen.page.js diff --git a/e2e/pageobjects/Profile pages/ProfileScreen.page.js b/e2e/pageobjects/Profile pages/ProfileScreen.page.js deleted file mode 100644 index 96c0b4e14..000000000 --- a/e2e/pageobjects/Profile pages/ProfileScreen.page.js +++ /dev/null @@ -1,65 +0,0 @@ -import { $ } from '@wdio/globals'; - -class ProfileScreen { - get editProfileButton() { - return $('android=new UiSelector().text("Edit profile")'); - } - - get setupProfileButton() { - return $('android=new UiSelector().text("Set up profile")'); - } - - get currentButton() { - return $('~edit-profile-button'); - } - - get displayName() { - return $('~profile-display-name'); - } - - get bio() { - return $('~profile-bio'); - } - - get location() { - return $('~profile-location'); - } - - get website() { - return $('~profile-website'); - } - - get birthDate() { - return $('~profile-birthdate'); - } - - async isSetupProfileButtonDisplayed() { - return this.setupProfileButton.isDisplayed(); - } - - async isEditProfileButtonDisplayed() { - return this.editProfileButton.isDisplayed(); - } - - async goToSetupProfile() { - await this.setupProfileButton.waitForDisplayed(); - await this.setupProfileButton.click(); - } - - async goToEditProfile() { - await this.editProfileButton.waitForDisplayed(); - await this.editProfileButton.click(); - } - - async isDisplayed() { - return ( - (await this.displayName.isDisplayed()) && - (await this.bio.isDisplayed()) && - (await this.location.isDisplayed()) && - (await this.website.isDisplayed()) && - (await this.birthDate.isDisplayed()) - ); - } -} - -export default new ProfileScreen(); diff --git a/e2e/pageobjects/Profile/ConnectionsScreen.page.js b/e2e/pageobjects/Profile/ConnectionsScreen.page.js new file mode 100644 index 000000000..52ccc353e --- /dev/null +++ b/e2e/pageobjects/Profile/ConnectionsScreen.page.js @@ -0,0 +1,170 @@ +import { $, $$, driver } from '@wdio/globals'; + +class ConnectionsScreen { + // Tab navigation + get followingTab() { + return $('android=new UiSelector().text("Following")'); + } + + get followersTab() { + return $('android=new UiSelector().text("Followers")'); + } + + get followersYouKnowTab() { + return $('android=new UiSelector().text("Followers you know")'); + } + + // Connection list items + get connectionItems() { + return $$( + 'android=new UiSelector().className("android.view.ViewGroup").descriptionContains("Avatar")' + ); + } + + // Follow/Unfollow buttons in list + get followButtons() { + return $$('~connection-follow-button'); + } + + get followingButtons() { + return $$('~connection-following-button'); + } + + // First user in list selectors + get firstConnectionFollowButton() { + return $('~connection-follow-button'); + } + + get firstConnectionFollowingButton() { + return $('~connection-following-button'); + } + + // Empty state + get emptyStateMessage() { + return $('android=new UiSelector().textContains("Looking for")'); + } + + // Loading + get loadingSpinner() { + return $('~footer-spinner'); + } + + // Back button + get backButton() { + return $('android=new UiSelector().className("android.widget.ImageView")'); + } + + // Helper methods + async switchToFollowingTab() { + await this.followingTab.waitForDisplayed(); + await this.followingTab.click(); + await driver.pause(500); + } + + async switchToFollowersTab() { + await this.followersTab.waitForDisplayed(); + await this.followersTab.click(); + await driver.pause(500); + } + + async switchToFollowersYouKnowTab() { + await this.followersYouKnowTab.waitForDisplayed(); + await this.followersYouKnowTab.click(); + await driver.pause(500); + } + + async isFollowingTabDisplayed() { + try { + return await this.followingTab.isDisplayed(); + } catch { + return false; + } + } + + async isFollowersTabDisplayed() { + try { + return await this.followersTab.isDisplayed(); + } catch { + return false; + } + } + + async isFollowersYouKnowTabDisplayed() { + try { + return await this.followersYouKnowTab.isDisplayed(); + } catch { + return false; + } + } + + async clickFirstFollowButton() { + await this.firstConnectionFollowButton.waitForDisplayed(); + await this.firstConnectionFollowButton.click(); + await driver.pause(500); + } + + async clickFirstFollowingButton() { + await this.firstConnectionFollowingButton.waitForDisplayed(); + await this.firstConnectionFollowingButton.click(); + await driver.pause(500); + } + + async hasConnectionInList(displayName) { + const selector = `android=new UiSelector().textContains("${displayName}")`; + try { + const element = await $(selector); + return await element.isDisplayed(); + } catch { + return false; + } + } + + async clickOnConnectionByDisplayName(displayName) { + const selector = `android=new UiSelector().textContains("${displayName}")`; + const element = await $(selector); + await element.waitForDisplayed(); + await element.click(); + await driver.pause(500); + } + + async getFollowButtonCount() { + try { + const buttons = await this.followButtons; + return buttons.length; + } catch { + return 0; + } + } + + async getFollowingButtonCount() { + try { + const buttons = await this.followingButtons; + return buttons.length; + } catch { + return 0; + } + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async pullToRefresh() { + // Perform a swipe down to refresh + const { width, height } = await driver.getWindowSize(); + const startX = width / 2; + const startY = height * 0.3; + const endY = height * 0.7; + + await driver.touchAction([ + { action: 'press', x: startX, y: startY }, + { action: 'wait', ms: 500 }, + { action: 'moveTo', x: startX, y: endY }, + { action: 'release' }, + ]); + await driver.pause(1000); + } +} + +export default new ConnectionsScreen(); diff --git a/e2e/pageobjects/Profile pages/EditProfileScreen.page.js b/e2e/pageobjects/Profile/EditProfileScreen.page.js similarity index 100% rename from e2e/pageobjects/Profile pages/EditProfileScreen.page.js rename to e2e/pageobjects/Profile/EditProfileScreen.page.js diff --git a/e2e/pageobjects/Profile/ProfileScreen.page.js b/e2e/pageobjects/Profile/ProfileScreen.page.js new file mode 100644 index 000000000..147617666 --- /dev/null +++ b/e2e/pageobjects/Profile/ProfileScreen.page.js @@ -0,0 +1,400 @@ +import { $, $$, browser, driver } from '@wdio/globals'; + +class ProfileScreen { + // Profile action buttons + get editProfileButton() { + return $('android=new UiSelector().text("Edit profile")'); + } + + get setupProfileButton() { + return $('android=new UiSelector().text("Set up profile")'); + } + + get followingButton() { + return $('android=new UiSelector().text("Following")'); + } + + get followBackButton() { + return $('android=new UiSelector().text("Follow back")'); + } + + get followButton() { + return $('android=new UiSelector().text("Follow")'); + } + + get blockedButton() { + return $('android=new UiSelector().text("Blocked")'); + } + + get currentButton() { + return $('~edit-profile-button'); + } + + // Profile info + get displayName() { + return $('~profile-display-name'); + } + + get username() { + return $('~profile-username'); + } + + get bio() { + return $('~profile-bio'); + } + + get location() { + return $('~profile-location'); + } + + get website() { + return $('~profile-website'); + } + + get birthDate() { + return $('~profile-birthdate'); + } + + // Banner and navigation + get backButton() { + return $('~back-button'); + } + + get menuDropdownButton() { + return $('~menu-dropdown-button'); + } + + get menuDropdown() { + return $('~menu-dropdown'); + } + + // Dropdown menu options + get shareButton() { + return $('~share-button'); + } + + get muteButton() { + return $('~mute-button'); + } + + get unmuteButton() { + return $('~unmute-button'); + } + + get blockButtonMenu() { + return $('~block-button-menu'); + } + + get unblockButton() { + return $('~unblock-button'); + } + + // Modals + get blockConfirmButton() { + return $('~block-button'); + } + + get cancelBlockButton() { + return $('~cancel-block-button'); + } + + get unfollowConfirmButton() { + return $('~unfollow-confirm-button'); + } + + // Muted indicator on profile + get mutedIndicator() { + return $('android=new UiSelector().textContains("You have muted posts from this account")'); + } + + get unmuteLinkInIndicator() { + return $('android=new UiSelector().text("Unmute")'); + } + + // Blocked state - view posts + get viewPostsButton() { + return $('android=new UiSelector().text("View Posts")'); + } + + get blockedMessage() { + return $('android=new UiSelector().textContains("is blocked")'); + } + + // Message button + get messageButton() { + return $('~profile-message-button'); + } + + // Profile stats + get followersCount() { + return $('android=new UiSelector().textContains("Followers")'); + } + + get followingCount() { + return $('android=new UiSelector().textContains("Following")'); + } + + get followersStatButton() { + return $('~profile-followers-stat'); + } + + get followingStatButton() { + return $('~profile-following-stat'); + } + + // Profile content tabs + get postsTab() { + return $('~ProfilePostsTab'); + } + + get repliesTab() { + return $('~ProfileRepliesTab'); + } + + get mediaTab() { + return $('~ProfileMediaTab'); + } + + get likesTab() { + return $('~ProfileLikesTab'); + } + + // Tweet content + get tweetCards() { + return $$('~tweet-card'); + } + + // Helper methods + async isSetupProfileButtonDisplayed() { + return this.setupProfileButton.isDisplayed(); + } + + async isEditProfileButtonDisplayed() { + return this.editProfileButton.isDisplayed(); + } + + async isFollowingButtonDisplayed() { + return this.followingButton.isDisplayed(); + } + + async isFollowBackButtonDisplayed() { + return this.followBackButton.isDisplayed(); + } + + async isFollowButtonDisplayed() { + return this.followButton.isDisplayed(); + } + + async isBlockedButtonDisplayed() { + return this.blockedButton.isDisplayed(); + } + + async isMutedIndicatorDisplayed() { + return this.mutedIndicator.isDisplayed(); + } + + async isBlockedMessageDisplayed() { + return this.blockedMessage.isDisplayed(); + } + + async isMessageButtonDisplayed() { + try { + return await this.messageButton.isDisplayed(); + } catch { + return false; + } + } + + async goToSetupProfile() { + await this.setupProfileButton.waitForDisplayed(); + await this.setupProfileButton.click(); + } + + async goToEditProfile() { + await this.editProfileButton.waitForDisplayed(); + await this.editProfileButton.click(); + } + + async openMenuDropdown() { + await this.menuDropdownButton.waitForDisplayed(); + await this.menuDropdownButton.click(); + await driver.pause(300); + } + + async closeMenuDropdown() { + const { width, height } = await browser.getWindowRect(); + const anchorX = width * 0.5; + let from = { x: anchorX, y: height * 0.5 }; + let to = { x: anchorX, y: height * 0.1 }; + let speed = 1000; + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: from.x, y: from.y }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 100 }, + { type: 'pointerMove', duration: speed, x: to.x, y: to.y }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + await browser.pause(500); + } + + async muteUser() { + await this.muteButton.waitForDisplayed(); + await this.muteButton.click(); + await this.blockConfirmButton.waitForDisplayed(); + await this.blockConfirmButton.click(); + await driver.pause(500); + } + + async unmuteUser() { + await this.unmuteButton.waitForDisplayed(); + await this.unmuteButton.click(); + await this.blockConfirmButton.waitForDisplayed(); + await this.blockConfirmButton.click(); + await driver.pause(500); + } + + async unmuteFromIndicator() { + await this.unmuteLinkInIndicator.waitForDisplayed(); + await this.unmuteLinkInIndicator.click(); + await this.blockConfirmButton.waitForDisplayed(); + await this.blockConfirmButton.click(); + await driver.pause(500); + } + + async blockUser() { + await this.blockButtonMenu.waitForDisplayed(); + await this.blockButtonMenu.click(); + await this.blockConfirmButton.waitForDisplayed(); + await this.blockConfirmButton.click(); + await driver.pause(500); + } + + async unblockUser() { + await this.unblockButton.waitForDisplayed(); + await this.unblockButton.click(); + await this.blockConfirmButton.waitForDisplayed(); + await this.blockConfirmButton.click(); + await driver.pause(500); + } + + async unblockFromButton() { + await this.blockedButton.waitForDisplayed(); + await this.blockedButton.click(); + await this.blockConfirmButton.waitForDisplayed(); + await this.blockConfirmButton.click(); + await driver.pause(500); + } + + async followUser() { + await this.followButton.waitForDisplayed(); + await this.followButton.click(); + await driver.pause(500); + } + + async followBackUser() { + await this.followBackButton.waitForDisplayed(); + await this.followBackButton.click(); + await driver.pause(500); + } + + async unfollowUser() { + await this.followingButton.waitForDisplayed(); + await this.followingButton.click(); + await this.unfollowConfirmButton.waitForDisplayed(); + await this.unfollowConfirmButton.click(); + await driver.pause(500); + } + + async clickOnMessageButton() { + await this.messageButton.waitForDisplayed(); + await this.messageButton.click(); + } + + async viewBlockedUserPosts() { + await this.viewPostsButton.waitForDisplayed(); + await this.viewPostsButton.click(); + } + + async isDisplayed() { + return ( + (await this.displayName.isDisplayed()) && + (await this.bio.isDisplayed()) && + (await this.location.isDisplayed()) && + (await this.website.isDisplayed()) && + (await this.birthDate.isDisplayed()) + ); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async goToFollowers() { + await this.followersStatButton.waitForDisplayed(); + await this.followersStatButton.click(); + await driver.pause(500); + } + + async goToFollowing() { + await this.followingStatButton.waitForDisplayed(); + await this.followingStatButton.click(); + await driver.pause(500); + } + + async switchToPostsTab() { + await this.postsTab.waitForDisplayed(); + await this.postsTab.click(); + await driver.pause(500); + } + + async switchToRepliesTab() { + await this.repliesTab.waitForDisplayed(); + await this.repliesTab.click(); + await driver.pause(500); + } + + async switchToMediaTab() { + await this.mediaTab.waitForDisplayed(); + await this.mediaTab.click(); + await driver.pause(500); + } + + async switchToLikesTab() { + await this.likesTab.waitForDisplayed(); + await this.likesTab.click(); + await driver.pause(500); + } + + async refresh() { + const { width, height } = await browser.getWindowRect(); + const anchorX = width * 0.5; + let from = { x: anchorX, y: height * 0.7 }; + let to = { x: anchorX, y: height * 0.9 }; + let speed = 1000; + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: from.x, y: from.y }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 100 }, + { type: 'pointerMove', duration: speed, x: to.x, y: to.y }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + await browser.pause(500); + } +} + +export default new ProfileScreen(); diff --git a/e2e/pageobjects/Reset Password pages/ConfirmCodeResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/ConfirmCodeResetPasswordScreen.page.js similarity index 100% rename from e2e/pageobjects/Reset Password pages/ConfirmCodeResetPasswordScreen.page.js rename to e2e/pageobjects/Reset Password/ConfirmCodeResetPasswordScreen.page.js diff --git a/e2e/pageobjects/Reset Password pages/ConfirmMethodResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/ConfirmMethodResetPasswordScreen.page.js similarity index 100% rename from e2e/pageobjects/Reset Password pages/ConfirmMethodResetPasswordScreen.page.js rename to e2e/pageobjects/Reset Password/ConfirmMethodResetPasswordScreen.page.js diff --git a/e2e/pageobjects/Reset Password pages/EmailResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/EmailResetPasswordScreen.page.js similarity index 100% rename from e2e/pageobjects/Reset Password pages/EmailResetPasswordScreen.page.js rename to e2e/pageobjects/Reset Password/EmailResetPasswordScreen.page.js diff --git a/e2e/pageobjects/Reset Password pages/NewPasswordResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/NewPasswordResetPasswordScreen.page.js similarity index 100% rename from e2e/pageobjects/Reset Password pages/NewPasswordResetPasswordScreen.page.js rename to e2e/pageobjects/Reset Password/NewPasswordResetPasswordScreen.page.js diff --git a/e2e/pageobjects/Search/SearchFiltersScreen.page.js b/e2e/pageobjects/Search/SearchFiltersScreen.page.js new file mode 100644 index 000000000..c9cfee07e --- /dev/null +++ b/e2e/pageobjects/Search/SearchFiltersScreen.page.js @@ -0,0 +1,102 @@ +import { $, driver } from '@wdio/globals'; + +class SearchFiltersScreen { + // Screen container + get screen() { + return $('~search-filters-screen'); + } + + // Header buttons + get cancelButton() { + return $('~search-filters-cancel'); + } + + get applyButton() { + return $('~search-filters-apply'); + } + + // People filter section + get peopleFilterSection() { + return $('~people-filter-section'); + } + + get fromAnyoneRadio() { + return $('~radio-from-anyone'); + } + + get peopleYouFollowRadio() { + return $('~radio-people-you-follow'); + } + + // Safety toggle section + get safetyFilterSection() { + return $('~safety-filter-section'); + } + + get removeBlockedMutedToggle() { + return $('~toggle-remove-blocked-and-muted-accounts'); + } + + // Helper methods + async waitForScreen() { + await this.screen.waitForDisplayed(); + await driver.pause(300); + } + + async cancel() { + await this.cancelButton.waitForDisplayed(); + await this.cancelButton.click(); + await driver.pause(300); + } + + async apply() { + await this.applyButton.waitForDisplayed(); + await this.applyButton.click(); + await driver.pause(500); + } + + async selectFromAnyone() { + await this.fromAnyoneRadio.waitForDisplayed(); + await this.fromAnyoneRadio.click(); + await driver.pause(200); + } + + async selectPeopleYouFollow() { + await this.peopleYouFollowRadio.waitForDisplayed(); + await this.peopleYouFollowRadio.click(); + await driver.pause(200); + } + + async toggleRemoveBlockedMuted() { + await this.removeBlockedMutedToggle.waitForDisplayed(); + await this.removeBlockedMutedToggle.click(); + await driver.pause(200); + } + + async isFromAnyoneSelected() { + try { + const radioInner = await $('~radio-from-anyone').$('~radio-inner'); + return await radioInner.isDisplayed(); + } catch { + return false; + } + } + + async isPeopleYouFollowSelected() { + try { + const radioInner = await $('~radio-people-you-follow').$('~radio-inner'); + return await radioInner.isDisplayed(); + } catch { + return false; + } + } + + async isApplyButtonEnabled() { + const button = await this.applyButton; + // Check if button has opacity-40 class (disabled state) + // In appium, we check if the element is enabled + return await button.isEnabled(); + } +} + +export default new SearchFiltersScreen(); diff --git a/e2e/pageobjects/Search/SearchScreen.page.js b/e2e/pageobjects/Search/SearchScreen.page.js new file mode 100644 index 000000000..cbaa28b60 --- /dev/null +++ b/e2e/pageobjects/Search/SearchScreen.page.js @@ -0,0 +1,277 @@ +import { $, $$, driver } from '@wdio/globals'; + +class SearchScreen { + // Search input + get searchInput() { + return $('~search-input'); + } + + get clearSearchButton() { + return $('~clear-search'); + } + + get cancelSearchButton() { + return $('~cancel-search'); + } + + get backButton() { + return $('~search-back'); + } + + get settingsButton() { + return $('~search-gear'); + } + + // Tabs + get topTab() { + return $('android=new UiSelector().text("Top")'); + } + + get latestTab() { + return $('android=new UiSelector().text("Latest")'); + } + + get peopleTab() { + return $('android=new UiSelector().text("People")'); + } + + get mediaTab() { + return $('android=new UiSelector().text("Media")'); + } + + // Results + get noResultsMessage() { + return $('android=new UiSelector().textContains("No results")'); + } + + // Suggestions + get suggestionsEmpty() { + return $('~search-suggestions-empty'); + } + + get suggestionSearchAction() { + return $('~suggestion-search-action'); + } + + get suggestionGotoAction() { + return $('~suggestion-goto-action'); + } + + // Top People + get topPeopleSection() { + return $('~search-top-people'); + } + + get topPeopleViewAll() { + return $('~search-top-people-view-all'); + } + + // People Results + get peopleResultsList() { + return $('~search-people-results'); + } + + get emptyState() { + return $('~search-empty-state'); + } + + // Search Input Overlay + get searchInputOverlay() { + return $('~search-input-overlay'); + } + + // Navigation triggers + get exploreSearchTrigger() { + return $('~explore-search-trigger'); + } + + get homeIcon() { + return $('~HomeTab'); + } + + // Helper methods + async openFromExplore() { + await this.exploreSearchTrigger.waitForDisplayed(); + await this.exploreSearchTrigger.click(); + await driver.pause(500); + } + + async searchFor(query) { + await this.searchInput.waitForDisplayed(); + await this.searchInput.setValue(query); + await driver.pressKeyCode(66); // Enter key + await driver.pause(1000); + } + + async clearSearch() { + await this.clearSearchButton.waitForDisplayed(); + await this.clearSearchButton.click(); + await driver.pause(300); + } + + async cancelSearch() { + await this.cancelSearchButton.waitForDisplayed(); + await this.cancelSearchButton.click(); + await driver.pause(300); + } + + async goBack() { + await this.backButton.waitForDisplayed(); + await this.backButton.click(); + await driver.pause(300); + } + + async switchToTopTab() { + await this.topTab.waitForDisplayed(); + await this.topTab.click(); + await driver.pause(500); + } + + async switchToLatestTab() { + await this.latestTab.waitForDisplayed(); + await this.latestTab.click(); + await driver.pause(500); + } + + async switchToPeopleTab() { + await this.peopleTab.waitForDisplayed(); + await this.peopleTab.click(); + await driver.pause(500); + } + + async switchToMediaTab() { + await this.mediaTab.waitForDisplayed(); + await this.mediaTab.click(); + await driver.pause(500); + } + + async isTopTabDisplayed() { + try { + return await this.topTab.isDisplayed(); + } catch { + return false; + } + } + + async isPeopleTabDisplayed() { + try { + return await this.peopleTab.isDisplayed(); + } catch { + return false; + } + } + + async hasUserInResults(displayName) { + const selector = `android=new UiSelector().textContains("${displayName}")`; + try { + const element = await $$(selector); + return await element[1].isDisplayed(); + } catch { + return false; + } + } + + async clickOnUserInResults(displayName) { + const selector = `android=new UiSelector().textContains("${displayName}")`; + const element = await $$(selector); + await element[1].waitForDisplayed(); + await element[1].click(); + await driver.pause(500); + } + + async goToForYouScreen() { + await this.homeIcon.waitForDisplayed(); + await this.homeIcon.click(); + } + + async isSuggestionsEmptyDisplayed() { + try { + return await this.suggestionsEmpty.isDisplayed(); + } catch { + return false; + } + } + + async isTopPeopleSectionDisplayed() { + try { + return await this.topPeopleSection.isDisplayed(); + } catch { + return false; + } + } + + async clickTopPeopleViewAll() { + try { + if (await this.topPeopleViewAll.isDisplayed()) { + await this.topPeopleViewAll.click(); + await driver.pause(500); + return true; + } + } catch { + return false; + } + return false; + } + + async isPeopleResultsDisplayed() { + try { + return await this.peopleResultsList.waitForDisplayed({ timeout: 5000 }); + } catch { + return false; + } + } + + async isEmptyStateDisplayed() { + try { + return await this.emptyState.waitForDisplayed({ timeout: 5000 }); + } catch { + return false; + } + } + + async isSuggestionSearchActionDisplayed() { + try { + return await this.suggestionSearchAction.waitForDisplayed({ timeout: 3000 }); + } catch { + return false; + } + } + + async clickSuggestionSearchAction() { + try { + if (await this.suggestionSearchAction.isDisplayed()) { + await this.suggestionSearchAction.click(); + await driver.pause(500); + return true; + } + } catch { + return false; + } + return false; + } + + async clickSearchInputOverlay() { + try { + if (await this.searchInputOverlay.isDisplayed()) { + await this.searchInputOverlay.click(); + await driver.pause(300); + return true; + } + } catch { + return false; + } + return false; + } + + async typeQuery(query) { + await this.searchInput.setValue(query); + await driver.pause(500); + } + + async submitSearch() { + await driver.pressKeyCode(66); + await driver.pause(1000); + } +} + +export default new SearchScreen(); diff --git a/e2e/pageobjects/Settings pages/AccountInformationScreen.page.js b/e2e/pageobjects/Settings/AccountInformationScreen.page.js similarity index 74% rename from e2e/pageobjects/Settings pages/AccountInformationScreen.page.js rename to e2e/pageobjects/Settings/AccountInformationScreen.page.js index 82204e481..62441786c 100644 --- a/e2e/pageobjects/Settings pages/AccountInformationScreen.page.js +++ b/e2e/pageobjects/Settings/AccountInformationScreen.page.js @@ -17,6 +17,14 @@ class AccountInformationScreen { return $('android=new UiSelector().text("Password")'); } + get changeCountryButton() { + return $('android=new UiSelector().text("Country")'); + } + + get selectedCountry() { + return $('~account-info-current-country'); + } + get logoutButton() { return $('android=new UiSelector().text("Log out")'); } @@ -25,6 +33,10 @@ class AccountInformationScreen { return $('~account-info-username-header'); } + get email() { + return $('~account-info-current-email'); + } + async logout() { await this.logoutButton.waitForDisplayed(); await this.logoutButton.click(); @@ -47,12 +59,19 @@ class AccountInformationScreen { await this.changePasswordButton.click(); } + async goToChangeCountry() { + await this.changeCountryButton.waitForDisplayed(); + await this.changeCountryButton.waitForEnabled(); + await this.changeCountryButton.click(); + } + async isDisplayed() { return ( this.screenTitle.isDisplayed() && this.changeUsernameButton.isDisplayed() && this.changeEmailButton.isDisplayed() && - this.changePasswordButton.isDisplayed() + this.changePasswordButton.isDisplayed() && + this.changeCountryButton.isDisplayed() ); } } diff --git a/e2e/pageobjects/Settings pages/AccountSettingsScreen.page.js b/e2e/pageobjects/Settings/AccountSettingsScreen.page.js similarity index 71% rename from e2e/pageobjects/Settings pages/AccountSettingsScreen.page.js rename to e2e/pageobjects/Settings/AccountSettingsScreen.page.js index 0e8d200da..b168854de 100644 --- a/e2e/pageobjects/Settings pages/AccountSettingsScreen.page.js +++ b/e2e/pageobjects/Settings/AccountSettingsScreen.page.js @@ -9,11 +9,20 @@ class AccountSettingsScreen { return $('android=new UiSelector().text("Account information")'); } + get changePasswordButton() { + return $('android=new UiSelector().text("Change password")'); + } + async goToAccountInformation() { await this.accountInformationButton.waitForDisplayed(); await this.accountInformationButton.click(); } + async goToChangePassword() { + await this.changePasswordButton.waitForDisplayed(); + await this.changePasswordButton.click(); + } + async isDisplayed() { return ( (await this.screenTitle.isDisplayed()) && (await this.accountInformationButton.isDisplayed()) diff --git a/e2e/pageobjects/Settings/AppearanceSettingsScreen.page.js b/e2e/pageobjects/Settings/AppearanceSettingsScreen.page.js new file mode 100644 index 000000000..678d84259 --- /dev/null +++ b/e2e/pageobjects/Settings/AppearanceSettingsScreen.page.js @@ -0,0 +1,41 @@ +import { $, driver } from '@wdio/globals'; + +class AppearanceSettingsScreen { + get screenTitle() { + return $('android=new UiSelector().text("Theme")'); + } + + get lightThemeButton() { + return $('android=new UiSelector().text("Light")'); + } + + get darkThemeButton() { + return $('android=new UiSelector().text("Dark")'); + } + + get systemThemeButton() { + return $('android=new UiSelector().text("System")'); + } + + async chooseDarkTheme() { + await this.darkThemeButton.waitForDisplayed(); + await this.darkThemeButton.click(); + } + + async chooseLightTheme() { + await this.lightThemeButton.waitForDisplayed(); + await this.lightThemeButton.click(); + } + + async chooseSystemTheme() { + await this.systemThemeButton.waitForDisplayed(); + await this.systemThemeButton.click(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } +} + +export default new AppearanceSettingsScreen(); diff --git a/e2e/pageobjects/Settings/BlockedListScreen.page.js b/e2e/pageobjects/Settings/BlockedListScreen.page.js new file mode 100644 index 000000000..4447d3e1f --- /dev/null +++ b/e2e/pageobjects/Settings/BlockedListScreen.page.js @@ -0,0 +1,48 @@ +import { $, $$, browser, driver } from '@wdio/globals'; + +class BlockedListScreen { + get screenTitle() { + return $('android=new UiSelector().text("Blocked accounts")'); + } + + get noBlockedAccounts() { + return $('android=new UiSelector().text("Block unwanted accounts")'); + } + + get blockedAccountsList() { + return $$('~connection-list-item'); + } + + get blockedButton() { + return $('android=new UiSelector().text("Blocked")'); + } + + get unblockConfirmButton() { + return $('~block-button'); + } + + async isNoBlockedAccountsDisplayed() { + return await this.noBlockedAccounts.isDisplayed(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async refresh() { + await browser.swipe({ + direction: 'down', + percent: 1, // 100% + }); + } + + async unblockFirstUser() { + await this.blockedButton.click(); + await this.unblockConfirmButton.waitForDisplayed(); + await this.unblockConfirmButton.click(); + await driver.pause(500); + } +} + +export default new BlockedListScreen(); diff --git a/e2e/pageobjects/Settings/ChangeCountryScreen.page.js b/e2e/pageobjects/Settings/ChangeCountryScreen.page.js new file mode 100644 index 000000000..8fd241f3c --- /dev/null +++ b/e2e/pageobjects/Settings/ChangeCountryScreen.page.js @@ -0,0 +1,68 @@ +import { $, browser, driver } from '@wdio/globals'; + +class ChangeCountryScreen { + get screenTitle() { + return $('android=new UiSelector().text("Country")'); + } + + get countrySearchInput() { + return $('~country-search-input'); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async swipeUp() { + const { width, height } = await browser.getWindowRect(); + const anchorX = width * 0.5; + let from = { x: anchorX, y: height * 0.6 }; + let to = { x: anchorX, y: height * 0.1 }; + let speed = 1000; + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: from.x, y: from.y }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerMove', duration: speed, x: to.x, y: to.y }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + await browser.pause(500); + } + + async clickCountry(countryName) { + const countryElement = await $(`android=new UiSelector().text("${countryName}")`); + await countryElement.click(); + } + + async findCountry(countryName) { + const countryElement = await $(`android=new UiSelector().text("${countryName}")`); + while (!(await countryElement.isDisplayed())) { + await this.swipeUp(); + } + } + + async searchForCountry(countryName) { + await this.countrySearchInput.waitForDisplayed(); + await this.countrySearchInput.setValue(countryName); + await this.countrySearchInput.setValue(''); + } + + async isCountryDisplayed(countryName) { + const countryElement = await $(`android=new UiSelector().text("${countryName}")`); + return await countryElement.isDisplayed(); + } + + async isDisplayed() { + return (await this.screenTitle.isDisplayed()) && (await this.countrySearchInput.isDisplayed()); + } +} + +export default new ChangeCountryScreen(); diff --git a/e2e/pageobjects/Settings pages/ChangeEmailScreen.page.js b/e2e/pageobjects/Settings/ChangeEmailScreen.page.js similarity index 100% rename from e2e/pageobjects/Settings pages/ChangeEmailScreen.page.js rename to e2e/pageobjects/Settings/ChangeEmailScreen.page.js diff --git a/e2e/pageobjects/Settings pages/ChangePasswordScreen.page.js b/e2e/pageobjects/Settings/ChangePasswordScreen.page.js similarity index 95% rename from e2e/pageobjects/Settings pages/ChangePasswordScreen.page.js rename to e2e/pageobjects/Settings/ChangePasswordScreen.page.js index bf4638808..2f97258fb 100644 --- a/e2e/pageobjects/Settings pages/ChangePasswordScreen.page.js +++ b/e2e/pageobjects/Settings/ChangePasswordScreen.page.js @@ -1,4 +1,4 @@ -import { $ } from '@wdio/globals'; +import { $, driver } from '@wdio/globals'; class ChangePasswordScreen { get screenTitle() { @@ -82,6 +82,11 @@ class ChangePasswordScreen { async isIncorrectOldPasswordErrorMessageDisplayed() { return await this.incorrectOldPasswordErrorMessage.isDisplayed(); } + + async goBack() { + await driver.back(); + await driver.pause(500); + } } export default new ChangePasswordScreen(); diff --git a/e2e/pageobjects/Settings pages/ChangeUsernameScreen.page.js b/e2e/pageobjects/Settings/ChangeUsernameScreen.page.js similarity index 100% rename from e2e/pageobjects/Settings pages/ChangeUsernameScreen.page.js rename to e2e/pageobjects/Settings/ChangeUsernameScreen.page.js diff --git a/e2e/pageobjects/Settings/ConfirmChangeCountryScreen.page.js b/e2e/pageobjects/Settings/ConfirmChangeCountryScreen.page.js new file mode 100644 index 000000000..c6540d432 --- /dev/null +++ b/e2e/pageobjects/Settings/ConfirmChangeCountryScreen.page.js @@ -0,0 +1,47 @@ +import { $, driver } from '@wdio/globals'; + +class ConfirmChangeCountryScreen { + get screenTitle() { + return $('android=new UiSelector().textContains("Change country")'); + } + + get changeButton() { + return $('android=new UiSelector().text("Change")'); + } + + get cancelButton() { + return $('android=new UiSelector().text("Cancel")'); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async isConfirmMessageDisplayed(countryName) { + const confirmMessage = await $( + `android=new UiSelector().textContains("Change country to ${countryName}?")` + ); + return confirmMessage.isDisplayed(); + } + + async isDisplayed() { + return ( + (await this.screenTitle.isDisplayed()) && + (await this.changeButton.isDisplayed()) && + (await this.cancelButton.isDisplayed()) + ); + } + + async clickChange() { + await this.changeButton.waitForEnabled(); + await this.changeButton.click(); + } + + async clickCancel() { + await this.cancelButton.waitForDisplayed(); + await this.cancelButton.click(); + } +} + +export default new ConfirmChangeCountryScreen(); diff --git a/e2e/pageobjects/Settings/ContentYouSeeScreen.page.js b/e2e/pageobjects/Settings/ContentYouSeeScreen.page.js new file mode 100644 index 000000000..29c887361 --- /dev/null +++ b/e2e/pageobjects/Settings/ContentYouSeeScreen.page.js @@ -0,0 +1,18 @@ +import { $ } from '@wdio/globals'; + +class ContentYouSeeScreen { + get screenTitle() { + return $('android=new UiSelector().text("Content you see")'); + } + + get interests() { + return $('android=new UiSelector().text("Interests")'); + } + + async goToInterests() { + await this.interests.waitForDisplayed(); + await this.interests.click(); + } +} + +export default new ContentYouSeeScreen(); diff --git a/e2e/pageobjects/Settings/InterestsScreen.page.js b/e2e/pageobjects/Settings/InterestsScreen.page.js new file mode 100644 index 000000000..7fe5e50ee --- /dev/null +++ b/e2e/pageobjects/Settings/InterestsScreen.page.js @@ -0,0 +1,31 @@ +import { $, driver } from '@wdio/globals'; + +class InterestsScreen { + get screenTitle() { + return $('android=new UiSelector().text("Interests")'); + } + + get saveButton() { + return $('~interests-save-changes'); + } + + async selectInterests(interests) { + for (let interest of interests) { + const interestElement = $(`android=new UiSelector().text("${interest}")`); + await interestElement.waitForDisplayed(); + await interestElement.click(); + await driver.pause(500); + } + } + + async isSaveButtonEnabled() { + return await this.saveButton.isEnabled(); + } + + async saveChanges() { + await this.saveButton.waitForDisplayed(); + await this.saveButton.click(); + } +} + +export default new InterestsScreen(); diff --git a/e2e/pageobjects/Settings/MuteAndBlockScreen.page.js b/e2e/pageobjects/Settings/MuteAndBlockScreen.page.js new file mode 100644 index 000000000..a55ae06ef --- /dev/null +++ b/e2e/pageobjects/Settings/MuteAndBlockScreen.page.js @@ -0,0 +1,32 @@ +import { $, driver } from '@wdio/globals'; + +class MuteAndBlockScreen { + get screenTitle() { + return $('android=new UiSelector().text("Mute and block")'); + } + + get blockedAccountsList() { + return $('android=new UiSelector().text("Blocked accounts")'); + } + + get mutedAccountsList() { + return $('android=new UiSelector().text("Muted accounts")'); + } + + async goToBlockedList() { + await this.blockedAccountsList.waitForDisplayed(); + await this.blockedAccountsList.click(); + } + + async goToMutedList() { + await this.mutedAccountsList.waitForDisplayed(); + await this.mutedAccountsList.click(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } +} + +export default new MuteAndBlockScreen(); diff --git a/e2e/pageobjects/Settings/MutedListScreen.page.js b/e2e/pageobjects/Settings/MutedListScreen.page.js new file mode 100644 index 000000000..2e19220af --- /dev/null +++ b/e2e/pageobjects/Settings/MutedListScreen.page.js @@ -0,0 +1,42 @@ +import { $, $$, browser, driver } from '@wdio/globals'; + +class MutedListScreen { + get screenTitle() { + return $('android=new UiSelector().text("Muted accounts")'); + } + + get noMutedAccounts() { + return $('android=new UiSelector().text("Muted accounts")'); + } + + get mutedAccountsList() { + return $$('~connection-list-item'); + } + + get muteButton() { + return $('~mute-button-muted'); + } + + async isNoMutedAccountsDisplayed() { + return await this.noMutedAccounts.isDisplayed(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async refresh() { + await browser.swipe({ + direction: 'down', + percent: 1, // 100% + }); + } + + async unmuteFirstUser() { + await this.muteButton.click(); + await driver.pause(500); + } +} + +export default new MutedListScreen(); diff --git a/e2e/pageobjects/Settings/PrivacySettingsScreen.page.js b/e2e/pageobjects/Settings/PrivacySettingsScreen.page.js new file mode 100644 index 000000000..2f9579388 --- /dev/null +++ b/e2e/pageobjects/Settings/PrivacySettingsScreen.page.js @@ -0,0 +1,27 @@ +import { $ } from '@wdio/globals'; + +class PrivacySettingsScreen { + get screenTitle() { + return $('android=new UiSelector().text("Privacy and safety")'); + } + + get muteAndBlockButton() { + return $('android=new UiSelector().text("Mute and block")'); + } + + get contentYouSeeButton() { + return $('android=new UiSelector().text("Content you see")'); + } + + async goToMuteAndBlock() { + await this.muteAndBlockButton.waitForDisplayed(); + await this.muteAndBlockButton.click(); + } + + async goToContentYouSee() { + await this.contentYouSeeButton.waitForDisplayed(); + await this.contentYouSeeButton.click(); + } +} + +export default new PrivacySettingsScreen(); diff --git a/e2e/pageobjects/Settings pages/SettingsScreen.page.js b/e2e/pageobjects/Settings/SettingsScreen.page.js similarity index 78% rename from e2e/pageobjects/Settings pages/SettingsScreen.page.js rename to e2e/pageobjects/Settings/SettingsScreen.page.js index a9eb65fe1..6fbb4eb8c 100644 --- a/e2e/pageobjects/Settings pages/SettingsScreen.page.js +++ b/e2e/pageobjects/Settings/SettingsScreen.page.js @@ -26,6 +26,16 @@ class SettingsScreen { await this.accountSettingsButton.click(); } + async goToPrivacySettings() { + await this.privacySettingsButton.waitForDisplayed(); + await this.privacySettingsButton.click(); + } + + async goToAppearanceSettings() { + await this.appearanceSettingsButton.waitForDisplayed(); + await this.appearanceSettingsButton.click(); + } + async isDisplayed() { return ( (await this.settingsScreenTitle.isDisplayed()) && diff --git a/e2e/pageobjects/SignUp pages/SignupStep1.page.js b/e2e/pageobjects/SignUp/SignupStep1.page.js similarity index 100% rename from e2e/pageobjects/SignUp pages/SignupStep1.page.js rename to e2e/pageobjects/SignUp/SignupStep1.page.js diff --git a/e2e/pageobjects/SignUp pages/SignupStep2.page.js b/e2e/pageobjects/SignUp/SignupStep2.page.js similarity index 100% rename from e2e/pageobjects/SignUp pages/SignupStep2.page.js rename to e2e/pageobjects/SignUp/SignupStep2.page.js diff --git a/e2e/pageobjects/SignUp pages/SignupStep3.page.js b/e2e/pageobjects/SignUp/SignupStep3.page.js similarity index 100% rename from e2e/pageobjects/SignUp pages/SignupStep3.page.js rename to e2e/pageobjects/SignUp/SignupStep3.page.js diff --git a/e2e/pageobjects/Tweets/TweetComposerScreen.page.js b/e2e/pageobjects/Tweets/TweetComposerScreen.page.js new file mode 100644 index 000000000..aee5122a6 --- /dev/null +++ b/e2e/pageobjects/Tweets/TweetComposerScreen.page.js @@ -0,0 +1,70 @@ +import { $, $$, driver } from '@wdio/globals'; + +class TweetComposerScreen { + get postButton() { + return $('~composer-post-button'); + } + + get tweetInput() { + return $('~tweet-text-input'); + } + + get composerMediaImages() { + return $$('~composer-media-image'); + } + + get composerRemoveMedia() { + return $$('~composer-remove-media'); + } + + get composerDiscardModal() { + return $('android=new UiSelector().text("Discard Tweet?")'); + } + + get composerDiscardModalDiscardButton() { + return $('android=new UiSelector().text("DISCARD")'); + } + + get composerDiscardModalCancelButton() { + return $('android=new UiSelector().text("CANCEL")'); + } + + async writeTweet(tweet) { + await this.tweetInput.waitForDisplayed(); + await this.tweetInput.setValue(tweet); + } + + async postTweet() { + await this.postButton.waitForDisplayed(); + await this.postButton.click(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async isDisplayed() { + return (await this.postButton.isDisplayed()) && (await this.tweetInput.isDisplayed()); + } + + async isPostButtonEnabled() { + return await this.postButton.isEnabled(); + } + + async isDiscardModalDisplayed() { + return await this.composerDiscardModal.isDisplayed(); + } + + async discardTweet() { + await this.composerDiscardModalDiscardButton.waitForDisplayed(); + await this.composerDiscardModalDiscardButton.click(); + } + + async cancelDiscardTweet() { + await this.composerDiscardModalCancelButton.waitForDisplayed(); + await this.composerDiscardModalCancelButton.click(); + } +} + +export default new TweetComposerScreen(); diff --git a/e2e/pageobjects/Tweets/TweetDetailsScreen.page.js b/e2e/pageobjects/Tweets/TweetDetailsScreen.page.js new file mode 100644 index 000000000..43d23796d --- /dev/null +++ b/e2e/pageobjects/Tweets/TweetDetailsScreen.page.js @@ -0,0 +1,187 @@ +import { $, $$, driver } from '@wdio/globals'; + +class TweetDetailsScreen { + // Screen identification + get screenTitle() { + return $('~tweet-view'); + } + + // Main tweet container + get mainTweetContainer() { + return $('~main-tweet-container'); + } + + // Tweet content + get tweetContent() { + return $$('~tweet-content'); + } + + get mainTweetContent() { + return this.mainTweetContainer.$('~tweet-content'); + } + + // Reply containers + get replyContainers() { + return $$('~reply-tweet-container'); + } + + // Parent tweet containers (thread context) + get parentContainers() { + return $$('~parent-tweet-container'); + } + + // Thread navigation + get showMoreButton() { + return $('~thread-show-more-button'); + } + + // Reply input elements + get replyInput() { + return $('~reply-input'); + } + + get submitReplyButton() { + return $('~submit-reply'); + } + + get replyingToContainer() { + return $('~replying-to-container'); + } + + // Tweet action buttons (on main tweet) + get likeButton() { + return $('~like-button'); + } + + get retweetButton() { + return $('~retweet-button'); + } + + get replyButton() { + return $('~reply-button'); + } + + get shareButton() { + return $('~share-button'); + } + + // Tweet counts + get likeCount() { + return $('~like-count'); + } + + get retweetCount() { + return $('~retweet-count'); + } + + get replyCount() { + return $('~reply-count'); + } + + // Tweet drawer + get tweetDrawerButton() { + return $('~tweet-drawer-button'); + } + + // Repost/Quote drawer options + get repostOption() { + return $('~repost-option'); + } + + get quoteOption() { + return $('~quote-option'); + } + + // AI Summary + get aiSummaryButton() { + return $('~ai-summary-button'); + } + + get aiSummaryText() { + return $('~ai-summary-text'); + } + + // All tweet cards on this screen + get tweetCards() { + return $$('~tweet-card'); + } + + // Methods + async isDisplayed() { + return this.screenTitle.isDisplayed(); + } + + async goBack() { + await driver.back(); + await driver.pause(500); + } + + async typeReply(text) { + await this.replyInput.waitForDisplayed(); + await this.replyInput.click(); + await this.replyInput.setValue(text); + } + + async submitReply() { + await this.submitReplyButton.waitForDisplayed(); + await this.submitReplyButton.click(); + } + + async replyToTweet(text) { + await this.typeReply(text); + await this.submitReply(); + } + + async toggleLike() { + await this.likeButton.waitForDisplayed(); + await this.likeButton.click(); + } + + async openRetweetMenu() { + await this.retweetButton.waitForDisplayed(); + await this.retweetButton.click(); + } + + async repost() { + await this.openRetweetMenu(); + await this.repostOption.waitForDisplayed(); + await this.repostOption.click(); + } + + async openQuoteComposer() { + await this.openRetweetMenu(); + await this.quoteOption.waitForDisplayed(); + await this.quoteOption.click(); + } + + async clickShowMore() { + await this.showMoreButton.waitForDisplayed(); + await this.showMoreButton.click(); + } + + async getReplyCount() { + const replies = await this.replyContainers; + return replies.length; + } + + async isMainTweetDisplayed() { + return this.mainTweetContainer.isDisplayed(); + } + + async getLikeCountValue() { + const count = await this.likeCount.getText(); + return parseInt(count) || 0; + } + + async getRetweetCountValue() { + const count = await this.retweetCount.getText(); + return parseInt(count) || 0; + } + + async getReplyCountValue() { + const count = await this.replyCount.getText(); + return parseInt(count) || 0; + } +} + +export default new TweetDetailsScreen(); diff --git a/e2e/specs/accountInfoSettings.e2e.js b/e2e/specs/accountInfoSettings.e2e.js index 046189eb9..3fe69e302 100644 --- a/e2e/specs/accountInfoSettings.e2e.js +++ b/e2e/specs/accountInfoSettings.e2e.js @@ -1,15 +1,17 @@ -import { expect, driver } from '@wdio/globals'; - -import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page'; -import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page'; -import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page'; -import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page'; -import AccountInformationScreen from '../pageobjects/Settings pages/AccountInformationScreen.page'; -import AccountSettingsScreen from '../pageobjects/Settings pages/AccountSettingsScreen.page'; -import ChangeEmailScreen from '../pageobjects/Settings pages/ChangeEmailScreen.page'; -import ChangePasswordScreen from '../pageobjects/Settings pages/ChangePasswordScreen.page'; -import ChangeUsernameScreen from '../pageobjects/Settings pages/ChangeUsernameScreen.page'; -import SettingsScreen from '../pageobjects/Settings pages/SettingsScreen.page'; +import { driver, expect } from '@wdio/globals'; + +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import SidebarScreen from '../pageobjects/Home/SidebarScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import AccountInformationScreen from '../pageobjects/Settings/AccountInformationScreen.page'; +import AccountSettingsScreen from '../pageobjects/Settings/AccountSettingsScreen.page'; +import ChangeCountryScreen from '../pageobjects/Settings/ChangeCountryScreen.page'; +import ChangeEmailScreen from '../pageobjects/Settings/ChangeEmailScreen.page'; +import ChangePasswordScreen from '../pageobjects/Settings/ChangePasswordScreen.page'; +import ChangeUsernameScreen from '../pageobjects/Settings/ChangeUsernameScreen.page'; +import ConfirmChangeCountryScreen from '../pageobjects/Settings/ConfirmChangeCountryScreen.page'; +import SettingsScreen from '../pageobjects/Settings/SettingsScreen.page'; import StartScreen from '../pageobjects/StartScreen.page'; import { createTestUser } from '../utils/createTestUser'; import { fetchOTP } from '../utils/fetchOTP'; @@ -28,11 +30,23 @@ describe('Account Information Settings', () => { await ForYouScreen.openSideMenu(); await SidebarScreen.goToSettings(); await SettingsScreen.goToAccountSettings(); - await AccountSettingsScreen.goToAccountInformation(); }); - describe('Account Information Screen Tests', () => { - it('should display account information screen', async () => { + describe('Account Settings Screen Tests', () => { + it('should navigate to change password screen from account settings screen', async () => { + await AccountSettingsScreen.goToChangePassword(); + await ChangePasswordScreen.screenTitle.waitForDisplayed(); + await expect(await ChangePasswordScreen.isDisplayed()).toBe(true); + }); + + it('should correctly navigate back to account settings screen', async () => { + await ChangePasswordScreen.goBack(); + await AccountSettingsScreen.screenTitle.waitForDisplayed(); + await expect(await AccountSettingsScreen.isDisplayed()).toBe(true); + }); + + it('should navigate to and display account information screen', async () => { + await AccountSettingsScreen.goToAccountInformation(); await AccountInformationScreen.screenTitle.waitForDisplayed(); await expect(await AccountInformationScreen.isDisplayed()).toBe(true); }); @@ -189,6 +203,82 @@ describe('Account Information Settings', () => { }); }); + describe('Change Country Screen Tests', () => { + before(async () => { + await AccountInformationScreen.goToChangeCountry(); + }); + + it('should validate change country screen', async () => { + await ChangeCountryScreen.screenTitle.waitForDisplayed(); + await expect(await ChangeCountryScreen.isDisplayed()).toBe(true); + }); + + it('should find country when searching for it', async () => { + const countryName = 'Canada'; + await ChangeCountryScreen.searchForCountry(countryName); + await expect(await ChangeCountryScreen.isCountryDisplayed(countryName)).toBe(true); + }); + + it('should go to the confirmation screen when selecting a country', async () => { + const countryName = 'Canada'; + await ChangeCountryScreen.clickCountry(countryName); + await ConfirmChangeCountryScreen.changeButton.waitForDisplayed(); + await expect(await ConfirmChangeCountryScreen.isDisplayed()).toBe(true); + }); + + it('should handle canceling country change', async () => { + await ConfirmChangeCountryScreen.clickCancel(); + await ChangeCountryScreen.screenTitle.waitForDisplayed(); + await expect(await ChangeCountryScreen.isDisplayed()).toBe(true); + }); + + it('should find country without searching', async () => { + const countryName = 'Egypt'; + await ChangeCountryScreen.findCountry(countryName); + await expect(await ChangeCountryScreen.isCountryDisplayed(countryName)).toBe(true); + }); + + it('should show correct country in confirmation screen', async () => { + const countryName = 'Egypt'; + await ChangeCountryScreen.clickCountry(countryName); + await ConfirmChangeCountryScreen.changeButton.waitForDisplayed(); + await expect(await ConfirmChangeCountryScreen.isConfirmMessageDisplayed(countryName)).toBe( + true + ); + }); + + it('should successfully change country and navigate back to account information screen', async () => { + await ConfirmChangeCountryScreen.clickChange(); + await AccountInformationScreen.screenTitle.waitForDisplayed(); + await expect(await AccountInformationScreen.isDisplayed()).toBe(true); + }); + + it('should handle long country names correctly in confirm screen', async () => { + await AccountInformationScreen.goToChangeCountry(); + const longCountryName = 'Saint Helena, Ascension and Tristan da Cunha'; + await ChangeCountryScreen.findCountry(longCountryName); + await ChangeCountryScreen.clickCountry(longCountryName); + await ConfirmChangeCountryScreen.changeButton.waitForDisplayed(); + await expect( + await ConfirmChangeCountryScreen.isConfirmMessageDisplayed(longCountryName) + ).toBe(true); + }); + + it('should successfully change to long country name and navigate to account information screen', async () => { + await ConfirmChangeCountryScreen.clickChange(); + await AccountInformationScreen.screenTitle.waitForDisplayed(); + await expect(await AccountInformationScreen.isDisplayed()).toBe(true); + }); + + it('should show truncated version of country name in account information screen', async () => { + const longCountryName = 'Saint Helena, Ascension and Tristan da Cunha'; + const truncatedCountryName = longCountryName.slice(0, 25) + '...'; + await expect(await AccountInformationScreen.selectedCountry.getText()).toBe( + truncatedCountryName + ); + }); + }); + describe('Change Password Screen Tests', () => { before(async () => { await AccountInformationScreen.goToChangePassword(); diff --git a/e2e/specs/dms.e2e.js b/e2e/specs/dms.e2e.js new file mode 100644 index 000000000..ba5714eba --- /dev/null +++ b/e2e/specs/dms.e2e.js @@ -0,0 +1,176 @@ +import { expect, driver } from '@wdio/globals'; + +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import SidebarScreen from '../pageobjects/Home/SidebarScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import ChatScreen from '../pageobjects/Messages/ChatScreen.page'; +import MessagesScreen from '../pageobjects/Messages/MessagesScreen.page'; +import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page'; +import StartScreen from '../pageobjects/StartScreen.page'; +import { createTestUser } from '../utils/createTestUser'; + +let testUser, otherUser; + +describe('DMs Flow', () => { + const login = async (user) => { + await StartScreen.openLoginScreen(); + await LoginEmailScreen.enterEmail(user.email); + await LoginEmailScreen.clickNext(); + await LoginPasswordScreen.enterPassword(user.password); + await LoginPasswordScreen.clickLogin(); + await ForYouScreen.forYouTab.waitForDisplayed(); + }; + + before(async () => { + testUser = await createTestUser(); + otherUser = await createTestUser(); + await login(testUser); + await ForYouScreen.switchToMessagingTab(); + }); + + it('should initially show no conversations in the messages screen', async () => { + await MessagesScreen.screenTitle.waitForDisplayed(); + await expect(await MessagesScreen.isNoConversationsMessageDisplayed()).toBe(true); + }); + + it('should open the new message drawer when clicking on the new message button', async () => { + await MessagesScreen.clickNewMessageButton(); + await MessagesScreen.newMessageBackdrop.waitForDisplayed(); + await expect(await MessagesScreen.isNewMessageDrawerDisplayed()).toBe(true); + }); + + it('should close drawer upon clicking on the backdrop', async () => { + await MessagesScreen.newMessageBackdrop.click(); + await expect(await MessagesScreen.isNewMessageDrawerDisplayed()).toBe(false); + }); + + it('should handle long inputs in the search bar', async () => { + await MessagesScreen.clickNewMessageButton(); + await MessagesScreen.searchFor('a'.repeat(420)); + await expect(await MessagesScreen.newMessageSearchInput.getText()).toBe('a'.repeat(420)); + }); + + it('should close drawer when clicking on cancel', async () => { + await MessagesScreen.cancelNewMessage(); + await expect(await MessagesScreen.isNewMessageDrawerDisplayed()).toBe(false); + }); + + it('should still show no conversations after refreshing conversations list', async () => { + await MessagesScreen.refresh(); + await expect(await MessagesScreen.isNoConversationsMessageDisplayed()).toBe(true); + }); + + it('should go to messages screen from the sidebar', async () => { + await MessagesScreen.openSideMenu(); + await SidebarScreen.goToChat(); + await expect(await MessagesScreen.screenTitle.waitForDisplayed()).toBe(true); + }); + + it('should handle no users found when searching in new message drawer', async () => { + await MessagesScreen.clickNewMessageButton(); + await MessagesScreen.searchFor(`user${new Date()}`); + await expect(await MessagesScreen.isNoUsersFoundDisplayed()).toBe(true); + }); + + it('should handle finding users by searching for them', async () => { + await MessagesScreen.searchFor(otherUser.username); + await driver.pause(1000); + await expect(await MessagesScreen.newMessageRecipients.length).toBeGreaterThan(0); + }); + + it('should correctly open the chat screen when clicking on a user', async () => { + await MessagesScreen.newMessageRecipients[0].click(); + const recipientDisplayName = await MessagesScreen.recipientDisplayNames[0].getText(); + const recipientUsername = await MessagesScreen.recipientUsernames[0].getText(); + await ChatScreen.noMessagesText.waitForDisplayed(); + await expect(await ChatScreen.isCorrectUsernameDisplayed(recipientUsername)).toBe(true); + await expect(await ChatScreen.isCorrectDisplayNameDisplayed(recipientDisplayName)).toBe(true); + }); + + it('should initially show no messages yet in the conversation', async () => { + await expect(await ChatScreen.isNoMessagesYetDisplayed()).toBe(true); + }); + + it('should be able to send a message', async () => { + const msg = 'Hello!'; + await ChatScreen.typeMessage(msg); + await ChatScreen.sendMessage(); + await expect(await ChatScreen.isMessageDisplayed(msg)); + await expect(await ChatScreen.isMessageSentDisplayed()).toBe(true); + }); + + it('should handle very long messages', async () => { + const longMsg = 's'.repeat(911); + await ChatScreen.typeMessage(longMsg); + await ChatScreen.sendMessage(); + await expect(await ChatScreen.isMessageDisplayed(longMsg)); + await expect(await ChatScreen.isMessageSentDisplayed()).toBe(true); + }); + + it('should show the scroll to bottom icon when theres messages not in view', async () => { + const anotherLongMsg = 's'.repeat(911); + await ChatScreen.typeMessage(anotherLongMsg); + await ChatScreen.sendMessage(); + await ChatScreen.scrollUp(); + await expect(await ChatScreen.isScrollBottomDisplayed()).toBe(true); + }); + + it('should scroll to bottom of screen when clicking on its icon', async () => { + await ChatScreen.scrollToBottom(); + await expect(await ChatScreen.isScrollBottomDisplayed()).toBe(false); + }); + + it('should go to user profile when clicking on the user name', async () => { + await ChatScreen.goToUserProfile(otherUser.displayName); + await expect(await ProfileScreen.displayName.waitForDisplayed()).toBe(true); + await expect(await ProfileScreen.displayName.getText()).toBe(otherUser.displayName); + }); + + it('should navigate to chat screen when clicking on the messaging icon', async () => { + await ProfileScreen.clickOnMessageButton(); + await ChatScreen.messageInput.waitForDisplayed(); + await expect(await ChatScreen.isCorrectDisplayNameDisplayed(otherUser.displayName)).toBe(true); + }); + + it('should show messages sent previously', async () => { + await expect(await ChatScreen.isMessageDisplayed('s'.repeat(911))).toBe(true); + }); + + it('should go back to conversations list and show the user and message preview', async () => { + await ChatScreen.goBack(); + await ChatScreen.goBack(); + await ChatScreen.goBack(); + await MessagesScreen.screenTitle.waitForDisplayed(); + await expect(await MessagesScreen.conversations.length).toBeGreaterThan(0); + await expect(await MessagesScreen.conversationUsernames[0].getText()).toBe( + `@${otherUser.username}` + ); + await expect(await MessagesScreen.conversationPreviews[0].isDisplayed()).toBe(true); + }); + + it('show a new message badge when logging in as the other user', async () => { + await MessagesScreen.goBack(); + await ForYouScreen.openSideMenu(); + await SidebarScreen.logout(); + await StartScreen.welcomeText.waitForDisplayed(); + await login(otherUser); + await expect(await MessagesScreen.badgeCount.getText()).toBe('1'); + }); + + it('should show new message with unread indicator', async () => { + await ForYouScreen.switchToMessagingTab(); + await MessagesScreen.screenTitle.waitForDisplayed(); + await expect(await MessagesScreen.conversations.length).toBeGreaterThan(0); + await expect(await MessagesScreen.conversationPreviews[0].isDisplayed()).toBe(true); + await expect(await MessagesScreen.conversationUnreadIndicator[0].isDisplayed()).toBe(true); + }); + + it('should remove indicator after reading the message', async () => { + await MessagesScreen.conversations[0].click(); + await ChatScreen.messageInput.waitForDisplayed(); + await ChatScreen.goBack(); + await MessagesScreen.screenTitle.waitForDisplayed(); + await expect(await MessagesScreen.conversationUnreadIndicator.length).toBe(0); + }); +}); diff --git a/e2e/specs/editProfile.e2e.js b/e2e/specs/editProfile.e2e.js index 82405b3bf..d367b4c4b 100644 --- a/e2e/specs/editProfile.e2e.js +++ b/e2e/specs/editProfile.e2e.js @@ -1,14 +1,14 @@ import { expect } from '@wdio/globals'; -import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page'; -import OnboardingBioScreen from '../pageobjects/Home pages/OnboardingBioScreen.page'; -import OnboardingProfilePicScreen from '../pageobjects/Home pages/OnboardingProfilePicScreen.page'; -import OnboardingUsernameScreen from '../pageobjects/Home pages/OnboardingUsernameScreen.page'; -import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page'; -import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page'; -import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page'; -import EditProfileScreen from '../pageobjects/Profile pages/EditProfileScreen.page'; -import ProfileScreen from '../pageobjects/Profile pages/ProfileScreen.page'; +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import SidebarScreen from '../pageobjects/Home/SidebarScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import OnboardingBioScreen from '../pageobjects/Onboarding/OnboardingBioScreen.page'; +import OnboardingProfilePicScreen from '../pageobjects/Onboarding/OnboardingProfilePicScreen.page'; +import OnboardingUsernameScreen from '../pageobjects/Onboarding/OnboardingUsernameScreen.page'; +import EditProfileScreen from '../pageobjects/Profile/EditProfileScreen.page'; +import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page'; import StartScreen from '../pageobjects/StartScreen.page'; import { createTestUser } from '../utils/createTestUser'; diff --git a/e2e/specs/explore.e2e.js b/e2e/specs/explore.e2e.js new file mode 100644 index 000000000..7bdcd281b --- /dev/null +++ b/e2e/specs/explore.e2e.js @@ -0,0 +1,309 @@ +import { $, driver, expect } from '@wdio/globals'; + +import ExploreScreen from '../pageobjects/Explore/ExploreScreen.page'; +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page'; +import SearchFiltersScreen from '../pageobjects/Search/SearchFiltersScreen.page'; +import SearchScreen from '../pageobjects/Search/SearchScreen.page'; +import StartScreen from '../pageobjects/StartScreen.page'; +import { createTestUser } from '../utils/createTestUser'; + +let testUser; + +describe('Explore and Search Flow', () => { + const login = async () => { + await StartScreen.openLoginScreen(); + await LoginEmailScreen.enterEmail(testUser.email); + await LoginEmailScreen.clickNext(); + await LoginPasswordScreen.enterPassword(testUser.password); + await LoginPasswordScreen.clickLogin(); + await ForYouScreen.forYouTab.waitForDisplayed(); + }; + + const ensureOnExplore = async () => { + try { + await SearchScreen.searchBack.waitForDisplayed(); + await SearchScreen.searchBack.click(); + await driver.pause(500); + } catch { + // Not in search results screen, continue + } + + try { + await SearchScreen.cancelSearch.waitForDisplayed(); + await SearchScreen.cancelSearch.click(); + await driver.pause(500); + } catch { + // Not in search input mode, continue + } + + const exploreTab = await $('~ExploreTab'); + await exploreTab.waitForDisplayed({ timeout: 5000 }); + await exploreTab.click(); + await driver.pause(500); + + await ExploreScreen.forYouTab.waitForDisplayed({ timeout: 5000 }); + await ExploreScreen.waitForContentToLoad(); + }; + + before(async () => { + testUser = await createTestUser(); + await login(); + }); + + describe('Explore Tab Navigation', () => { + before(async () => { + await ForYouScreen.switchToExploreTab(); + await ExploreScreen.waitForContentToLoad(); + }); + + it('should display ForYou tab by default', async () => { + await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true); + }); + + it('should switch to Trending tab', async () => { + await ExploreScreen.switchToTrendingTab(); + await expect(await ExploreScreen.trendingList.waitForDisplayed()).toBe(true); + }); + + it('should switch to News tab', async () => { + await ExploreScreen.switchToNewsTab(); + await expect(await ExploreScreen.newsList.waitForDisplayed()).toBe(true); + }); + + it('should switch to Sports tab', async () => { + await ExploreScreen.switchToSportsTab(); + await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true); + }); + + it('should switch to Entertainment tab', async () => { + await ExploreScreen.switchToEntertainmentTab(); + await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true); + }); + }); + + describe('Search Screen Basic Flow', () => { + before(async () => { + await ensureOnExplore(); + await ExploreScreen.switchToForYouTab(); + }); + + it('should open search from explore', async () => { + await ExploreScreen.openSearch(); + await expect(await SearchScreen.searchInput.waitForDisplayed()).toBe(true); + }); + + it('should display empty state message when no query', async () => { + await expect(await SearchScreen.suggestionsEmpty.waitForDisplayed()).toBe(true); + }); + + it('should show cancel button', async () => { + await expect(await SearchScreen.cancelSearchButton.isDisplayed()).toBe(true); + }); + + it('should allow typing in search input', async () => { + await SearchScreen.searchInput.setValue('test'); + const value = await SearchScreen.searchInput.getText(); + await expect(value).toBe('test'); + }); + + it('should show clear button when query is entered', async () => { + await expect(await SearchScreen.clearSearchButton.waitForDisplayed()).toBe(true); + }); + + it('should clear search input with clear button', async () => { + await SearchScreen.clearSearch(); + const value = await SearchScreen.searchInput.getText(); + await expect(value === 'Search').toBe(true); + }); + + it('should cancel search and return to explore', async () => { + await SearchScreen.cancelSearch(); + await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true); + }); + }); + + describe('Search Submission and Results', () => { + before(async () => { + await ensureOnExplore(); + await ExploreScreen.openSearch(); + }); + + it('should submit search query and show results', async () => { + await SearchScreen.searchFor('test'); + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + }); + + it('should display settings (filters) button', async () => { + await expect(await SearchScreen.settingsButton.isDisplayed()).toBe(true); + }); + + it('should display Top tab by default', async () => { + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + }); + + it('should switch to Latest tab', async () => { + await SearchScreen.switchToLatestTab(); + await expect(await SearchScreen.latestTab.isDisplayed()).toBe(true); + }); + + it('should switch to People tab', async () => { + await SearchScreen.switchToPeopleTab(); + await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true); + }); + + it('should switch to Media tab', async () => { + await SearchScreen.switchToMediaTab(); + await expect(await SearchScreen.mediaTab.isDisplayed()).toBe(true); + }); + }); + + describe('Search Filters', () => { + it('should open search filters screen', async () => { + await SearchScreen.settingsButton.click(); + await SearchFiltersScreen.waitForScreen(); + await expect(await SearchFiltersScreen.screen.isDisplayed()).toBe(true); + }); + + it('should display people filter section', async () => { + await expect(await SearchFiltersScreen.peopleFilterSection.isDisplayed()).toBe(true); + }); + + it('should display safety filter section', async () => { + await expect(await SearchFiltersScreen.safetyFilterSection.isDisplayed()).toBe(true); + }); + + it('should select People you follow option', async () => { + await SearchFiltersScreen.selectPeopleYouFollow(); + await expect(await SearchFiltersScreen.isPeopleYouFollowSelected()).toBe(true); + }); + + it('should apply filters and close screen', async () => { + await SearchFiltersScreen.apply(); + await expect(await SearchScreen.backButton.waitForDisplayed()).toBe(true); + }); + + it('should preserve filter selection when reopening', async () => { + await SearchScreen.settingsButton.click(); + await SearchFiltersScreen.waitForScreen(); + await expect(await SearchFiltersScreen.isPeopleYouFollowSelected()).toBe(true); + }); + + it('should reset to From anyone and apply', async () => { + await SearchFiltersScreen.selectFromAnyone(); + await SearchFiltersScreen.apply(); + await expect(await SearchScreen.backButton.waitForDisplayed()).toBe(true); + }); + }); + + describe('Trending Tab', () => { + before(async () => { + await SearchScreen.goBack(); + await SearchScreen.goBack(); + }); + + it('should navigate to Trending tab', async () => { + await ExploreScreen.switchToTrendingTab(); + await ExploreScreen.waitForContentToLoad(); + await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true); + }); + + it('should display the trending list', async () => { + await expect(await ExploreScreen.trendingList.waitForDisplayed({ timeout: 5000 })).toBe(true); + }); + + it('should click on a trend and navigate to search results', async () => { + const clicked = await ExploreScreen.clickFirstTrendCard(); + if (clicked) { + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + await SearchScreen.goBack(); + await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true); + } + }); + }); + + describe('Hashtag Search', () => { + before(async () => { + await ensureOnExplore(); + }); + + it('should search for a hashtag and display results', async () => { + await ExploreScreen.openSearch(); + await SearchScreen.searchFor('#test'); + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + }); + + it('should switch to People tab and verify results list', async () => { + await SearchScreen.switchToPeopleTab(); + await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true); + const hasPeopleResults = await SearchScreen.isPeopleResultsDisplayed(); + await expect(hasPeopleResults).toBe(true); + }); + + it('should return to explore', async () => { + await SearchScreen.goBack(); + await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true); + }); + }); + + describe('Search Empty State', () => { + before(async () => { + await ensureOnExplore(); + await ExploreScreen.openSearch(); + }); + + it('should search for gibberish and show empty or no results', async () => { + await SearchScreen.searchFor('zzzxxx999nonexistent42'); + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + }); + + it('should display empty state or no results message', async () => { + await driver.pause(1000); + const hasEmptyState = await SearchScreen.isEmptyStateDisplayed(); + await expect(hasEmptyState).toBe(true); + }); + + it('should return to explore', async () => { + await SearchScreen.goBack(); + await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true); + }); + }); + + describe('Username Search with Profile Navigation', () => { + const targetUsername = 'notnowomar'; + + before(async () => { + await ensureOnExplore(); + await ExploreScreen.openSearch(); + }); + + it('should search for username and display results', async () => { + await SearchScreen.searchFor(`@${targetUsername}`); + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + }); + + it('should find the user in Top tab results', async () => { + await driver.pause(1000); + await driver.pause(1000); + const hasUser = await SearchScreen.hasUserInResults(targetUsername); + await expect(hasUser).toBe(true); + }); + + it('should click on user and navigate to their profile', async () => { + await SearchScreen.clickOnUserInResults(targetUsername); + await expect(await ProfileScreen.username.waitForDisplayed()).toBe(true); + }); + + it('should display the correct username on profile', async () => { + const usernameText = await ProfileScreen.username.getText(); + await expect(usernameText).toContain(targetUsername); + }); + + it('should go back to search results', async () => { + await ProfileScreen.goBack(); + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + }); + }); +}); diff --git a/e2e/specs/login.e2e.js b/e2e/specs/login.e2e.js index 38903959d..9a0eff45d 100644 --- a/e2e/specs/login.e2e.js +++ b/e2e/specs/login.e2e.js @@ -1,8 +1,8 @@ import { $, driver, expect } from '@wdio/globals'; -import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page'; -import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page'; -import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page'; +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; import StartScreen from '../pageobjects/StartScreen.page'; import { createTestUser } from '../utils/createTestUser'; diff --git a/e2e/specs/logout.e2e.js b/e2e/specs/logout.e2e.js index 37d0aecb0..fe327f0a2 100644 --- a/e2e/specs/logout.e2e.js +++ b/e2e/specs/logout.e2e.js @@ -1,12 +1,12 @@ import { browser, driver, expect } from '@wdio/globals'; -import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page'; -import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page'; -import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page'; -import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page'; -import AccountInformationScreen from '../pageobjects/Settings pages/AccountInformationScreen.page'; -import AccountSettingsScreen from '../pageobjects/Settings pages/AccountSettingsScreen.page'; -import SettingsScreen from '../pageobjects/Settings pages/SettingsScreen.page'; +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import SidebarScreen from '../pageobjects/Home/SidebarScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import AccountInformationScreen from '../pageobjects/Settings/AccountInformationScreen.page'; +import AccountSettingsScreen from '../pageobjects/Settings/AccountSettingsScreen.page'; +import SettingsScreen from '../pageobjects/Settings/SettingsScreen.page'; import StartScreen from '../pageobjects/StartScreen.page'; import { createTestUser } from '../utils/createTestUser'; diff --git a/e2e/specs/notifications.e2e.js b/e2e/specs/notifications.e2e.js new file mode 100644 index 000000000..79b439cb4 --- /dev/null +++ b/e2e/specs/notifications.e2e.js @@ -0,0 +1,248 @@ +import { expect, driver } from '@wdio/globals'; + +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import AllNotificationsScreen from '../pageobjects/Notifications/AllNotificationsScreen.page'; +import MentionsScreen from '../pageobjects/Notifications/MentionsScreen.page'; +import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page'; +import StartScreen from '../pageobjects/StartScreen.page'; +import TweetDetailsScreen from '../pageobjects/Tweets/TweetDetailsScreen.page'; +import { changeUsername } from '../utils/changeUsername'; +import { createTestTweets } from '../utils/createTestTweets'; +import { createTestUser } from '../utils/createTestUser'; +import { followUser } from '../utils/followUser'; +import { getAccessToken } from '../utils/getAccessToken'; +import { receiveTweetActions } from '../utils/receiveTweetActions'; + +let testUser, tweetId, token; + +describe('Notifications Flow', () => { + const login = async () => { + await StartScreen.openLoginScreen(); + await LoginEmailScreen.enterEmail(testUser.email); + await LoginEmailScreen.clickNext(); + await LoginPasswordScreen.enterPassword(testUser.password); + await LoginPasswordScreen.clickLogin(); + await ForYouScreen.forYouTab.waitForDisplayed(); + }; + + before(async () => { + testUser = await createTestUser(); + token = await getAccessToken(testUser.email, testUser.password); + testUser.username = await changeUsername(token); + await login(); + const tweetIds = await createTestTweets(token); + tweetId = tweetIds[0]; + await ForYouScreen.switchToNotificationsTab(); + }); + + it('should display Notifications screen', async () => { + await expect(await AllNotificationsScreen.screenTitle.waitForDisplayed()).toBe(true); + }); + + it('should initially show no notifications and show its message', async () => { + await expect(await AllNotificationsScreen.isNoNotificationsMessageDisplayed()).toBe(true); + }); + + describe('Follow notifications', () => { + before(async () => { + const numOfFollowers = 6; + await followUser(testUser.username, numOfFollowers); + }); + + it('should show all follow notifications after users follow me', async () => { + await AllNotificationsScreen.refresh(); + await expect(await AllNotificationsScreen.followNotifications.length).toBe(6); + await expect(await AllNotificationsScreen.followBackButton.length).toBe(6); + }); + + it('should correctly handle following back a user from notifications list', async () => { + await AllNotificationsScreen.followBackButton[0].click(); + await expect(await AllNotificationsScreen.followingButton.length).toBe(1); + }); + + it('should navigate to user profile when clicking on the notification', async () => { + const displayName = await AllNotificationsScreen.followNotifUserDisplayName[0].getText(); + await AllNotificationsScreen.followNotifications[0].click(); + await ProfileScreen.displayName.waitForDisplayed(); + await expect(await ProfileScreen.displayName.getText()).toBe(displayName); + }); + + it('should verify following status in user profile', async () => { + await expect(await ProfileScreen.isFollowingButtonDisplayed()).toBe(true); + }); + + it('should update state of the follow button after unfollowing user in profile', async () => { + await ProfileScreen.goBack(); + await AllNotificationsScreen.followingButton[0].click(); + await expect(await AllNotificationsScreen.followingButton.length).toBe(0); + await expect(await AllNotificationsScreen.followBackButton.length).toBe(6); + await AllNotificationsScreen.followNotifications[0].click(); + await expect(await ProfileScreen.isFollowBackButtonDisplayed()).toBe(true); + await ProfileScreen.goBack(); + }); + }); + + describe('Tweet Actions notifications', () => { + before(async () => { + const countPerAction = 1; + await receiveTweetActions(tweetId, countPerAction, { + like: true, + retweet: true, + quote: true, + reply: true, + }); + }); + + it('should correctly show all tweet actions notifications after users interact with my tweets', async () => { + await AllNotificationsScreen.refresh(); + await driver.pause(500); + await AllNotificationsScreen.refresh(); + await expect(await AllNotificationsScreen.likeNotifications.length).toBe(1); + await expect(await AllNotificationsScreen.retweetNotifications.length).toBe(1); + await expect(await AllNotificationsScreen.quoteNotifications.length).toBe(1); + await expect(await AllNotificationsScreen.replyNotifications.length).toBe(1); + }); + + it('should go to tweet view when clicking on a reply notification', async () => { + await AllNotificationsScreen.replyNotifications[0].click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should go to tweet view when clicking on a quote notification', async () => { + await TweetDetailsScreen.goBack(); + await AllNotificationsScreen.quoteNotifications[0].click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should go to tweet view when clicking on a retweet notification', async () => { + await TweetDetailsScreen.goBack(); + await AllNotificationsScreen.retweetNotifications[0].click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should go to tweet view when clicking on a like notification', async () => { + await TweetDetailsScreen.goBack(); + await AllNotificationsScreen.likeNotifications[0].click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should handle liking a tweet from the notification screen', async () => { + await TweetDetailsScreen.goBack(); + const initialCount = parseInt(await AllNotificationsScreen.likeCount[0].getText()); + await AllNotificationsScreen.likeIcon[0].click(); + await expect(await AllNotificationsScreen.likeCount[0].getText()).toBe( + String(initialCount + 1) + ); + }); + + it('should handle retweeting a tweet from the notification screen', async () => { + const initialCount = parseInt(await AllNotificationsScreen.retweetCount[0].getText()); + await AllNotificationsScreen.retweetIcon[0].click(); + await expect(await AllNotificationsScreen.retweetCount[0].getText()).toBe( + String(initialCount + 1) + ); + }); + + it('should navigate to tweet view when clicking on the reply icon', async () => { + await AllNotificationsScreen.replyIcon[0].click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should show new notifications on refresh', async () => { + await TweetDetailsScreen.goBack(); + await receiveTweetActions(tweetId, 5, { + like: true, + retweet: false, + quote: false, + reply: false, + }); + await AllNotificationsScreen.refresh(); + await driver.pause(500); + await AllNotificationsScreen.refresh(); + await expect(await AllNotificationsScreen.likeNotifications.length).toBe(5); + }); + }); + + describe('Mention Notifications', () => { + let newTweetId; + before(async () => { + const newUser = await createTestUser(); + const newToken = await getAccessToken(newUser.email, newUser.password); + const ids = await createTestTweets(newToken); + newTweetId = ids[0]; + await receiveTweetActions( + newTweetId, + 1, + { like: false, retweet: false, quote: false, reply: true }, + `@${testUser.username}` + ); + }); + + it('should show the mention notification in the all notifications tab', async () => { + await AllNotificationsScreen.refresh(); + await driver.pause(500); + await AllNotificationsScreen.refresh(); + await expect(await AllNotificationsScreen.mentionNotifications.length).toBe(1); + }); + + it('should navigate to tweet view when clicking on mention notification', async () => { + await AllNotificationsScreen.mentionNotifications[0].click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should initially show no mention notifications in mentions tab', async () => { + await TweetDetailsScreen.goBack(); + await AllNotificationsScreen.goToMentionsTab(); + await expect(await MentionsScreen.isNoNotificationsMessageDisplayed()).toBe(true); + }); + + it('should show the mention notification in the mentions tab', async () => { + await MentionsScreen.refresh(); + await expect(await MentionsScreen.mentionNotifications.length).toBe(1); + }); + + it('should handle liking mention from mentions tab', async () => { + const initialCount = parseInt(await MentionsScreen.likeCount[0].getText()); + await MentionsScreen.likeIcon[0].click(); + await expect(await MentionsScreen.likeCount[0].getText()).toBe(String(initialCount + 1)); + }); + + it('should handle retweeting mention from mentions tab', async () => { + const initialCount = parseInt(await MentionsScreen.retweetCount[0].getText()); + await MentionsScreen.retweetIcon[0].click(); + await expect(await MentionsScreen.retweetCount[0].getText()).toBe(String(initialCount + 1)); + }); + + it('should navigate to mention tweet view when clicking on reply icon in mentions tab', async () => { + await MentionsScreen.replyIcon[0].click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should handle showing multiple new mention notifications', async () => { + await TweetDetailsScreen.goBack(); + await receiveTweetActions( + newTweetId, + 3, + { + like: false, + retweet: false, + quote: true, + reply: false, + }, + `@${testUser.username}` + ); + await MentionsScreen.refresh(); + await driver.pause(1000); + await expect(await MentionsScreen.mentionNotifications.length).toBe(4); + }); + }); +}); diff --git a/e2e/specs/onboarding.e2e.js b/e2e/specs/onboarding.e2e.js index 654afa1c2..f8fac12b4 100644 --- a/e2e/specs/onboarding.e2e.js +++ b/e2e/specs/onboarding.e2e.js @@ -1,13 +1,15 @@ -import { expect, driver } from '@wdio/globals'; - -import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page'; -import OnboardingBioScreen from '../pageobjects/Home pages/OnboardingBioScreen.page'; -import OnboardingProfilePicScreen from '../pageobjects/Home pages/OnboardingProfilePicScreen.page'; -import OnboardingUsernameScreen from '../pageobjects/Home pages/OnboardingUsernameScreen.page'; -import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page'; -import SignupStep1Page from '../pageobjects/SignUp pages/SignupStep1.page'; -import SignupStep2Page from '../pageobjects/SignUp pages/SignupStep2.page'; -import SignupStep3Page from '../pageobjects/SignUp pages/SignupStep3.page'; +import { driver, expect } from '@wdio/globals'; + +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import SidebarScreen from '../pageobjects/Home/SidebarScreen.page'; +import OnboardingBioScreen from '../pageobjects/Onboarding/OnboardingBioScreen.page'; +import OnboardingFollowScreen from '../pageobjects/Onboarding/OnboardingFollowScreen.page'; +import OnboardingInterestsScreen from '../pageobjects/Onboarding/OnboardingInterestsScreen.page'; +import OnboardingProfilePicScreen from '../pageobjects/Onboarding/OnboardingProfilePicScreen.page'; +import OnboardingUsernameScreen from '../pageobjects/Onboarding/OnboardingUsernameScreen.page'; +import SignupStep1Page from '../pageobjects/SignUp/SignupStep1.page'; +import SignupStep2Page from '../pageobjects/SignUp/SignupStep2.page'; +import SignupStep3Page from '../pageobjects/SignUp/SignupStep3.page'; import StartScreen from '../pageobjects/StartScreen.page'; import { fetchOTP } from '../utils/fetchOTP'; @@ -22,7 +24,7 @@ describe('Onboarding Flow', () => { SignupStep1Page.generateTestName(), SignupStep1Page.generateTestEmail() ); - await driver.pause(2000); + await SignupStep2Page.screenTitle.waitForDisplayed(); const otp = await fetchOTP(SignupStep1Page.testEmail, 'registration'); await SignupStep2Page.completeStep2(otp); await SignupStep3Page.completeStep3(SignupStep1Page.testPassword); @@ -67,6 +69,7 @@ describe('Onboarding Flow', () => { }); it('should show error for short usernames', async () => { + await OnboardingUsernameScreen.screenTitle.waitForDisplayed(); await OnboardingUsernameScreen.enterUsername('ab'); await expect(await OnboardingUsernameScreen.isShortUsernameErrorMessageDisplayed()).toBe( true @@ -120,20 +123,42 @@ describe('Onboarding Flow', () => { chosenSuggestion = await OnboardingUsernameScreen.usernameInput.getText(); }); - it('should proceed to the bio onboarding step with a valid username', async () => { + it('should proceed to the interests onboarding step when clicking next', async () => { await OnboardingUsernameScreen.clickNext(); - await OnboardingBioScreen.screenTitle.waitForDisplayed(); - await expect(await OnboardingBioScreen.isDisplayed()).toBe(true); + await OnboardingInterestsScreen.screenTitle.waitForDisplayed(); + await expect(await OnboardingInterestsScreen.isDisplayed()).toBe(true); }); it('should preserve username when navigating back to the username screen', async () => { - await OnboardingBioScreen.goBack(); + await OnboardingInterestsScreen.goBack(); await OnboardingUsernameScreen.screenTitle.waitForDisplayed(); await expect(await OnboardingUsernameScreen.isNextButtonEnabled()).toBe(true); await expect(await OnboardingUsernameScreen.usernameInput.getText()).toBe(chosenSuggestion); await OnboardingUsernameScreen.clickNext(); }); + it('should enable next button upon selecting at least one interest', async () => { + await OnboardingInterestsScreen.clickInterests(['Medical']); // Select + await expect(await OnboardingInterestsScreen.isNoneSelectedMessageDisplayed()).toBe(false); + }); + + it('should allow deselecting interests', async () => { + await OnboardingInterestsScreen.clickInterests(['Medical']); // Deselect + await driver.pause(100); + await expect(await OnboardingInterestsScreen.isNoneSelectedMessageDisplayed()).toBe(true); + }); + + it('should allow selecting multiple interests', async () => { + await OnboardingInterestsScreen.clickInterests(['Tech', 'Culture', 'Travel']); + await expect(await OnboardingInterestsScreen.isNoneSelectedMessageDisplayed()).toBe(false); + }); + + it('should proceed to the bio onboarding step when clicking next', async () => { + await OnboardingInterestsScreen.clickNext(); + await OnboardingBioScreen.screenTitle.waitForDisplayed(); + await expect(await OnboardingBioScreen.isDisplayed()).toBe(true); + }); + it('should have next button disabled initially on bio screen', async () => { await OnboardingBioScreen.screenTitle.waitForDisplayed(); await expect(await OnboardingBioScreen.isNextButtonEnabled()).toBe(false); @@ -151,14 +176,54 @@ describe('Onboarding Flow', () => { await expect(enteredBio.length).toBe(160); }); - it('should complete onboarding and navigate to home screen upon completing bio', async () => { + it('should proceed to follow screen when clicking next', async () => { await OnboardingBioScreen.enterBio('Hello, this is my bio!'); await OnboardingBioScreen.clickNext(); + await OnboardingFollowScreen.screenTitle.waitForDisplayed(); + await expect(await OnboardingFollowScreen.screenTitle.waitForDisplayed()).toBe(true); + }); + + it('should show follow suggestions on follow screen', async () => { + await OnboardingFollowScreen.screenTitle.waitForDisplayed(); + await expect(await OnboardingFollowScreen.interactionButtons.length).toBeGreaterThan(0); + }); + + it('should have next button disabled initially on follow screen', async () => { + await OnboardingFollowScreen.screenTitle.waitForDisplayed(); + await OnboardingFollowScreen.clickNext(); + await expect(await OnboardingFollowScreen.screenTitle.isDisplayed()).toBe(true); + }); + + it('should enable next button upon selecting at least one follow suggestion', async () => { + await OnboardingFollowScreen.interactionButtons[0].click(); + await expect(await OnboardingFollowScreen.isNextButtonEnabled()).toBe(true); + }); + + it('should disable next button again upon unfollowing that suggestion', async () => { + await OnboardingFollowScreen.interactionButtons[0].click(); + await driver.pause(500); + await OnboardingFollowScreen.clickNext(); + await expect(await OnboardingFollowScreen.screenTitle.isDisplayed()).toBe(true); + }); + + it('should handle following multiple users from suggestions', async () => { + for (let i = 0; i < (await OnboardingFollowScreen.interactionButtons.length); i++) { + await OnboardingFollowScreen.interactionButtons[i].click(); + await driver.pause(500); + } + await expect(await OnboardingFollowScreen.isNextButtonEnabled()).toBe(true); + await expect(await OnboardingFollowScreen.followingButtons.length).toBe( + await OnboardingFollowScreen.interactionButtons.length + ); + }); + + it('should navigate to for you screen upon clicking next', async () => { + await OnboardingFollowScreen.clickNext(); await ForYouScreen.forYouTab.waitForDisplayed(); - await expect(ForYouScreen.forYouTab).toBeDisplayed(); + await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true); }); - it('should have the correct username in the profile after onboarding', async () => { + it('should reflect all changes in profile after onboarding', async () => { await ForYouScreen.openSideMenu(); await expect(await SidebarScreen.currentUsername.getText()).toBe(chosenSuggestion); }); @@ -192,17 +257,32 @@ describe('Onboarding Flow', () => { await expect(await OnboardingUsernameScreen.isDisplayed()).toBe(true); }); - it('should allow skipping username screen and proceed to bio screen', async () => { + it('should allow skipping username screen and proceed to interests screen', async () => { chosenSuggestion = await OnboardingUsernameScreen.usernameInput.getText(); await OnboardingUsernameScreen.clickSkip(); + await OnboardingInterestsScreen.screenTitle.waitForDisplayed(); + await expect(await OnboardingInterestsScreen.isDisplayed()).toBe(true); + }); + + it('should still need one interest selected to proceed to bio screen', async () => { + await OnboardingInterestsScreen.clickInterests(['Food']); + await OnboardingInterestsScreen.clickNext(); await OnboardingBioScreen.screenTitle.waitForDisplayed(); await expect(await OnboardingBioScreen.isDisplayed()).toBe(true); }); - it('should allow skipping bio screen and complete onboarding', async () => { + it('should allow skipping bio screen and proceed to follow screen', async () => { await OnboardingBioScreen.clickSkip(); + await OnboardingFollowScreen.screenTitle.waitForDisplayed(); + await expect(await OnboardingFollowScreen.screenTitle.isDisplayed()).toBe(true); + }); + + it('should still need at least one follow to complete onboarding', async () => { + await OnboardingFollowScreen.interactionButtons[0].click(); + await driver.pause(500); + await OnboardingFollowScreen.clickNext(); await ForYouScreen.forYouTab.waitForDisplayed(); - await expect(ForYouScreen.forYouTab).toBeDisplayed(); + await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true); }); it('should have handled username skipping by defaulting to first suggestion', async () => { diff --git a/e2e/specs/resetPassword.e2e.js b/e2e/specs/resetPassword.e2e.js index 2445133c9..4c7c4ea1b 100644 --- a/e2e/specs/resetPassword.e2e.js +++ b/e2e/specs/resetPassword.e2e.js @@ -1,12 +1,12 @@ -import { expect, $, driver } from '@wdio/globals'; - -import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page'; -import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page'; -import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page'; -import ConfirmCodeResetPasswordScreen from '../pageobjects/Reset Password pages/ConfirmCodeResetPasswordScreen.page'; -import ConfirmMethodResetPasswordScreen from '../pageobjects/Reset Password pages/ConfirmMethodResetPasswordScreen.page'; -import EmailResetPasswordScreen from '../pageobjects/Reset Password pages/EmailResetPasswordScreen.page'; -import NewPasswordResetPasswordScreen from '../pageobjects/Reset Password pages/NewPasswordResetPasswordScreen.page'; +import { $, driver, expect } from '@wdio/globals'; + +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import ConfirmCodeResetPasswordScreen from '../pageobjects/Reset Password/ConfirmCodeResetPasswordScreen.page'; +import ConfirmMethodResetPasswordScreen from '../pageobjects/Reset Password/ConfirmMethodResetPasswordScreen.page'; +import EmailResetPasswordScreen from '../pageobjects/Reset Password/EmailResetPasswordScreen.page'; +import NewPasswordResetPasswordScreen from '../pageobjects/Reset Password/NewPasswordResetPasswordScreen.page'; import StartScreen from '../pageobjects/StartScreen.page'; import { createTestUser } from '../utils/createTestUser'; import { fetchOTP } from '../utils/fetchOTP'; diff --git a/e2e/specs/settings.e2e.js b/e2e/specs/settings.e2e.js new file mode 100644 index 000000000..77827eaf1 --- /dev/null +++ b/e2e/specs/settings.e2e.js @@ -0,0 +1,138 @@ +import { expect } from '@wdio/globals'; + +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import SidebarScreen from '../pageobjects/Home/SidebarScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import AppearanceSettingsScreen from '../pageobjects/Settings/AppearanceSettingsScreen.page'; +import BlockedListScreen from '../pageobjects/Settings/BlockedListScreen.page'; +import ContentYouSeeScreen from '../pageobjects/Settings/ContentYouSeeScreen.page'; +import InterestsScreen from '../pageobjects/Settings/InterestsScreen.page'; +import MuteAndBlockScreen from '../pageobjects/Settings/MuteAndBlockScreen.page'; +import MutedListScreen from '../pageobjects/Settings/MutedListScreen.page'; +import PrivacySettingsScreen from '../pageobjects/Settings/PrivacySettingsScreen.page'; +import SettingsScreen from '../pageobjects/Settings/SettingsScreen.page'; +import StartScreen from '../pageobjects/StartScreen.page'; +import { blockUser } from '../utils/blockUser'; +import { createTestUser } from '../utils/createTestUser'; +import { muteUser } from '../utils/muteUser'; + +let testUser; + +describe('Settings Flow', () => { + const login = async () => { + await StartScreen.openLoginScreen(); + await LoginEmailScreen.enterEmail(testUser.email); + await LoginEmailScreen.clickNext(); + await LoginPasswordScreen.enterPassword(testUser.password); + await LoginPasswordScreen.clickLogin(); + await ForYouScreen.forYouTab.waitForDisplayed(); + }; + + before(async () => { + testUser = await createTestUser(); + await login(); + await ForYouScreen.openSideMenu(); + }); + + describe('Theme Switcher Settings', () => { + it('should handle changing theme from sidebar', async () => { + await SidebarScreen.openThemeSwitcher(); + await SidebarScreen.chooseDarkTheme(); + await expect(await SidebarScreen.darkThemeButton.waitForDisplayed()).toBe(true); + await SidebarScreen.chooseLightTheme(); + await expect(await SidebarScreen.lightThemeButton.waitForDisplayed()).toBe(true); + await SidebarScreen.chooseSystemTheme(); + await expect(await SidebarScreen.systemThemeButton.waitForDisplayed()).toBe(true); + }); + + it('should handle changing theme in appearance settings', async () => { + await SidebarScreen.goBack(); + await ForYouScreen.openSideMenu(); + await SidebarScreen.goToSettings(); + await SettingsScreen.goToAppearanceSettings(); + await AppearanceSettingsScreen.chooseDarkTheme(); + await expect(await AppearanceSettingsScreen.darkThemeButton.waitForDisplayed()).toBe(true); + await AppearanceSettingsScreen.chooseLightTheme(); + await expect(await AppearanceSettingsScreen.lightThemeButton.waitForDisplayed()).toBe(true); + await AppearanceSettingsScreen.chooseSystemTheme(); + await expect(await AppearanceSettingsScreen.systemThemeButton.waitForDisplayed()).toBe(true); + }); + }); + + describe('Mute and Block Settings', () => { + before(async () => { + await AppearanceSettingsScreen.goBack(); + await SettingsScreen.goToPrivacySettings(); + await PrivacySettingsScreen.goToMuteAndBlock(); + }); + + it('should display mute and block settings correctly', async () => { + await expect(await MuteAndBlockScreen.screenTitle.waitForDisplayed()).toBe(true); + await expect(await MuteAndBlockScreen.blockedAccountsList.isDisplayed()).toBe(true); + await expect(await MuteAndBlockScreen.mutedAccountsList.isDisplayed()).toBe(true); + }); + + it('should handle empty blocked list', async () => { + await MuteAndBlockScreen.goToBlockedList(); + await expect(await BlockedListScreen.noBlockedAccounts.waitForDisplayed()).toBe(true); + }); + + it('should show blocked users in list', async () => { + await blockUser(testUser.email, testUser.password, 3); + await BlockedListScreen.refresh(); + await expect(await BlockedListScreen.blockedAccountsList.length).toBe(3); + }); + + it('should unblock a user via blocked button', async () => { + await BlockedListScreen.unblockFirstUser(); + await BlockedListScreen.refresh(); + await expect(await BlockedListScreen.blockedAccountsList.length).toBe(2); + }); + + it('should handle empty muted list', async () => { + await BlockedListScreen.goBack(); + await MuteAndBlockScreen.goToMutedList(); + await expect(await MutedListScreen.noMutedAccounts.waitForDisplayed()).toBe(true); + }); + + it('should show muted users in list', async () => { + await muteUser(testUser.email, testUser.password, 3); + await MutedListScreen.refresh(); + await expect(await MutedListScreen.mutedAccountsList.length).toBe(3); + }); + + it('should unmute a user via mute icon', async () => { + await MutedListScreen.unmuteFirstUser(); + await MutedListScreen.refresh(); + await expect(await MutedListScreen.mutedAccountsList.length).toBe(2); + }); + }); + + describe('Interests Settings', () => { + before(async () => { + await MutedListScreen.goBack(); + await MuteAndBlockScreen.goBack(); + await PrivacySettingsScreen.goToContentYouSee(); + await ContentYouSeeScreen.goToInterests(); + }); + + it('should display interests settings correctly', async () => { + await expect(await InterestsScreen.screenTitle.waitForDisplayed()).toBe(true); + }); + + it('should have save button disabled when no interests are picked', async () => { + await expect(await InterestsScreen.isSaveButtonEnabled()).toBe(false); + }); + + it('should have save button enabled when interests are picked', async () => { + await InterestsScreen.selectInterests(['Finance', 'General']); + await expect(await InterestsScreen.isSaveButtonEnabled()).toBe(true); + }); + + it('should save interests and navigate back to content you see screen', async () => { + await InterestsScreen.saveChanges(); + await expect(await ContentYouSeeScreen.screenTitle.waitForDisplayed()).toBe(true); + }); + }); +}); diff --git a/e2e/specs/signup.e2e.js b/e2e/specs/signup.e2e.js index dcb76b50e..ef38b32da 100644 --- a/e2e/specs/signup.e2e.js +++ b/e2e/specs/signup.e2e.js @@ -1,9 +1,9 @@ import { expect, driver } from '@wdio/globals'; -import OnboardingProfilePicScreen from '../pageobjects/Home pages/OnboardingProfilePicScreen.page'; -import SignupStep1Page from '../pageobjects/SignUp pages/SignupStep1.page'; -import SignupStep2Page from '../pageobjects/SignUp pages/SignupStep2.page'; -import SignupStep3Page from '../pageobjects/SignUp pages/SignupStep3.page'; +import OnboardingProfilePicScreen from '../pageobjects/Onboarding/OnboardingProfilePicScreen.page'; +import SignupStep1Page from '../pageobjects/SignUp/SignupStep1.page'; +import SignupStep2Page from '../pageobjects/SignUp/SignupStep2.page'; +import SignupStep3Page from '../pageobjects/SignUp/SignupStep3.page'; import StartScreen from '../pageobjects/StartScreen.page'; import { fetchOTP } from '../utils/fetchOTP'; diff --git a/e2e/specs/tweets.e2e.js b/e2e/specs/tweets.e2e.js new file mode 100644 index 000000000..c0d70d1ae --- /dev/null +++ b/e2e/specs/tweets.e2e.js @@ -0,0 +1,426 @@ +import { driver, expect } from '@wdio/globals'; + +import FollowingScreen from '../pageobjects/Home/FollowingScreen.page'; +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import StartScreen from '../pageobjects/StartScreen.page'; +import TweetComposerScreen from '../pageobjects/Tweets/TweetComposerScreen.page'; +import TweetDetailsScreen from '../pageobjects/Tweets/TweetDetailsScreen.page'; +import { createTestUser } from '../utils/createTestUser'; + +let testUser; + +describe('Tweets Flow', () => { + const login = async () => { + await StartScreen.openLoginScreen(); + await LoginEmailScreen.enterEmail(testUser.email); + await LoginEmailScreen.clickNext(); + await LoginPasswordScreen.enterPassword(testUser.password); + await LoginPasswordScreen.clickLogin(); + await ForYouScreen.forYouTab.waitForDisplayed(); + }; + + before(async () => { + testUser = await createTestUser(); + await login(); + }); + + describe('Creating Tweet Tests', () => { + before(async () => { + await ForYouScreen.clickNewTweetButton(); + }); + + it('should show tweet composer screen', async () => { + await expect(await TweetComposerScreen.isDisplayed()).toBe(true); + }); + + it('should initially have post button disabled when composer is empty', async () => { + await expect(await TweetComposerScreen.isPostButtonEnabled()).toBe(false); + }); + + it('should enable post button upon entering any input', async () => { + await TweetComposerScreen.writeTweet('Hello!'); + await expect(await TweetComposerScreen.isPostButtonEnabled()).toBe(true); + }); + + it('should disable posting tweet when input is too long', async () => { + await TweetComposerScreen.writeTweet('s'.repeat(281)); + await expect(await TweetComposerScreen.isPostButtonEnabled()).toBe(false); + }); + + it('should show discard modal when going back with unsaved changes', async () => { + await TweetComposerScreen.goBack(); + await expect(await TweetComposerScreen.isDiscardModalDisplayed()).toBe(true); + }); + + it('should stay on composer screen when canceling discard', async () => { + await TweetComposerScreen.cancelDiscardTweet(); + await expect(await TweetComposerScreen.isDisplayed()).toBe(true); + }); + + it('should discard tweet when discarding', async () => { + await TweetComposerScreen.goBack(); + await TweetComposerScreen.discardTweet(); + await expect(await ForYouScreen.isDisplayed()).toBe(true); + }); + + it('should handle posting tweet with only text', async () => { + await ForYouScreen.clickNewTweetButton(); + await TweetComposerScreen.tweetInput.waitForDisplayed(); + await TweetComposerScreen.writeTweet('Hello World!'); + await TweetComposerScreen.postTweet(); + await ForYouScreen.forYouTab.waitForDisplayed(); + await ForYouScreen.switchToFollowingTab(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await driver.pause(500); + await expect(await FollowingScreen.tweetCard.length).toBe(1); + }); + + it('should verify posted tweet is the same as input', async () => { + await expect(await FollowingScreen.tweetContent[0].getText()).toBe('Hello World!'); + }); + + it('should handle choosing media in composer', async () => { + await FollowingScreen.clickNewTweetButton(); + await TweetComposerScreen.tweetInput.waitForDisplayed(); + try { + await expect(await TweetComposerScreen.composerMediaImages.length).toBeGreaterThan(0); + await TweetComposerScreen.composerMediaImages[0].click(); + await driver.pause(500); + await expect(await TweetComposerScreen.composerRemoveMedia.length).toBe(1); + } catch (error) { + console.error(error); + } + }); + + it('should max out at 4 images', async () => { + try { + await TweetComposerScreen.composerMediaImages[1].click(); + await driver.pause(100); + await TweetComposerScreen.composerMediaImages[2].click(); + await driver.pause(100); + await TweetComposerScreen.composerMediaImages[3].click(); + await driver.pause(100); + await expect(await TweetComposerScreen.composerRemoveMedia.length).toBeGreaterThan(0); + } catch (error) { + console.error(error); + } + }); + + it('should handle removing media', async () => { + try { + for (let i = 0; i < 4; i++) { + await TweetComposerScreen.composerRemoveMedia[0].click(); + await driver.pause(500); + } + await expect(await TweetComposerScreen.composerRemoveMedia.length).toBe(0); + } catch (error) { + console.error(error); + } + }); + + it('should handle posting tweet with media', async () => { + try { + await TweetComposerScreen.composerMediaImages[0].click(); + await driver.pause(100); + await TweetComposerScreen.postTweet(); + await ForYouScreen.forYouTab.waitForDisplayed(); + await ForYouScreen.switchToFollowingTab(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await driver.pause(500); + await expect(await FollowingScreen.tweetCard.length).toBe(2); + } catch (error) { + console.error(error); + } + }); + + it('should handle posting tweet with mention, hashtag, and link', async () => { + await FollowingScreen.clickNewTweetButton(); + await TweetComposerScreen.tweetInput.waitForDisplayed(); + await TweetComposerScreen.writeTweet(`@${testUser.username} #test https://raven.cmp27.space`); + await TweetComposerScreen.postTweet(); + await ForYouScreen.forYouTab.waitForDisplayed(); + await ForYouScreen.switchToFollowingTab(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await driver.pause(500); + await expect(await FollowingScreen.tweetCard.length).toBe(3); + }); + }); + + describe('Timeline Navigation Tests', () => { + before(async () => { + await ForYouScreen.forYouTab.waitForDisplayed(); + }); + + it('should switch between For You and Following tabs', async () => { + await expect(await FollowingScreen.isDisplayed()).toBe(true); + + await ForYouScreen.forYouTab.click(); + await driver.pause(300); + await expect(await ForYouScreen.forYouTab).toBeDisplayed(); + + await ForYouScreen.switchToFollowingTab(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await expect(await FollowingScreen.screenTitle).toBeDisplayed(); + }); + + it('should display tweet cards on timeline', async () => { + const tweetCards = await FollowingScreen.tweetCard; + await expect(tweetCards.length).toBeGreaterThan(0); + }); + + it('should navigate to tweet detail when clicking on a tweet', async () => { + const firstTweet = await FollowingScreen.tweetCard[0]; + await firstTweet.click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should go back to timeline from tweet detail', async () => { + await TweetDetailsScreen.goBack(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await expect(await FollowingScreen.isDisplayed()).toBe(true); + }); + }); + + describe('Thread View Tests', () => { + before(async () => { + await FollowingScreen.screenTitle.waitForDisplayed(); + const firstTweet = await FollowingScreen.tweetCard[0]; + await firstTweet.click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + }); + + it('should display main tweet in detail view', async () => { + await expect(await TweetDetailsScreen.isMainTweetDisplayed()).toBe(true); + }); + + it('should display tweet content in detail view', async () => { + const contentElements = await TweetDetailsScreen.tweetContent; + await expect(contentElements.length).toBeGreaterThan(0); + }); + + it('should display tweet action buttons', async () => { + await expect(await TweetDetailsScreen.likeButton).toBeDisplayed(); + await expect(await TweetDetailsScreen.retweetButton).toBeDisplayed(); + await expect(await TweetDetailsScreen.replyButton).toBeDisplayed(); + }); + + it('should display reply input at bottom', async () => { + await expect(await TweetDetailsScreen.replyInput).toBeDisplayed(); + }); + + after(async () => { + await TweetDetailsScreen.goBack(); + await FollowingScreen.screenTitle.waitForDisplayed(); + }); + }); + + describe('Reply Flow Tests', () => { + let initialReplyCount; + + before(async () => { + const firstTweet = await FollowingScreen.tweetCard[0]; + await firstTweet.click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + initialReplyCount = await TweetDetailsScreen.getReplyCountValue(); + }); + + it('should show reply input with placeholder', async () => { + const replyInput = await TweetDetailsScreen.replyInput; + await expect(replyInput).toBeDisplayed(); + }); + + it('should submit a reply using quick reply input', async () => { + await TweetDetailsScreen.typeReply('This is a test reply!'); + await TweetDetailsScreen.submitReply(); + await driver.pause(1000); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + it('should update reply count after replying', async () => { + await driver.pause(500); + const newReplyCount = await TweetDetailsScreen.getReplyCountValue(); + expect(newReplyCount).toBe(initialReplyCount + 1); + }); + + it('should show the reply in the thread', async () => { + const replies = await TweetDetailsScreen.replyContainers; + await expect(replies.length).toBeGreaterThan(0); + }); + + it('should navigate to reply detail when clicking on a reply', async () => { + const replies = await TweetDetailsScreen.replyContainers; + if (replies.length > 0) { + await replies[0].click(); + await driver.pause(500); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + await TweetDetailsScreen.goBack(); + } + }); + + after(async () => { + await TweetDetailsScreen.goBack(); + await FollowingScreen.screenTitle.waitForDisplayed(); + }); + }); + + describe('Tweet Interaction Tests', () => { + it('should show delete tweet modal when deleting tweet', async () => { + await FollowingScreen.tweetDrawerButton[0].click(); + await FollowingScreen.deleteTweet(); + await FollowingScreen.deleteTweetModal.waitForDisplayed(); + await expect(await FollowingScreen.isDeleteTweetModalDisplayed()).toBe(true); + }); + + it('should not delete tweet when canceling deletion', async () => { + const initialCount = 3; + await FollowingScreen.cancelDeleteTweet(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await expect(await FollowingScreen.tweetCard.length).toBe(initialCount); + }); + + it('should delete tweet when confirming deletion', async () => { + const initialCount = await FollowingScreen.tweetCard.length; + await FollowingScreen.tweetDrawerButton[0].click(); + await FollowingScreen.deleteTweet(); + await FollowingScreen.confirmDeleteTweet(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await driver.pause(500); + await expect(await FollowingScreen.tweetCard.length).toBe(initialCount - 1); + }); + + it('should show ai summary when clicking on its icon', async () => { + const aiButtons = await FollowingScreen.aiSummaryButton; + if (aiButtons.length > 0) { + await aiButtons[0].click(); + await driver.pause(2000); + const aiTexts = await FollowingScreen.aiSummaryText; + if (aiTexts.length > 0) { + await expect(await aiTexts[0].waitForDisplayed()).toBe(true); + } + } + }); + + it('should handle liking tweet', async () => { + const initialLikeCount = await FollowingScreen.tweetLikeCount[0].getText(); + await FollowingScreen.tweetLikeButton[0].click(); + await driver.pause(300); + const updatedLikeCount = await FollowingScreen.tweetLikeCount[0].getText(); + expect(parseInt(updatedLikeCount)).toBe(parseInt(initialLikeCount) + 1); + }); + + it('should handle unliking tweet', async () => { + const initialLikeCount = await FollowingScreen.tweetLikeCount[0].getText(); + await FollowingScreen.tweetLikeButton[0].click(); + await driver.pause(300); + const updatedLikeCount = await FollowingScreen.tweetLikeCount[0].getText(); + expect(parseInt(updatedLikeCount)).toBe(parseInt(initialLikeCount) - 1); + }); + + it('should handle retweeting tweet', async () => { + const initialRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText(); + await FollowingScreen.tweetRetweetButton[0].click(); + await FollowingScreen.chooseRepostFromDrawer(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await driver.pause(300); + const updatedRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText(); + expect(parseInt(updatedRetweetCount)).toBe(parseInt(initialRetweetCount) + 1); + }); + + it('should handle undo retweet', async () => { + const initialRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText(); + await FollowingScreen.tweetRetweetButton[0].click(); + await FollowingScreen.chooseUndoRepostFromDrawer(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await driver.pause(300); + const updatedRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText(); + expect(parseInt(updatedRetweetCount)).toBe(parseInt(initialRetweetCount) - 1); + }); + + it('should handle quoting tweet', async () => { + await FollowingScreen.tweetRetweetButton[1].click(); + await FollowingScreen.chooseQuoteFromDrawer(); + await TweetComposerScreen.tweetInput.waitForDisplayed(); + await TweetComposerScreen.writeTweet("I'm quoting this tweet!"); + await TweetComposerScreen.postTweet(); + await ForYouScreen.forYouTab.waitForDisplayed(); + await ForYouScreen.switchToFollowingTab(); + await FollowingScreen.screenTitle.waitForDisplayed(); + await driver.pause(500); + const tweetContent = await FollowingScreen.tweetContent[0].getText(); + expect(tweetContent).toContain("I'm quoting this tweet!"); + }); + }); + + describe('Reply via Composer Tests', () => { + before(async () => { + const firstTweet = await FollowingScreen.tweetCard[2]; + await firstTweet.click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + }); + + it('should open full composer when clicking reply button', async () => { + await TweetDetailsScreen.replyButton.click(); + await TweetComposerScreen.tweetInput.waitForDisplayed(); + await expect(await TweetComposerScreen.isDisplayed()).toBe(true); + }); + + it('should show replying to context in composer', async () => { + await expect(await TweetComposerScreen.tweetInput).toBeDisplayed(); + }); + + it('should submit reply via full composer', async () => { + await TweetComposerScreen.writeTweet('Reply via full composer!'); + await TweetComposerScreen.postTweet(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + await expect(await TweetDetailsScreen.isDisplayed()).toBe(true); + }); + + after(async () => { + await TweetDetailsScreen.goBack(); + await FollowingScreen.screenTitle.waitForDisplayed(); + }); + }); + + describe('Tweet Detail Interaction Tests', () => { + before(async () => { + const firstTweet = await FollowingScreen.tweetCard[2]; + await firstTweet.click(); + await TweetDetailsScreen.screenTitle.waitForDisplayed(); + }); + + it('should like tweet from detail view', async () => { + const initialCount = await TweetDetailsScreen.getLikeCountValue(); + await TweetDetailsScreen.toggleLike(); + await driver.pause(300); + const newCount = await TweetDetailsScreen.getLikeCountValue(); + expect(newCount).toBe(initialCount + 1); + }); + + it('should retweet from detail view', async () => { + await TweetDetailsScreen.openRetweetMenu(); + await TweetDetailsScreen.repostOption.waitForDisplayed(); + await expect(await TweetDetailsScreen.repostOption).toBeDisplayed(); + await expect(await TweetDetailsScreen.quoteOption).toBeDisplayed(); + await driver.back(); + await driver.pause(300); + }); + + it('should show tweet drawer options', async () => { + const drawerButton = await TweetDetailsScreen.tweetDrawerButton; + if (await drawerButton.isDisplayed()) { + await drawerButton.click(); + await driver.pause(500); + await driver.back(); + await driver.pause(300); + } + }); + + after(async () => { + await TweetDetailsScreen.goBack(); + await FollowingScreen.screenTitle.waitForDisplayed(); + }); + }); +}); diff --git a/e2e/specs/userProfile.e2e.js b/e2e/specs/userProfile.e2e.js new file mode 100644 index 000000000..ea13b336c --- /dev/null +++ b/e2e/specs/userProfile.e2e.js @@ -0,0 +1,363 @@ +import { driver, expect } from '@wdio/globals'; + +import ForYouScreen from '../pageobjects/Home/ForYouScreen.page'; +import SidebarScreen from '../pageobjects/Home/SidebarScreen.page'; +import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page'; +import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page'; +import AllNotificationsScreen from '../pageobjects/Notifications/AllNotificationsScreen.page'; +import ConnectionsScreen from '../pageobjects/Profile/ConnectionsScreen.page'; +import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page'; +import SearchScreen from '../pageobjects/Search/SearchScreen.page'; +import StartScreen from '../pageobjects/StartScreen.page'; +import { createTestTweets } from '../utils/createTestTweets'; +import { createTestUser } from '../utils/createTestUser'; +import { followUser } from '../utils/followUser'; +import { getAccessToken } from '../utils/getAccessToken'; +import { createReply, likeTweet } from '../utils/tweetActions'; + +let testUser; +let otherUserDisplayName; + +describe('User Profile Flow', () => { + const login = async (user) => { + await StartScreen.openLoginScreen(); + await LoginEmailScreen.enterEmail(user.email); + await LoginEmailScreen.clickNext(); + await LoginPasswordScreen.enterPassword(user.password); + await LoginPasswordScreen.clickLogin(); + await ForYouScreen.forYouTab.waitForDisplayed(); + }; + + const navigateToOwnProfile = async () => { + await ForYouScreen.openSideMenu(); + await SidebarScreen.goToProfile(); + await ProfileScreen.displayName.waitForDisplayed(); + }; + + const navigateToOtherUserViaNotifications = async () => { + await ForYouScreen.switchToNotificationsTab(); + await AllNotificationsScreen.screenTitle.waitForDisplayed(); + await AllNotificationsScreen.followNotifications[0].click(); + await ProfileScreen.displayName.waitForDisplayed(); + otherUserDisplayName = await ProfileScreen.displayName.getText(); + }; + + before(async () => { + testUser = await createTestUser(); + await followUser(testUser.username, 1); + await login(testUser); + }); + + describe('Own Profile Navigation', () => { + it('should navigate to own profile from sidebar', async () => { + await navigateToOwnProfile(); + await expect(await ProfileScreen.displayName.isDisplayed()).toBe(true); + }); + + it('should display profile information correctly', async () => { + await expect(await ProfileScreen.displayName.getText()).toBe(testUser.displayName); + }); + + it('should not show message button on own profile', async () => { + await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(false); + }); + + it('should navigate back to home from own profile', async () => { + await ProfileScreen.goBack(); + await ForYouScreen.forYouTab.waitForDisplayed(); + await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true); + }); + }); + + describe('Other User Profile - Follow/Unfollow', () => { + before(async () => { + await navigateToOtherUserViaNotifications(); + }); + + it('should display other user profile correctly', async () => { + await expect(await ProfileScreen.displayName.getText()).toBe(otherUserDisplayName); + }); + + it('should show Follow back button for user who follows you', async () => { + await expect(await ProfileScreen.isFollowBackButtonDisplayed()).toBe(true); + }); + + it('should show message button on other user profile', async () => { + await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(true); + }); + + it('should follow user when clicking Follow back button', async () => { + await ProfileScreen.followBackUser(); + await expect(await ProfileScreen.isFollowingButtonDisplayed()).toBe(true); + }); + }); + + describe('Profile Content Tabs', () => { + let accessToken; + let tweetId; + + before(async () => { + accessToken = await getAccessToken(testUser.email, testUser.password); + const tweetIds = await createTestTweets(accessToken, 1); + tweetId = tweetIds[0]; + await createReply(accessToken, tweetId); + await likeTweet(accessToken, tweetId); + + await ProfileScreen.goBack(); + await navigateToOwnProfile(); + }); + + it('should display Posts tab by default', async () => { + await expect(await ProfileScreen.postsTab.isDisplayed()).toBe(true); + }); + + it('should show at least one tweet in Posts tab', async () => { + await ProfileScreen.switchToPostsTab(); + await ProfileScreen.refresh(); + const tweets = await ProfileScreen.tweetCards; + await expect(tweets.length).toBeGreaterThan(0); + await expect(await tweets[0].isDisplayed()).toBe(true); + }); + + it('should switch to Replies tab and show at least one reply', async () => { + await ProfileScreen.switchToRepliesTab(); + await ProfileScreen.refresh(); + const tweets = await ProfileScreen.tweetCards; + await expect(tweets.length).toBeGreaterThan(0); + await expect(await tweets[0].isDisplayed()).toBe(true); + }); + + it('should switch to Media tab', async () => { + await ProfileScreen.switchToMediaTab(); + await ProfileScreen.refresh(); + await expect(await ProfileScreen.mediaTab.isDisplayed()).toBe(true); + }); + + it('should switch to Likes tab and show at least one liked tweet', async () => { + await ProfileScreen.switchToLikesTab(); + await ProfileScreen.refresh(); + const tweets = await ProfileScreen.tweetCards; + await expect(tweets.length).toBeGreaterThan(0); + await expect(await tweets[0].isDisplayed()).toBe(true); + }); + }); + + describe('Following/Followers Lists from Profile', () => { + before(async () => { + await ProfileScreen.switchToPostsTab(); + }); + + it('should navigate to followers list from profile stats', async () => { + await ProfileScreen.goToFollowers(); + await ConnectionsScreen.followersTab.waitForDisplayed(); + await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true); + }); + + it('should display all tabs in connections screen', async () => { + await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true); + await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true); + await expect(await ConnectionsScreen.isFollowersYouKnowTabDisplayed()).toBe(true); + }); + + it('should switch to following tab', async () => { + await ConnectionsScreen.switchToFollowingTab(); + await driver.pause(500); + await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true); + }); + + it('should switch to followers you know tab', async () => { + await ConnectionsScreen.switchToFollowersYouKnowTab(); + await driver.pause(500); + await expect(await ConnectionsScreen.isFollowersYouKnowTabDisplayed()).toBe(true); + }); + + it('should switch back to followers tab', async () => { + await ConnectionsScreen.switchToFollowersTab(); + await driver.pause(500); + await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true); + }); + + it('should go back to profile from followers list', async () => { + await ConnectionsScreen.goBack(); + await ProfileScreen.displayName.waitForDisplayed(); + await expect(await ProfileScreen.displayName.isDisplayed()).toBe(true); + }); + + it('should navigate to following list from profile stats', async () => { + await ProfileScreen.goToFollowing(); + await ConnectionsScreen.followingTab.waitForDisplayed(); + await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true); + }); + + it('should go back to profile from following list', async () => { + await ConnectionsScreen.goBack(); + await ProfileScreen.displayName.waitForDisplayed(); + await expect(await ProfileScreen.displayName.isDisplayed()).toBe(true); + }); + }); + + describe('Following/Followers Lists from Sidebar', () => { + before(async () => { + await ProfileScreen.goBack(); + await AllNotificationsScreen.goToForYouScreen(); + }); + + it('should navigate to following list from sidebar', async () => { + await ForYouScreen.openSideMenu(); + await SidebarScreen.goToFollowing(); + await ConnectionsScreen.followingTab.waitForDisplayed(); + await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true); + }); + + it('should go back to home from following list', async () => { + await ConnectionsScreen.goBack(); + await ForYouScreen.forYouTab.waitForDisplayed(); + await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true); + }); + + it('should navigate to followers list from sidebar', async () => { + await ForYouScreen.openSideMenu(); + await SidebarScreen.goToFollowers(); + await ConnectionsScreen.followersTab.waitForDisplayed(); + await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true); + }); + + it('should go back to home from followers list', async () => { + await ConnectionsScreen.goBack(); + await ForYouScreen.forYouTab.waitForDisplayed(); + await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true); + }); + }); + + describe('Search for profiles', () => { + it('should navigate to search screen via explore tab', async () => { + await ForYouScreen.switchToExploreTab(); + await SearchScreen.openFromExplore(); + await SearchScreen.searchInput.waitForDisplayed(); + await expect(await SearchScreen.searchInput.isDisplayed()).toBe(true); + }); + + it('should search for the other user profile and find results', async () => { + await SearchScreen.searchFor(otherUserDisplayName); + await driver.pause(500); + await expect(await SearchScreen.isTopTabDisplayed()).toBe(true); + }); + + it('should display all other tabs correctly in search results', async () => { + await expect(await SearchScreen.latestTab.isDisplayed()).toBe(true); + await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true); + await expect(await SearchScreen.mediaTab.isDisplayed()).toBe(true); + }); + + it('should switch to People tab and show user results', async () => { + await SearchScreen.switchToPeopleTab(); + await driver.pause(500); + const hasUser = await SearchScreen.hasUserInResults(otherUserDisplayName); + await expect(hasUser).toBe(true); + }); + + it('should navigate to user profile from search results', async () => { + await SearchScreen.clickOnUserInResults(otherUserDisplayName); + await ProfileScreen.displayName.waitForDisplayed(); + await expect(await ProfileScreen.displayName.getText()).toBe(otherUserDisplayName); + }); + + it('should go back to search results from user profile', async () => { + await ProfileScreen.goBack(); + await SearchScreen.peopleTab.waitForDisplayed(); + await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true); + }); + }); + + describe('Mute/Unmute Flow', () => { + before(async () => { + await SearchScreen.goBack(); + await SearchScreen.goToForYouScreen(); + await navigateToOtherUserViaNotifications(); + }); + + it('should open profile menu dropdown and show Mute option', async () => { + await ProfileScreen.openMenuDropdown(); + await expect(await ProfileScreen.muteButton.isDisplayed()).toBe(true); + }); + + it('should mute user and show muted indicator', async () => { + await ProfileScreen.muteUser(); + await expect(await ProfileScreen.isMutedIndicatorDisplayed()).toBe(true); + }); + + it('should show Unmute option in dropdown after muting', async () => { + await ProfileScreen.openMenuDropdown(); + await expect(await ProfileScreen.unmuteButton.isDisplayed()).toBe(true); + }); + + it('should unmute user through dropdown menu', async () => { + await ProfileScreen.unmuteUser(); + const isMutedDisplayed = await ProfileScreen.mutedIndicator.isDisplayed(); + await expect(isMutedDisplayed).toBe(false); + }); + + it('should unmute user through indicator link', async () => { + await ProfileScreen.openMenuDropdown(); + await ProfileScreen.muteUser(); + await ProfileScreen.unmuteFromIndicator(); + const isMutedDisplayed = await ProfileScreen.mutedIndicator.isDisplayed(); + await expect(isMutedDisplayed).toBe(false); + }); + }); + + describe('Block/Unblock Flow', () => { + it('should show Block option in dropdown menu', async () => { + await ProfileScreen.openMenuDropdown(); + await expect(await ProfileScreen.blockButtonMenu.isDisplayed()).toBe(true); + }); + + it('should block user and show Blocked button', async () => { + await ProfileScreen.blockUser(); + await expect(await ProfileScreen.isBlockedButtonDisplayed()).toBe(true); + }); + + it('should hide message button after blocking', async () => { + await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(false); + }); + + it('should not show mute option for blocked user', async () => { + await ProfileScreen.openMenuDropdown(); + const isMuteDisplayed = await ProfileScreen.muteButton.isDisplayed(); + await expect(isMuteDisplayed).toBe(false); + }); + + it('should show Unblock option in dropdown for blocked user', async () => { + await expect(await ProfileScreen.unblockButton.isDisplayed()).toBe(true); + await ProfileScreen.closeMenuDropdown(); + }); + + it('should show blocked user message', async () => { + await expect(await ProfileScreen.isBlockedMessageDisplayed()).toBe(true); + }); + + it('should allow viewing posts of blocked user', async () => { + await ProfileScreen.viewBlockedUserPosts(); + await driver.pause(500); + const isBlockedMsgDisplayed = await ProfileScreen.blockedMessage.isDisplayed(); + await expect(isBlockedMsgDisplayed).toBe(false); + }); + + it('should unblock user through dropdown menu', async () => { + await ProfileScreen.openMenuDropdown(); + await ProfileScreen.unblockUser(); + await expect(await ProfileScreen.isFollowButtonDisplayed()).toBe(true); + }); + + it('should show message button after unblocking', async () => { + await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(true); + }); + + it('should unblock user through blocked button', async () => { + await ProfileScreen.openMenuDropdown(); + await ProfileScreen.blockUser(); + await ProfileScreen.unblockFromButton(); + await expect(await ProfileScreen.isFollowButtonDisplayed()).toBe(true); + }); + }); +}); diff --git a/e2e/utils/blockUser.js b/e2e/utils/blockUser.js new file mode 100644 index 000000000..f0ba6f9c5 --- /dev/null +++ b/e2e/utils/blockUser.js @@ -0,0 +1,35 @@ +import { createTestUser } from './createTestUser'; + +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function blockUser(email, password, count = 1) { + let response = await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'mobile', + }, + body: JSON.stringify({ + identifier: email, + password: password, + }), + }).then((res) => res.json()); + + const accessToken = response.data.accessToken; + + for (let i = 0; i < count; i++) { + let blockedUser = await createTestUser(); + + response = await fetch(`${BASE_URL}/me/blocks/${blockedUser.username}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to block user: ${response.statusText}`); + } + } +} diff --git a/e2e/utils/changeUsername.js b/e2e/utils/changeUsername.js new file mode 100644 index 000000000..7f5a685a3 --- /dev/null +++ b/e2e/utils/changeUsername.js @@ -0,0 +1,20 @@ +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function changeUsername(accessToken) { + const newUsername = `u${new Date().getTime()}`; + const response = await fetch(`${BASE_URL}/me/settings/username`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + newUsername: newUsername, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to change username: ${response.statusText}`); + } + return newUsername; +} diff --git a/e2e/utils/createHashtagTweets.js b/e2e/utils/createHashtagTweets.js new file mode 100644 index 000000000..ac2348491 --- /dev/null +++ b/e2e/utils/createHashtagTweets.js @@ -0,0 +1,30 @@ +import { faker } from '@faker-js/faker'; + +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function createHashtagTweets(accessToken, hashtag, count = 5) { + const normalizedHashtag = hashtag.startsWith('#') ? hashtag : `#${hashtag}`; + const createdTweetsIds = []; + + for (let i = 0; i < count; i++) { + const content = `${faker.lorem.sentence()} ${normalizedHashtag}`; + + const response = await fetch(`${BASE_URL}/tweets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + throw new Error(`Failed to create tweet with hashtag: ${response.statusText}`); + } + + const res = await response.json(); + createdTweetsIds.push(res.data.id); + } + + return createdTweetsIds; +} diff --git a/e2e/utils/createTestTweets.js b/e2e/utils/createTestTweets.js new file mode 100644 index 000000000..f9551283f --- /dev/null +++ b/e2e/utils/createTestTweets.js @@ -0,0 +1,28 @@ +import { faker } from '@faker-js/faker'; + +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function createTestTweets(accessToken, count = 1) { + const createdTweetsIds = []; + let response; + for (let i = 0; i < count; i++) { + response = await fetch(`${BASE_URL}/tweets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + content: faker.lorem.sentence(), + }), + }); + + if (!response.ok) { + throw new Error(`Failed to create tweet: ${response.statusText}`); + } + + const res = await response.json(); + createdTweetsIds.push(res.data.id); + } + return createdTweetsIds; +} diff --git a/e2e/utils/followUser.js b/e2e/utils/followUser.js new file mode 100644 index 000000000..6e4cae473 --- /dev/null +++ b/e2e/utils/followUser.js @@ -0,0 +1,34 @@ +import { createTestUser } from './createTestUser'; +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function followUser(username, count = 1) { + for (let i = 0; i < count; i++) { + let follower = await createTestUser(); + + let response = await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'mobile', + }, + body: JSON.stringify({ + identifier: follower.email, + password: follower.password, + }), + }).then((res) => res.json()); + + const accessToken = response.data.accessToken; + + response = await fetch(`${BASE_URL}/users/${username}/following`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to follow user: ${response.statusText}`); + } + } +} diff --git a/e2e/utils/getAccessToken.js b/e2e/utils/getAccessToken.js new file mode 100644 index 000000000..cb20aade6 --- /dev/null +++ b/e2e/utils/getAccessToken.js @@ -0,0 +1,17 @@ +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function getAccessToken(email, password) { + const response = await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'mobile', + }, + body: JSON.stringify({ + identifier: email, + password: password, + }), + }).then((res) => res.json()); + + return response.data.accessToken; +} diff --git a/e2e/utils/muteUser.js b/e2e/utils/muteUser.js new file mode 100644 index 000000000..126e87d33 --- /dev/null +++ b/e2e/utils/muteUser.js @@ -0,0 +1,35 @@ +import { createTestUser } from './createTestUser'; + +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function muteUser(email, password, count = 1) { + let response = await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'mobile', + }, + body: JSON.stringify({ + identifier: email, + password: password, + }), + }).then((res) => res.json()); + + const accessToken = response.data.accessToken; + + for (let i = 0; i < count; i++) { + let mutedUser = await createTestUser(); + + response = await fetch(`${BASE_URL}/me/mutes/${mutedUser.username}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to mute user: ${response.statusText}`); + } + } +} diff --git a/e2e/utils/receiveTweetActions.js b/e2e/utils/receiveTweetActions.js new file mode 100644 index 000000000..a17dea3c8 --- /dev/null +++ b/e2e/utils/receiveTweetActions.js @@ -0,0 +1,89 @@ +import { faker } from '@faker-js/faker'; + +import { createTestUser } from './createTestUser'; + +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function receiveTweetActions(tweetId, count, actions, mention = '') { + for (let i = 0; i < count; i++) { + let user = await createTestUser(); + + let response = await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'mobile', + }, + body: JSON.stringify({ + identifier: user.email, + password: user.password, + }), + }).then((res) => res.json()); + + const accessToken = response.data.accessToken; + + if (actions.like) { + response = await fetch(`${BASE_URL}/tweets/${tweetId}/like`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + console.error(`Failed to like tweet: ${response.statusText}`); + } + } + + if (actions.retweet) { + response = await fetch(`${BASE_URL}/tweets/${tweetId}/retweet`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + console.error(`Failed to retweet tweet: ${response.statusText}`); + } + } + + if (actions.quote) { + response = await fetch(`${BASE_URL}/tweets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + content: faker.lorem.sentence() + ' ' + mention, + quoteToTweetId: tweetId, + }), + }); + + if (!response.ok) { + console.error(`Failed to quote tweet: ${response.statusText}`); + } + } + + if (actions.reply) { + response = await fetch(`${BASE_URL}/tweets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + content: faker.lorem.sentence() + ' ' + mention, + replyToTweetId: tweetId, + }), + }); + + if (!response.ok) { + console.error(`Failed to reply to tweet: ${response.statusText}`); + } + } + } +} diff --git a/e2e/utils/tweetActions.js b/e2e/utils/tweetActions.js new file mode 100644 index 000000000..75b61c486 --- /dev/null +++ b/e2e/utils/tweetActions.js @@ -0,0 +1,40 @@ +import { faker } from '@faker-js/faker'; + +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000'; + +export async function createReply(accessToken, tweetId) { + const response = await fetch(`${BASE_URL}/tweets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + content: faker.lorem.sentence(), + replyToTweetId: tweetId, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to create reply: ${response.statusText}`); + } + + const res = await response.json(); + return res.data.id; +} + +export async function likeTweet(accessToken, tweetId) { + const response = await fetch(`${BASE_URL}/tweets/${tweetId}/like`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to like tweet: ${response.statusText}`); + } + + return response.json(); +} diff --git a/eslint.config.js b/eslint.config.js index ab5540bc3..e7b9fabe4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,6 +13,7 @@ module.exports = defineConfig([ it: true, before: true, beforeEach: true, + after: true, }, }, }, diff --git a/jest.setup.js b/jest.setup.js index b16fac748..dce36752b 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -96,6 +96,41 @@ jest.mock('expo-video', () => { }; }); +jest.mock('@shopify/flash-list', () => { + const ReactActual = jest.requireActual('react'); + + function FlashList(props) { + const { data = [], renderItem, ListEmptyComponent, ListFooterComponent, keyExtractor } = props; + + return ReactActual.createElement( + ReactActual.Fragment, + null, + + data.length > 0 && renderItem + ? data.map((item, index) => { + const element = renderItem({ item, index }); + if (!element) return null; + + const key = keyExtractor?.(item, index) ?? index; + return ReactActual.cloneElement(element, { key }); + }) + : ListEmptyComponent + ? ReactActual.isValidElement(ListEmptyComponent) + ? ListEmptyComponent + : ReactActual.createElement(ListEmptyComponent) + : null, + + ListFooterComponent + ? ReactActual.isValidElement(ListFooterComponent) + ? ListFooterComponent + : ReactActual.createElement(ListFooterComponent) + : null + ); + } + + return { FlashList }; +}); + // Add global cleanup to handle timers and async operations afterEach(() => { // Clear all timers after each test diff --git a/package.json b/package.json index 7b1cefcab..c63e1ac00 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "expo-image": "~3.0.10", "expo-image-picker": "~17.0.8", "expo-linking": "~8.0.8", - "expo-localization": "~17.0.7", "expo-media-library": "^18.2.0", "expo-secure-store": "~15.0.7", "expo-splash-screen": "~31.0.10", @@ -109,6 +108,7 @@ "@wdio/spec-reporter": "^9.20.0", "appium": "^2.19.0", "appium-uiautomator2-driver": "^4.2.9", + "babel-plugin-dynamic-import-node": "^2.3.3", "babel-preset-expo": "^54.0.5", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e86423de4..24a84eab6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,9 +101,6 @@ importers: expo-linking: specifier: ~8.0.8 version: 8.0.8(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-localization: - specifier: ~17.0.7 - version: 17.0.7(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0) expo-media-library: specifier: ^18.2.0 version: 18.2.0(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) @@ -267,6 +264,9 @@ importers: appium-uiautomator2-driver: specifier: ^4.2.9 version: 4.2.9(appium@2.19.0) + babel-plugin-dynamic-import-node: + specifier: ^2.3.3 + version: 2.3.3 babel-preset-expo: specifier: ^54.0.5 version: 54.0.6(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-refresh@0.14.2) @@ -3151,6 +3151,9 @@ packages: peerDependencies: '@babel/core': ^7.8.0 + babel-plugin-dynamic-import-node@2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} + babel-plugin-istanbul@6.1.1: resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} engines: {node: '>=8'} @@ -4409,12 +4412,6 @@ packages: react: '*' react-native: '*' - expo-localization@17.0.7: - resolution: {integrity: sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA==} - peerDependencies: - expo: '*' - react: '*' - expo-manifests@1.0.8: resolution: {integrity: sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ==} peerDependencies: @@ -7241,9 +7238,6 @@ packages: react: '*' react-native: '*' - rtl-detect@1.1.2: - resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} - run-async@4.0.6: resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} engines: {node: '>=0.12.0'} @@ -12280,6 +12274,10 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-dynamic-import-node@2.3.3: + dependencies: + object.assign: 4.1.7 + babel-plugin-istanbul@6.1.1: dependencies: '@babel/helper-plugin-utils': 7.27.1 @@ -13798,12 +13796,6 @@ snapshots: - expo - supports-color - expo-localization@17.0.7(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0): - dependencies: - expo: 54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react: 19.1.0 - rtl-detect: 1.1.2 - expo-manifests@1.0.8(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): dependencies: '@expo/config': 12.0.10 @@ -17272,8 +17264,6 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - rtl-detect@1.1.2: {} - run-async@4.0.6: {} run-parallel@1.2.0: diff --git a/src/App.tsx b/src/App.tsx index fca01940e..7d416ef92 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,6 @@ import './global.css'; import { queryClient } from '@/libs/queryClient'; import { usePushNotifications } from './hooks/notifications/usePushNotifications'; -import { useDmSse } from './hooks/useDmSse'; import { ThemeProvider, useTheme } from './hooks/useTheme'; import { navigationRef } from './navigation/navigationRef'; import RootNavigator from './navigation/RootNavigator'; @@ -81,8 +80,6 @@ export function ThemedAppContent() { } }, [pushToken, activeAccountId]); - useDmSse(activeAccountId); - if (!appIsReady || !fontsLoaded) return null; const initialRouteName = activeAccountId ? 'Drawer' : 'Start'; diff --git a/src/__tests__/components/TweetComposer.test.tsx b/src/__tests__/components/TweetComposer.test.tsx index f1f3317bd..0a73288ca 100644 --- a/src/__tests__/components/TweetComposer.test.tsx +++ b/src/__tests__/components/TweetComposer.test.tsx @@ -41,6 +41,14 @@ jest.mock('expo-image-picker', () => ({ }, })); +jest.mock('expo-file-system', () => ({ + getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 1024 * 1024 }), // 1MB mock +})); + +jest.mock('expo-file-system/legacy', () => ({ + getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 1024 * 1024 }), +})); + jest.mock('expo-video', () => { const addListener = jest.fn(() => ({ remove: jest.fn() })); const React = jest.requireActual('react'); @@ -131,6 +139,42 @@ const mockQueryClient = new QueryClient({ }, }); +// Shared mock tweet factory +const createMockTweet = (overrides = {}) => ({ + id: 'tweet-default', + content: 'Default tweet content', + author: { + displayName: 'Test User', + username: 'testuser', + avatarUrl: null, + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }, + createdAt: new Date().toISOString(), + replyToTweetId: null, + quoteToTweetId: null, + quotedTweet: null, + replyToTweet: null, + repostedBy: null, + isRepost: false, + replyCount: 0, + retweetCount: 0, + likeCount: 0, + isLiked: false, + isRetweeted: false, + media: [], + entities: { + mentions: null, + hashtags: null, + }, + ...overrides, +}); + describe('TweetComposer', () => { const renderWithProviders = (component: React.ReactElement) => { return render( @@ -145,27 +189,22 @@ describe('TweetComposer', () => { ); }; - const mockTweet = { + const mockTweet = createMockTweet({ id: '1', content: 'This is a mock tweet', author: { displayName: 'Mock User', username: 'mockuser', avatarUrl: 'https://example.com/avatar.png', + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, }, - createdAt: '2025-12-08T12:00:00Z', - replyToTweet: null, - replyCount: 0, - retweetCount: 0, - likeCount: 0, - isLiked: false, - isRetweeted: false, - media: [], - entities: { - mentions: [], - hashtags: [], - }, - }; + }); beforeEach(() => { jest.clearAllMocks(); @@ -249,7 +288,6 @@ describe('TweetComposer', () => { mediaTypes: ['images', 'videos'], allowsMultipleSelection: true, selectionLimit: 4, - allowsEditing: true, quality: 1, videoMaxDuration: 140, exif: false, @@ -527,7 +565,6 @@ describe('TweetComposer', () => { expect(mockLaunchCamera).toHaveBeenCalledWith({ mediaTypes: ['images'], quality: 1, - allowsEditing: true, exif: false, }); }); @@ -750,10 +787,15 @@ describe('TweetComposer', () => { it('successfully posts tweet with text only', async () => { const mockPostTweet = jest.mocked(tweetService.postTweet); + const postedTweet = createMockTweet({ + id: 'tweet-123', + content: 'Test tweet content', + }); + mockPostTweet.mockResolvedValue({ success: true, message: 'Tweet posted successfully', - data: { id: 'tweet-123' }, + data: postedTweet, }); const onClose = jest.fn(); @@ -765,12 +807,10 @@ describe('TweetComposer', () => { const postButton = getByTestId('post-button'); fireEvent.press(postButton); - // Composer closes after successful posting await waitFor(() => { expect(onClose).toHaveBeenCalled(); }); - // Posting happens await waitFor(() => { expect(mockPostTweet).toHaveBeenCalledWith({ content: 'Test tweet content', @@ -780,6 +820,9 @@ describe('TweetComposer', () => { }); it('handles posting error gracefully', async () => { + // Mock console.error to suppress expected error logs + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const mockPostTweet = jest.mocked(tweetService.postTweet); mockPostTweet.mockRejectedValue(new Error('Network error')); @@ -792,18 +835,31 @@ describe('TweetComposer', () => { const postButton = getByTestId('post-button'); fireEvent.press(postButton); - // Posting fails await waitFor(() => { expect(mockPostTweet).toHaveBeenCalled(); }); - // Composer stays open on error expect(onClose).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); }); it('uploads media and posts tweet with media IDs', async () => { const mockUploadImage = jest.mocked(tweetService.uploadImage); const mockPostTweet = jest.mocked(tweetService.postTweet); + const postedTweet = createMockTweet({ + id: 'tweet-123', + content: 'Test tweet with media', + media: [ + { + type: 'IMAGE' as const, + url: 'https://example.com/media-123.jpg', + altText: '', + width: 800, + height: 600, + }, + ], + }); mockUploadImage.mockResolvedValue({ success: true, @@ -816,7 +872,7 @@ describe('TweetComposer', () => { mockPostTweet.mockResolvedValue({ success: true, message: 'Tweet posted successfully', - data: { id: 'tweet-123' }, + data: postedTweet, }); const onClose = jest.fn(); @@ -824,7 +880,6 @@ describe('TweetComposer', () => { ); - // Select an asset const mediaButtons = getAllByTestId(/^media-image-/); fireEvent.press(mediaButtons[0]); @@ -846,6 +901,11 @@ describe('TweetComposer', () => { it('disables post button when submitting', async () => { const mockPostTweet = jest.mocked(tweetService.postTweet); + const postedTweet = createMockTweet({ + id: 'tweet-123', + content: 'Test tweet', + }); + mockPostTweet.mockImplementation( () => new Promise((resolve) => @@ -854,7 +914,7 @@ describe('TweetComposer', () => { resolve({ success: true, message: 'Tweet posted successfully', - data: { id: 'tweet-123' }, + data: postedTweet, }), 100 ) @@ -876,8 +936,9 @@ describe('TweetComposer', () => { }); describe('Quote Tweet Functionality', () => { - const mockQuotedTweet = { + const mockQuotedTweet = createMockTweet({ id: 'quoted-123', + content: 'Original tweet content', author: { username: 'quoted_user', displayName: 'Quoted User', @@ -890,16 +951,9 @@ describe('TweetComposer', () => { follower: false, }, }, - content: 'Original tweet content', - createdAt: new Date().toISOString(), - replyCount: 0, retweetCount: 5, likeCount: 10, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - }; + }); it('renders quoted tweet when quotedTweet prop is provided', () => { const { getByText } = renderWithProviders( @@ -913,10 +967,17 @@ describe('TweetComposer', () => { it('includes quotedTweetId when posting with quoted tweet', async () => { const mockPostTweet = jest.mocked(tweetService.postTweet); + const postedTweet = createMockTweet({ + id: 'tweet-456', + content: 'My quote comment', + quoteToTweetId: 'quoted-123', + quotedTweet: mockQuotedTweet, + }); + mockPostTweet.mockResolvedValue({ success: true, message: 'Tweet posted successfully', - data: { id: 'tweet-456' }, + data: postedTweet, }); const onClose = jest.fn(); @@ -941,11 +1002,6 @@ describe('TweetComposer', () => { it('requires text or media even with quoted tweet', async () => { const mockPostTweet = jest.mocked(tweetService.postTweet); - mockPostTweet.mockResolvedValue({ - success: true, - message: 'Tweet posted successfully', - data: { id: 'tweet-789' }, - }); const onClose = jest.fn(); const { getByTestId } = renderWithProviders( @@ -954,10 +1010,8 @@ describe('TweetComposer', () => { const postButton = getByTestId('post-button'); - // Post button should be disabled without text or media expect(postButton.props.accessibilityState?.disabled).toBe(true); - // Button press should not trigger post when disabled fireEvent.press(postButton); expect(mockPostTweet).not.toHaveBeenCalled(); @@ -965,10 +1019,17 @@ describe('TweetComposer', () => { it('calls onSuccess callback after successful quote tweet post', async () => { const mockPostTweet = jest.mocked(tweetService.postTweet); + const postedTweet = createMockTweet({ + id: 'tweet-success', + content: 'My quote comment', + quoteToTweetId: 'quoted-123', + quotedTweet: mockQuotedTweet, + }); + mockPostTweet.mockResolvedValue({ success: true, message: 'Tweet posted successfully', - data: { id: 'tweet-success' }, + data: postedTweet, }); const onSuccess = jest.fn(); @@ -1009,7 +1070,6 @@ describe('TweetComposer', () => { /> ); - // Add some text to enable the post button const textInput = getByTestId('text-input'); fireEvent.changeText(textInput, 'This will fail'); @@ -1027,6 +1087,21 @@ describe('TweetComposer', () => { it('can post quote tweet with media attachments', async () => { const mockUploadImage = jest.mocked(tweetService.uploadImage); const mockPostTweet = jest.mocked(tweetService.postTweet); + const postedTweet = createMockTweet({ + id: 'tweet-with-media', + content: 'Quote with image', + quoteToTweetId: 'quoted-123', + quotedTweet: mockQuotedTweet, + media: [ + { + type: 'IMAGE' as const, + url: 'https://example.com/media-456.jpg', + altText: '', + width: 800, + height: 600, + }, + ], + }); mockUploadImage.mockResolvedValue({ success: true, @@ -1039,14 +1114,13 @@ describe('TweetComposer', () => { mockPostTweet.mockResolvedValue({ success: true, message: 'Tweet posted successfully', - data: { id: 'tweet-with-media' }, + data: postedTweet, }); const { getByTestId, getAllByTestId } = renderWithProviders( {}} quotedTweet={mockQuotedTweet} /> ); - // Select an asset const mediaButtons = getAllByTestId(/^media-image-/); fireEvent.press(mediaButtons[0]); @@ -1066,4 +1140,79 @@ describe('TweetComposer', () => { }); }); }); + + it('handles navigation to author profile', () => { + const onPressAuthor = jest.fn(); + const onClose = jest.fn(); + + const mockReplyToTweet = { + author: { username: 'author', displayName: 'Author', avatarUrl: null }, + createdAt: new Date().toISOString(), + content: 'Original tweet', + }; + + const { getByTestId } = renderWithProviders( + + ); + + const authorLink = getByTestId('replying-to-username'); + fireEvent.press(authorLink); + expect(onPressAuthor).toHaveBeenCalledWith('author'); + expect(onClose).toHaveBeenCalled(); + }); + + it('alerts when camera permission is denied', async () => { + const mockRequestPermission = ImagePicker.requestCameraPermissionsAsync as jest.Mock; + mockRequestPermission.mockResolvedValueOnce({ status: 'denied' }); + const mockAlert = jest.spyOn(Alert, 'alert'); + + const { getByTestId } = renderWithProviders( {}} />); + const cameraIcon = getByTestId('camera-icon'); + + fireEvent.press(cameraIcon); + + await waitFor(() => { + expect(mockAlert).toHaveBeenCalledWith('Permission needed', expect.any(String)); + }); + mockAlert.mockRestore(); + }); + + it('alerts when file is too large', async () => { + const mockLaunchImageLibrary = ImagePicker.launchImageLibraryAsync as jest.Mock; + mockLaunchImageLibrary.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file://large-image.jpg', + fileName: 'large-image.jpg', + width: 1000, + height: 1000, + }, + ], + }); + + const mockGetInfo = require('expo-file-system/legacy').getInfoAsync; + mockGetInfo.mockResolvedValue({ exists: true, size: 10 * 1024 * 1024 }); // 10MB > 5MB limit + + const mockAlert = jest.spyOn(Alert, 'alert'); + + const { getByTestId } = renderWithProviders( {}} />); + const pictureIcon = getByTestId('media-icon'); + + fireEvent.press(pictureIcon); + + await waitFor(() => { + expect(mockAlert).toHaveBeenCalledWith( + 'File Too Large', + expect.stringContaining('exceed size limits') + ); + }); + mockAlert.mockRestore(); + }); }); diff --git a/src/__tests__/components/messages/ChatHeader.test.tsx b/src/__tests__/components/messages/ChatHeader.test.tsx new file mode 100644 index 000000000..f28babdba --- /dev/null +++ b/src/__tests__/components/messages/ChatHeader.test.tsx @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +import { fireEvent, render } from '@testing-library/react-native'; + +import ChatHeader from '@/components/messages/ChatHeader'; + +jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const }) })); + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), +})); + +describe('ChatHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders back button', () => { + const { UNSAFE_getByProps } = render( + + ); + + const backIcon = UNSAFE_getByProps({ name: 'arrow-back' }); + expect(backIcon).toBeTruthy(); + }); + + it('calls goBack when back button is pressed', () => { + const { UNSAFE_getByProps } = render( + + ); + + const backButton = UNSAFE_getByProps({ name: 'arrow-back' }).parent?.parent; + fireEvent.press(backButton); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('renders participant display name', () => { + const { getByText } = render( + + ); + + expect(getByText('Bob Smith')).toBeTruthy(); + }); + + it('renders participant avatar when provided', () => { + const { UNSAFE_getByType } = render( + + ); + + const { Image } = require('react-native'); + const image = UNSAFE_getByType(Image); + expect(image.props.source.uri).toBe('https://example.com/avatar.jpg'); + }); + + it('navigates to user profile when participant is pressed', () => { + const { getByText } = render( + + ); + + const participantButton = getByText('Dave').parent?.parent?.parent; + fireEvent.press(participantButton); + + expect(mockNavigate).toHaveBeenCalledWith('Profile', { + screen: 'UserProfile', + params: { username: 'dave' }, + }); + }); + + it('does not render participant info when null', () => { + const { UNSAFE_queryByType } = render(); + + const { Image } = require('react-native'); + expect(UNSAFE_queryByType(Image)).toBeNull(); + }); + + it('handles participant without avatarUrl', () => { + const { UNSAFE_getByType } = render( + + ); + + const { Image } = require('react-native'); + const image = UNSAFE_getByType(Image); + expect(image.props.source.uri).toBeUndefined(); + }); +}); diff --git a/src/__tests__/components/messages/MessageInput.test.tsx b/src/__tests__/components/messages/MessageInput.test.tsx index 38b6f14ea..684bc907f 100644 --- a/src/__tests__/components/messages/MessageInput.test.tsx +++ b/src/__tests__/components/messages/MessageInput.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ import { fireEvent, render } from '@testing-library/react-native'; import MessageInput from '@/components/messages/MessageInput'; @@ -5,6 +6,10 @@ import MessageInput from '@/components/messages/MessageInput'; jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const }) })); jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +jest.mock('expo-image-picker', () => ({ + launchImageLibraryAsync: jest.fn(), +})); + describe('MessageInput', () => { it('does not render send button for empty value', () => { const { queryByTestId } = render( @@ -47,4 +52,188 @@ describe('MessageInput', () => { ); expect(queryByTestId('send-button')).toBeNull(); }); + + it('shows send button when media is present without text', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('send-button')).toBeTruthy(); + }); + + it('displays media preview with remove button', () => { + const { getByLabelText, UNSAFE_getByType } = render( + + ); + + const removeButton = getByLabelText('Remove image'); + expect(removeButton).toBeTruthy(); + + const { Image } = require('react-native'); + const image = UNSAFE_getByType(Image); + expect(image.props.source.uri).toBe('file://test-photo.jpg'); + }); + + it('calls onMediaSelect with null when media is removed', () => { + const onMediaSelect = jest.fn(); + const { getByLabelText } = render( + + ); + + const removeButton = getByLabelText('Remove image'); + fireEvent.press(removeButton); + + expect(onMediaSelect).toHaveBeenCalledWith(null); + }); + + it('opens image picker when image button is pressed', async () => { + const ImagePicker = require('expo-image-picker'); + const onMediaSelect = jest.fn(); + + ImagePicker.launchImageLibraryAsync.mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file://selected-image.jpg', duration: undefined }], + }); + + const { UNSAFE_getByProps } = render( + + ); + + const imageButton = UNSAFE_getByProps({ name: 'image-outline' }).parent?.parent; + await fireEvent.press(imageButton); + + expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({ + mediaTypes: ['images', 'videos'], + allowsMultipleSelection: false, + allowsEditing: false, + quality: 1, + videoMaxDuration: 140, + }); + + expect(onMediaSelect).toHaveBeenCalledWith('file://selected-image.jpg', 'photo'); + }); + + it('detects video type when asset has duration', async () => { + const ImagePicker = require('expo-image-picker'); + const onMediaSelect = jest.fn(); + + ImagePicker.launchImageLibraryAsync.mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file://selected-video.mp4', duration: 30 }], + }); + + const { UNSAFE_getByProps } = render( + + ); + + const imageButton = UNSAFE_getByProps({ name: 'image-outline' }).parent?.parent; + await fireEvent.press(imageButton); + + expect(onMediaSelect).toHaveBeenCalledWith('file://selected-video.mp4', 'video'); + }); + + it('does not call onMediaSelect when picker is canceled', async () => { + const ImagePicker = require('expo-image-picker'); + const onMediaSelect = jest.fn(); + + ImagePicker.launchImageLibraryAsync.mockResolvedValue({ + canceled: true, + assets: [], + }); + + const { UNSAFE_getByProps } = render( + + ); + + const imageButton = UNSAFE_getByProps({ name: 'image-outline' }).parent?.parent; + await fireEvent.press(imageButton); + + expect(onMediaSelect).not.toHaveBeenCalled(); + }); + + it('respects maxLength of 1000 characters', () => { + const { UNSAFE_getByType } = render( + + ); + + const { TextInput } = require('react-native'); + const input = UNSAFE_getByType(TextInput); + expect(input.props.maxLength).toBe(1000); + }); + + it('has multiline enabled for TextInput', () => { + const { UNSAFE_getByType } = render( + + ); + + const { TextInput } = require('react-native'); + const input = UNSAFE_getByType(TextInput); + expect(input.props.multiline).toBe(true); + }); + + it('has correct style constraints for height', () => { + const { UNSAFE_getByType } = render( + + ); + + const { TextInput, StyleSheet } = require('react-native'); + const input = UNSAFE_getByType(TextInput); + + const flattenedStyle = StyleSheet.flatten(input.props.style); + expect(flattenedStyle.maxHeight).toBe(100); + expect(flattenedStyle.minHeight).toBe(20); + }); }); diff --git a/src/__tests__/components/messages/MessageItem.test.tsx b/src/__tests__/components/messages/MessageItem.test.tsx index b2752de2e..6ea0f8a80 100644 --- a/src/__tests__/components/messages/MessageItem.test.tsx +++ b/src/__tests__/components/messages/MessageItem.test.tsx @@ -1,16 +1,23 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable react/display-name */ -import { Linking } from 'react-native'; +import { Image, Linking, Pressable, TouchableOpacity } from 'react-native'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render } from '@testing-library/react-native'; import MessageItem from '@/components/messages/MessageItem'; +import { MessageOptionsModal } from '@/components/messages/MessageOptionsModal'; +import { ReactionDetailsDrawer } from '@/components/messages/ReactionDetailsDrawer'; +import { ReactionPicker } from '@/components/messages/ReactionPicker'; jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const }) })); +const mockNavigate = jest.fn(); + jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ - navigate: jest.fn(), + navigate: mockNavigate, goBack: jest.fn(), }), })); @@ -134,4 +141,716 @@ describe('MessageItem', () => { expect(openSpy).toHaveBeenCalledWith('https://raven.dev'); }); + + it('displays same reaction count when both users react with same emoji', () => { + const msg = { + ...base, + id: 'same-react', + content: 'Test message', + reactions: { + sender: { + username: 'alice', + displayName: 'Alice', + avatarUrl: null, + reaction: '❤️', + reactedAt: new Date().toISOString(), + }, + receiver: { + username: 'bob', + displayName: 'Bob', + avatarUrl: null, + reaction: '❤️', + reactedAt: new Date().toISOString(), + }, + }, + }; + + const { getByText } = render( + , + { wrapper: createWrapper() } + ); + + expect(getByText('❤️')).toBeTruthy(); + expect(getByText('2')).toBeTruthy(); + }); + + it('displays different reactions when users react with different emojis', () => { + const msg = { + ...base, + id: 'diff-react', + content: 'Test message', + reactions: { + sender: { + username: 'alice', + displayName: 'Alice', + avatarUrl: null, + reaction: '❤️', + reactedAt: new Date().toISOString(), + }, + receiver: { + username: 'bob', + displayName: 'Bob', + avatarUrl: null, + reaction: '😂', + reactedAt: new Date().toISOString(), + }, + }, + }; + + const { getByText } = render( + , + { wrapper: createWrapper() } + ); + + expect(getByText('❤️')).toBeTruthy(); + expect(getByText('😂')).toBeTruthy(); + }); + + it('shows border on current user reaction when different reactions', () => { + const msg = { + ...base, + id: 'border-react', + isMine: true, + content: 'Test message', + reactions: { + sender: { + username: 'testuser', + displayName: 'Test User', + avatarUrl: null, + reaction: '❤️', + reactedAt: new Date().toISOString(), + }, + receiver: { + username: 'bob', + displayName: 'Bob', + avatarUrl: null, + reaction: '😂', + reactedAt: new Date().toISOString(), + }, + }, + }; + + const { getByText } = render( + , + { wrapper: createWrapper() } + ); + + const heartReaction = getByText('❤️').parent; + // Current user's reaction should have border styling + expect(heartReaction?.props.style).toBeTruthy(); + }); + + it('displays image media with correct dimensions', () => { + const msg = { + ...base, + id: 'img-msg', + content: '', + mediaUrl: 'https://example.com/image.jpg', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const image = UNSAFE_getByType(Image); + expect(image.props.source.uri).toBe('https://example.com/image.jpg'); + expect(image.props.style).toMatchObject({ + borderRadius: 16, + }); + }); + + it('displays video media with play icon overlay', () => { + const msg = { + ...base, + id: 'vid-msg', + content: '', + mediaUrl: 'https://example.com/video.mp4', + }; + + const { UNSAFE_getByProps } = render( + , + { wrapper: createWrapper() } + ); + + const playIcon = UNSAFE_getByProps({ name: 'play' }); + expect(playIcon.props.size).toBe(32); + expect(playIcon.props.color).toBe('white'); + }); + + it('navigates to MESSAGE_IMAGE screen when image is pressed', () => { + const msg = { + ...base, + id: 'img-press', + content: 'Check this', + mediaUrl: 'https://example.com/photo.jpg', + }; + + const { getByTestId, UNSAFE_queryAllByType } = render( + , + { wrapper: createWrapper() } + ); + + // Check if image is rendered first + const image = UNSAFE_queryAllByType(Image)[0]; + expect(image).toBeTruthy(); + + // The image is wrapped in a Pressable + const pressables = UNSAFE_queryAllByType(Pressable); + if (pressables.length > 0) { + pressables[0].props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith('MESSAGE_IMAGE', { + uri: 'https://example.com/photo.jpg', + }); + } + }); + + it('displays video media with correct structure', () => { + const msg = { + ...base, + id: 'vid-press', + content: 'Watch this', + mediaUrl: 'https://example.com/video.mp4', + }; + + const { getByTestId } = render( + , + { wrapper: createWrapper() } + ); + + // Verify the message is rendered + expect(getByTestId('message-vid-press')).toBeTruthy(); + }); + + it('shows "Uploading" status for optimistic message with media', () => { + const msg = { + ...base, + id: 'optimistic-media-123', + content: '', + mediaUrl: 'file://local-video.mp4', + }; + + const { getByText } = render( + , + { wrapper: createWrapper() } + ); + + expect(getByText(/Uploading/)).toBeTruthy(); + }); + + it('calls onDelete when delete is confirmed in options modal', () => { + const { act } = require('react'); + const onDelete = jest.fn(); + const msg = { + ...base, + id: 'delete-me', + content: 'Test message', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + // Simulate long press to open options modal + const touchable = UNSAFE_getByType(TouchableOpacity); + act(() => { + touchable.props.onLongPress(); + }); + + // Find and trigger the delete handler through the MessageOptionsModal + // (The modal receives onDelete prop which should call our onDelete) + const modal = UNSAFE_getByType(MessageOptionsModal); + modal.props.onDelete(); + + expect(onDelete).toHaveBeenCalledWith('delete-me'); + }); + + it('does not show options modal on long press for optimistic messages', () => { + const msg = { + ...base, + id: 'optimistic-no-options', + content: 'Sending...', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const touchable = UNSAFE_getByType(TouchableOpacity); + expect(touchable.props.disabled).toBe(true); + }); + + it('opens reaction details drawer when reactions are pressed', () => { + const msg = { + ...base, + id: 'react-details', + content: 'Test message', + reactions: { + sender: { + username: 'alice', + displayName: 'Alice', + avatarUrl: null, + reaction: '❤️', + reactedAt: new Date().toISOString(), + }, + receiver: { + username: 'bob', + displayName: 'Bob', + avatarUrl: null, + reaction: '😂', + reactedAt: new Date().toISOString(), + }, + }, + }; + + const { getByText, UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + // Press on the reactions area + const reactionsPressable = getByText('❤️').parent?.parent; + if (reactionsPressable?.props.onPress) { + reactionsPressable.props.onPress(); + } + + // ReactionDetailsDrawer should receive visible=true + const drawer = UNSAFE_getByType(ReactionDetailsDrawer); + // Initial state before interaction would have visible=false + }); + + it('opens reaction picker on double tap', async () => { + jest.useFakeTimers(); + + const msg = { + ...base, + id: 'double-tap-msg', + content: 'Double tap me', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const touchable = UNSAFE_getByType(TouchableOpacity); + + // First tap + touchable.props.onPress(); + + // Second tap within 300ms + touchable.props.onPress(); + + // ReactionPicker should be triggered + const picker = UNSAFE_getByType(ReactionPicker); + expect(picker).toBeTruthy(); + + jest.useRealTimers(); + }); + + it('opens reaction picker from options modal', () => { + const { act } = require('react'); + const msg = { + ...base, + id: 'react-from-modal', + content: 'Message', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + // Long press to open options + const touchable = UNSAFE_getByType(TouchableOpacity); + act(() => { + touchable.props.onLongPress(); + }); + + // Trigger react option + const modal = UNSAFE_getByType(MessageOptionsModal); + modal.props.onReact(); + + // ReactionPicker should be rendered + const picker = UNSAFE_getByType(ReactionPicker); + expect(picker).toBeTruthy(); + }); + + it('calls handleSelectEmoji when emoji is selected', () => { + const msg = { + ...base, + id: 'select-emoji', + content: 'Test', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const picker = UNSAFE_getByType(ReactionPicker); + picker.props.onSelectEmoji('👍'); + + // Should call sendReaction internally + expect(picker.props.onSelectEmoji).toBeTruthy(); + }); + + it('handles undo reaction when user has reacted', () => { + const { act } = require('react'); + const msg = { + ...base, + id: 'undo-react', + content: 'Test', + reactions: { + sender: { + username: 'testuser', + displayName: 'Test', + avatarUrl: null, + reaction: '❤️', + reactedAt: new Date().toISOString(), + }, + receiver: { + username: 'other', + displayName: 'Other', + avatarUrl: null, + reaction: null, + reactedAt: null, + }, + }, + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const drawer = UNSAFE_getByType(ReactionDetailsDrawer); + act(() => { + drawer.props.onUndoReaction(); + }); + + // Should call sendReaction to toggle off + expect(drawer.props.onUndoReaction).toBeTruthy(); + }); + + it('closes modals when onClose is called', () => { + const { act } = require('react'); + const msg = { + ...base, + id: 'close-modals', + content: 'Test', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const optionsModal = UNSAFE_getByType(MessageOptionsModal); + const reactionPicker = UNSAFE_getByType(ReactionPicker); + const reactionDrawer = UNSAFE_getByType(ReactionDetailsDrawer); + + act(() => { + optionsModal.props.onClose(); + reactionPicker.props.onClose(); + reactionDrawer.props.onClose(); + }); + + // All modals should have onClose handlers + expect(optionsModal.props.onClose).toBeTruthy(); + expect(reactionPicker.props.onClose).toBeTruthy(); + expect(reactionDrawer.props.onClose).toBeTruthy(); + }); + + it('handles image load event and calculates dimensions', () => { + const { act } = require('react'); + const msg = { + ...base, + id: 'image-load', + content: 'Photo', + mediaUrl: 'https://example.com/large-image.jpg', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const image = UNSAFE_getByType(Image); + + // Simulate loading a large image + const loadEvent = { + nativeEvent: { + source: { + width: 800, + height: 600, + }, + }, + }; + + if (image.props.onLoad) { + act(() => { + image.props.onLoad(loadEvent); + }); + } + + // Image should be resized to fit maxWidth/maxHeight + expect(image.props.onLoad).toBeTruthy(); + }); + + it('handles image load for small images without resizing', () => { + const { act } = require('react'); + const msg = { + ...base, + id: 'small-image', + content: 'Small photo', + mediaUrl: 'https://example.com/small.jpg', + }; + + const { UNSAFE_getByType } = render( + , + { wrapper: createWrapper() } + ); + + const image = UNSAFE_getByType(Image); + + // Simulate loading a small image + const loadEvent = { + nativeEvent: { + source: { + width: 200, + height: 150, + }, + }, + }; + + if (image.props.onLoad) { + act(() => { + image.props.onLoad(loadEvent); + }); + } + + expect(image.props.onLoad).toBeTruthy(); + }); + + it('identifies video URLs correctly', () => { + const mp4Msg = { + ...base, + id: 'mp4-video', + content: 'MP4 video', + mediaUrl: 'https://example.com/video.mp4', + }; + + const movMsg = { + ...base, + id: 'mov-video', + content: 'MOV video', + mediaUrl: 'https://example.com/video.mov', + }; + + const videoMsg = { + ...base, + id: 'video-url', + content: 'Video', + mediaUrl: 'https://cdn.example.com/videos/clip.m3u8', + }; + + const { rerender } = render( + , + { wrapper: createWrapper() } + ); + + // All should be recognized as videos and render play icon + expect(true).toBeTruthy(); + }); + + it('does not navigate to IMAGE screen for video URLs', () => { + const msg = { + ...base, + id: 'video-not-image', + content: 'Video', + mediaUrl: 'https://example.com/clip.mp4', + }; + + render( + , + { wrapper: createWrapper() } + ); + + // Video URLs should not trigger image navigation + // handleImagePress checks isVideo before navigating + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('handles empty content with only media', () => { + const msg = { + ...base, + id: 'media-only', + content: '', + mediaUrl: 'https://example.com/image.jpg', + }; + + const { getByTestId } = render( + , + { wrapper: createWrapper() } + ); + + expect(getByTestId('message-media-only')).toBeTruthy(); + }); + + it('handles message without currentUser gracefully', () => { + jest.mock('@/stores/userStore', () => ({ + useUserStore: () => null, + })); + + const msg = { + ...base, + id: 'no-user', + content: 'Test', + }; + + const { queryByText } = render( + , + { wrapper: createWrapper() } + ); + + // Should still render message even without currentUser + expect(queryByText('Test')).toBeTruthy(); + }); }); diff --git a/src/__tests__/components/messages/MessageOptionsModal.test.tsx b/src/__tests__/components/messages/MessageOptionsModal.test.tsx new file mode 100644 index 000000000..d0ffb4061 --- /dev/null +++ b/src/__tests__/components/messages/MessageOptionsModal.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render } from '@testing-library/react-native'; + +import { MessageOptionsModal } from '@/components/messages/MessageOptionsModal'; + +jest.mock('@/hooks/useTheme', () => ({ + useTheme: () => ({ theme: 'dark' as const }), +})); + +jest.mock('@/utils/colorTheme', () => ({ + colors: { + dark: { + dialogBackground: '#111', + border: '#333', + primary: '#4a90e2', + destructive: '#ff4d4d', + foreground: '#fff', + mutedForeground: '#aaa', + }, + }, +})); + +jest.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +describe('MessageOptionsModal', () => { + let onClose: jest.Mock; + let onDelete: jest.Mock; + let onReact: jest.Mock; + + beforeEach(() => { + onClose = jest.fn(); + onDelete = jest.fn(); + onReact = jest.fn(); + }); + + it('calls onClose when overlay is pressed', () => { + const { getByTestId } = render( + + ); + + fireEvent.press(getByTestId('modal-overlay')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('opens confirmation modal after pressing delete', () => { + const { getByText } = render( + + ); + + fireEvent.press(getByText('Delete message for you')); + + expect(getByText('Delete Message?')).toBeTruthy(); + }); + + it('closes confirmation modal when overlay is pressed', () => { + const { getByText, getByTestId, queryByText } = render( + + ); + + fireEvent.press(getByText('Delete message for you')); + + fireEvent.press(getByTestId('confirm-overlay')); + + expect(queryByText('Delete Message?')).toBeNull(); + }); + + it('clicking Cancel closes confirmation modal', () => { + const { getByText, queryByText } = render( + + ); + + fireEvent.press(getByText('Delete message for you')); + fireEvent.press(getByText('Cancel')); + + expect(queryByText('Delete Message?')).toBeNull(); + }); + + it('clicking Delete calls onDelete', () => { + const { getByText } = render( + + ); + + fireEvent.press(getByText('Delete message for you')); + fireEvent.press(getByText('Delete')); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('pressing "Add a reaction" calls onReact and onClose', () => { + const { getByText } = render( + + ); + + fireEvent.press(getByText('Add a reaction')); + + expect(onReact).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/components/messages/NewMessageComposer.test.tsx b/src/__tests__/components/messages/NewMessageComposer.test.tsx index 5cae48af1..d95f67269 100644 --- a/src/__tests__/components/messages/NewMessageComposer.test.tsx +++ b/src/__tests__/components/messages/NewMessageComposer.test.tsx @@ -121,6 +121,7 @@ describe('NewMessageComposer', () => { avatarUrl: 'https://avatar/bob.png', bannerUrl: 'https://banner/bob.png', bio: 'Can fix it.', + bioEntities: null, relationship: { following: false, follower: false, @@ -134,6 +135,7 @@ describe('NewMessageComposer', () => { avatarUrl: 'https://avatar/carol.png', bannerUrl: 'https://banner/carol.png', bio: null, + bioEntities: null, relationship: { following: false, follower: false, @@ -278,6 +280,7 @@ describe('NewMessageComposer', () => { displayName: 'Missing Id', avatarUrl: null as unknown as string, bannerUrl: null, + bioEntities: null, bio: null, relationship: { following: false, @@ -324,6 +327,7 @@ describe('NewMessageComposer', () => { displayName: 'Ahmed', avatarUrl: undefined as unknown as string, bannerUrl: null, + bioEntities: null, bio: 'Building Raven.', relationship: { following: false, diff --git a/src/__tests__/components/messages/ReactionDetailsDrawer.test.tsx b/src/__tests__/components/messages/ReactionDetailsDrawer.test.tsx new file mode 100644 index 000000000..8d2629577 --- /dev/null +++ b/src/__tests__/components/messages/ReactionDetailsDrawer.test.tsx @@ -0,0 +1,130 @@ +/* eslint-disable react/display-name */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { fireEvent, render } from '@testing-library/react-native'; + +import { ReactionDetailsDrawer } from '@/components/messages/ReactionDetailsDrawer'; + +jest.mock('react-native-animated-bottom-drawer', () => { + return ({ children }: any) => <>{children}; +}); + +jest.mock('@/hooks/useTheme', () => ({ + useTheme: () => ({ + theme: 'light', + }), +})); + +jest.mock('@/utils/colorTheme', () => ({ + colors: { + light: { + background: '#fff', + primary: '#00f', + }, + }, +})); + +jest.mock('@/stores/userStore', () => ({ + useUserStore: (selector: any) => + selector({ + user: { + username: 'currentUser', + displayName: 'Current User', + }, + }), +})); + +const senderReaction = { + username: 'currentUser', + displayName: 'Current User', + avatarUrl: null, + reaction: '😂', + reactedAt: new Date().toISOString(), +}; + +const receiverReaction = { + username: 'otherUser', + displayName: 'Other User', + avatarUrl: null, + reaction: '🔥', + reactedAt: new Date().toISOString(), +}; + +describe('ReactionDetailsDrawer', () => { + const baseProps = { + visible: true, + onClose: jest.fn(), + onUndoReaction: jest.fn(), + senderReaction: null, + receiverReaction: null, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('does not render when visible=false', () => { + const { queryByText } = render(); + + expect(queryByText('Reactions')).toBeNull(); + }); + + it('renders current user reaction first', () => { + const { getByText } = render( + + ); + + expect(getByText('😂')).toBeTruthy(); + + expect(getByText('Current User')).toBeTruthy(); + }); + + it('renders other user reaction second', () => { + const { getByText } = render( + + ); + + expect(getByText('🔥')).toBeTruthy(); + expect(getByText('Other User')).toBeTruthy(); + }); + + it('calls undo + close on pressing Undo', () => { + const { getByText } = render( + + ); + + fireEvent.press(getByText('Undo')); + + expect(baseProps.onUndoReaction).toHaveBeenCalled(); + expect(baseProps.onClose).toHaveBeenCalled(); + }); + + it('renders empty state when no reactions exist', () => { + const { getByText } = render( + + ); + + expect(getByText('No reactions yet')).toBeTruthy(); + }); + + it('renders only other user reaction if the current user did not react', () => { + const { getByText } = render( + + ); + + expect(getByText('🔥')).toBeTruthy(); + expect(getByText('Other User')).toBeTruthy(); + + expect(() => getByText('Undo')).toThrow(); + }); +}); diff --git a/src/__tests__/components/messages/ReactionPicker.test.tsx b/src/__tests__/components/messages/ReactionPicker.test.tsx new file mode 100644 index 000000000..172936ca7 --- /dev/null +++ b/src/__tests__/components/messages/ReactionPicker.test.tsx @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-require-imports */ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import { ReactionPicker } from '@/components/messages/ReactionPicker'; + +// Mock Ionicons +jest.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +// Mock Emoji Picker +jest.mock('rn-emoji-keyboard', () => { + const mockReact = require('react'); + const { View, Text } = require('react-native'); + + return function MockEmojiPicker(props: any) { + if (!props.open) return null; + + return mockReact.createElement( + View, + { testID: 'emoji-picker' }, + mockReact.createElement( + Text, + { + testID: 'emoji-picker-item', + onPress: () => props.onEmojiSelected({ emoji: '🔥' }), + }, + '🔥' + ) + ); + }; +}); + +// Default mock Zustand store +jest.mock('@/stores/reactionStore', () => { + const reactions = ['😂', '👍', '❤️']; + return { + useReactionStore: () => ({ + addRecentReaction: jest.fn(), + loadReactions: jest.fn(), + getDisplayReactions: () => reactions, + }), + }; +}); + +// Mock theme hook +jest.mock('@/hooks/useTheme', () => ({ + useTheme: () => ({ + theme: 'light', + }), +})); + +// Mock color theme +jest.mock('@/utils/colorTheme', () => ({ + colors: { + light: { + dialogBackground: '#fff', + border: '#ddd', + mutedForeground: '#999', + primary: '#00f', + accent: '#eee', + foreground: '#000', + }, + }, +})); + +describe('ReactionPicker', () => { + const baseProps = { + visible: true, + onClose: jest.fn(), + onSelectEmoji: jest.fn(), + messagePosition: { y: 200 }, + isMine: false, + currentReaction: null, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders nothing when visible=false', () => { + const { queryByTestId } = render(); + expect(queryByTestId('reaction-overlay')).toBeNull(); + }); + + it('renders quick reactions', () => { + const { getByText } = render(); + expect(getByText('😂')).toBeTruthy(); + expect(getByText('👍')).toBeTruthy(); + expect(getByText('❤️')).toBeTruthy(); + }); + + it('calls onSelectEmoji on quick tap', () => { + const { getByText } = render(); + fireEvent.press(getByText('😂')); + expect(baseProps.onSelectEmoji).toHaveBeenCalledWith('😂'); + expect(baseProps.onClose).toHaveBeenCalled(); + }); + + it('opens full picker via "more" button', async () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('more-button')); + + await waitFor(() => expect(getByTestId('emoji-picker-item')).toBeTruthy()); + }); + + it('selects emoji from full picker', async () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('more-button')); + fireEvent.press(await waitFor(() => getByTestId('emoji-picker-item'))); + + expect(baseProps.onSelectEmoji).toHaveBeenCalledWith('🔥'); + }); + + it('closes when overlay pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('reaction-overlay')); + + expect(baseProps.onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/messages/TypingIndicator.test.tsx b/src/__tests__/components/messages/TypingIndicator.test.tsx new file mode 100644 index 000000000..9411d177d --- /dev/null +++ b/src/__tests__/components/messages/TypingIndicator.test.tsx @@ -0,0 +1,64 @@ +import { Animated } from 'react-native'; + +import { render } from '@testing-library/react-native'; + +import TypingIndicator from '@/components/messages/TypingIndicator'; + +jest.mock('@/hooks/useTheme', () => ({ + useTheme: () => ({ theme: 'dark' as const }), +})); + +jest.mock('@/utils/colorTheme', () => ({ + colors: { + dark: { + search: '#222222', + foreground: '#ffffff', + }, + }, +})); + +const mockStart = jest.fn(); +const mockStop = jest.fn(); + +const createMockAnimation = () => ({ + start: mockStart, + stop: mockStop, + reset: jest.fn(), +}); + +jest.spyOn(Animated, 'timing').mockImplementation(() => createMockAnimation()); + +jest.spyOn(Animated, 'loop').mockImplementation(() => createMockAnimation()); + +jest.spyOn(Animated, 'sequence').mockImplementation(() => createMockAnimation()); + +jest.spyOn(Animated, 'delay').mockImplementation(() => createMockAnimation()); + +describe('TypingIndicator', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders three animated dots', () => { + const { getAllByTestId } = render(); + + const dots = getAllByTestId('typing-dot'); + expect(dots.length).toBe(3); + }); + + it('creates animation loops for all three dots', () => { + render(); + expect(Animated.loop).toHaveBeenCalledTimes(3); + }); + + it('starts animations on mount', () => { + render(); + expect(mockStart).toHaveBeenCalled(); + }); + + it('stops animations on unmount', () => { + const { unmount } = render(); + unmount(); + expect(mockStop).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/notifications/FollowNotificationItem.test.tsx b/src/__tests__/components/notifications/FollowNotificationItem.test.tsx index 47ffdb899..b18c59256 100644 --- a/src/__tests__/components/notifications/FollowNotificationItem.test.tsx +++ b/src/__tests__/components/notifications/FollowNotificationItem.test.tsx @@ -1,7 +1,7 @@ import { Alert } from 'react-native'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { fireEvent, render } from '@testing-library/react-native'; +import { act, fireEvent, render } from '@testing-library/react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { FollowNotificationItem } from '@/components/notifications/types/FollowNotificationItem'; @@ -123,7 +123,6 @@ describe('FollowNotificationItem', () => { ); - // Button text is rendered with lowercase from nativewind const followButton = getByText('Follow back'); expect(followButton).toBeTruthy(); }); @@ -185,12 +184,13 @@ describe('FollowNotificationItem', () => { const followButton = getByText('Follow back'); fireEvent.press(followButton); - // Simulate error by calling the error callback const mutateCall = mockFollowMutation.mutate.mock.calls[0]; const errorCallback = mutateCall[1]?.onError; if (errorCallback) { - errorCallback(new Error('Network error')); + act(() => { + errorCallback(new Error('Network error')); + }); } expect(mockAlert).toHaveBeenCalledWith('Unable to update follow', 'Network error'); @@ -292,9 +292,46 @@ describe('FollowNotificationItem', () => { const nameText = getByText('Follower One'); - // The name text is within a pressable container fireEvent.press(nameText.parent?.parent || nameText); expect(mockOnPress).toHaveBeenCalled(); }); + + it('navigates to user profile when avatar is pressed', () => { + const { getByTestId } = render( + + + + ); + + const avatarGroup = getByTestId('actor-avatar-group'); + fireEvent.press(avatarGroup); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + it('should use default error message if error has no message', () => { + const mockAlert = jest.spyOn(Alert, 'alert'); + + const { getByText } = render( + + + + ); + + const followButton = getByText('Follow back'); + fireEvent.press(followButton); + + const mutateCall = mockFollowMutation.mutate.mock.calls[0]; + const errorCallback = mutateCall[1]?.onError; + + if (errorCallback) { + act(() => { + errorCallback({}); + }); + } + + expect(mockAlert).toHaveBeenCalledWith('Unable to update follow', 'Please try again.'); + mockAlert.mockRestore(); + }); }); diff --git a/src/__tests__/components/notifications/NotificationItem.test.tsx b/src/__tests__/components/notifications/NotificationItem.test.tsx index 45aadcd00..d7ea32391 100644 --- a/src/__tests__/components/notifications/NotificationItem.test.tsx +++ b/src/__tests__/components/notifications/NotificationItem.test.tsx @@ -92,6 +92,13 @@ const createMockTweet = () => ({ username: 'testuser', displayName: 'Test User', avatarUrl: null, + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, }, content: 'Test content', createdAt: '2024-01-01T00:00:00Z', diff --git a/src/__tests__/components/notifications/NotificationsList.test.tsx b/src/__tests__/components/notifications/NotificationsList.test.tsx index f1fcdc69f..9e58e0d6e 100644 --- a/src/__tests__/components/notifications/NotificationsList.test.tsx +++ b/src/__tests__/components/notifications/NotificationsList.test.tsx @@ -108,7 +108,13 @@ describe('NotificationsList', () => { username: 'follower1', displayName: 'Follower One', avatarUrl: '', - isFollowing: false, + relationship: { + following: false, + follower: null, + blocking: false, + blockedBy: false, + muted: false, + }, }, ], totalCount: 1, @@ -125,7 +131,13 @@ describe('NotificationsList', () => { username: 'follower2', displayName: 'Follower Two', avatarUrl: '', - isFollowing: false, + relationship: { + following: false, + follower: null, + blocking: false, + blockedBy: false, + muted: false, + }, }, ], totalCount: 1, diff --git a/src/__tests__/components/notifications/TweetActionBar.test.tsx b/src/__tests__/components/notifications/TweetActionBar.test.tsx index 89e5fb343..f6656dd8b 100644 --- a/src/__tests__/components/notifications/TweetActionBar.test.tsx +++ b/src/__tests__/components/notifications/TweetActionBar.test.tsx @@ -40,6 +40,7 @@ describe('TweetActionBar', () => { beforeEach(() => { jest.clearAllMocks(); + mockQueryClient.clear(); // Clear the query cache between tests (tweetsService.likeTweet as jest.Mock).mockResolvedValue({}); (tweetsService.unlikeTweet as jest.Mock).mockResolvedValue({}); (tweetsService.retweetTweet as jest.Mock).mockResolvedValue({}); @@ -91,7 +92,7 @@ describe('TweetActionBar', () => { expect(tweetsService.likeTweet).toHaveBeenCalledWith('tweet-1'); }); - expect(getByText('11')).toBeTruthy(); // like count increased + await waitFor(() => expect(getByText('11')).toBeTruthy()); // like count increased }); it('should handle unlike action', async () => { @@ -119,7 +120,7 @@ describe('TweetActionBar', () => { expect(tweetsService.unlikeTweet).toHaveBeenCalledWith('tweet-1'); }); - expect(getByText('9')).toBeTruthy(); // like count decreased + await waitFor(() => expect(getByText('9')).toBeTruthy()); // like count decreased }); it('should handle retweet action', async () => { @@ -147,7 +148,7 @@ describe('TweetActionBar', () => { expect(tweetsService.retweetTweet).toHaveBeenCalledWith('tweet-1'); }); - expect(getByText('6')).toBeTruthy(); // retweet count increased + await waitFor(() => expect(getByText('6')).toBeTruthy()); // retweet count increased }); it('should handle unretweet action', async () => { @@ -175,7 +176,7 @@ describe('TweetActionBar', () => { expect(tweetsService.unretweetTweet).toHaveBeenCalledWith('tweet-1'); }); - expect(getByText('4')).toBeTruthy(); // retweet count decreased + await waitFor(() => expect(getByText('4')).toBeTruthy()); // retweet count decreased }); it('should call onReplyPress when reply button is pressed', () => { diff --git a/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx index 5f0f476b1..fa4d34bee 100644 --- a/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx +++ b/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx @@ -1,9 +1,10 @@ import { useNavigation } from '@react-navigation/native'; -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; import { TweetLikeNotificationItem } from '@/components/notifications/types/TweetLikeNotificationItem'; import { useRootNavigation } from '@/hooks/navigation/useRootNavigation'; import { NotificationType, TweetNotification } from '@/types/notifications'; +import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames'; jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'light' }), @@ -21,14 +22,15 @@ jest.mock('@/hooks/navigation/useRootNavigation', () => ({ })), })); -jest.mock('@/components/ui/ActorAvatarGroup', () => () => null); - +jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup'); jest.mock('@/components/notifications/types/TweetActionBar', () => ({ - TweetActionBar: () => null, + TweetActionBar: 'TweetActionBar', })); describe('TweetLikeNotificationItem', () => { - const mockOnPress = jest.fn(); + const mockOnPress = jest.fn((callback) => callback()); + const mockNavigate = jest.fn(); + const mockRootNavigate = jest.fn(); const createNotification = (overrides?: Partial): TweetNotification => ({ id: '1', @@ -53,6 +55,13 @@ describe('TweetLikeNotificationItem', () => { username: 'author', displayName: 'Author', avatarUrl: null, + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, }, content: 'Test tweet content', createdAt: '2024-01-01T00:00:00Z', @@ -75,19 +84,28 @@ describe('TweetLikeNotificationItem', () => { beforeEach(() => { jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate }); }); - it('renders single user like notification', () => { + it('renders correctly and handles main press', () => { const notification = createNotification(); const { getByText } = render( ); - expect(getByText(/User One/)).toBeTruthy(); + expect(getByText('User One')).toBeTruthy(); expect(getByText(/liked your tweet/)).toBeTruthy(); + expect(getByText('Test tweet content')).toBeTruthy(); + + const contentText = getByText('Test tweet content'); + fireEvent.press(contentText); + + expect(mockOnPress).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' }); }); - it('renders multiple users like notification', () => { + it('renders multiple users like notification and handles interaction', () => { const notification = createNotification({ actorSummary: { previewActors: [ @@ -106,49 +124,37 @@ describe('TweetLikeNotificationItem', () => { ); - expect(getByText(/User One/)).toBeTruthy(); - expect(getByText(/4 others/)).toBeTruthy(); + expect(getByText('User One')).toBeTruthy(); + expect(getByText(/and 4 others/)).toBeTruthy(); // Updated expectation logic if "and" is present + + // Pressing the notification should still navigate to tweet + fireEvent.press(getByText('User One')); + expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' }); }); - it('renders tweet content when available', () => { + it('navigates to user profile when actor avatar is pressed', () => { const notification = createNotification(); - const { getByText } = render( + const { getByTestId } = render( ); - expect(getByText(/Test tweet content/)).toBeTruthy(); + // Press the avatar pressable wrapper which triggers handleActorPress + const avatarPressable = getByTestId('actor-avatar-pressable'); + fireEvent.press(avatarPressable); + + expect(mockOnPress).toHaveBeenCalled(); + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'user1' }, + }); }); - it('renders notification with media', () => { + it('handles hashtag press in tweet content', () => { const notification = createNotification({ tweetSummary: { primaryTweet: { - id: 'tweet1', - author: { - username: 'author', - displayName: 'Author', - avatarUrl: null, - }, - content: 'Test tweet', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 1, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: [ - { - type: 'IMAGE', - url: 'https://example.com/image.jpg', - altText: 'Test image', - width: 100, - height: 100, - }, - ], - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, + ...createNotification().tweetSummary.primaryTweet, + content: 'Hello #testhashtag', }, totalCount: 1, subjectIds: ['tweet1'], @@ -159,79 +165,38 @@ describe('TweetLikeNotificationItem', () => { ); - expect(getByText(/Test tweet/)).toBeTruthy(); - }); + const hashtag = getByText('#testhashtag'); + fireEvent.press(hashtag); - it('handles notification press correctly', () => { - const mockNavigate = jest.fn(); - (useNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, { + initialQuery: '#testhashtag', + initialTab: 'top', }); + }); + it('renders without content when tweet content is empty', () => { const notification = createNotification(); - render(); + notification.tweetSummary.primaryTweet.content = ''; - expect(mockOnPress).toBeDefined(); - }); - - it('handles mention press in tweet content', () => { - const mockRootNavigate = jest.fn(); - (useRootNavigation as jest.Mock).mockReturnValue({ - navigate: mockRootNavigate, - }); + const { getByText } = render( + + ); - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - id: 'tweet1', - author: { - username: 'author', - displayName: 'Author', - avatarUrl: null, - }, - content: 'Hello @testuser this is a test', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 1, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, - }, - totalCount: 1, - subjectIds: ['tweet1'], - }, - }); + expect(getByText(/liked your tweet/)).toBeTruthy(); + }); + it('renders seen styling', () => { + const notification = createNotification(); + notification.isSeen = true; render(); }); - it('renders without content when tweet content is empty', () => { + it('handles mention press in tweet content', () => { const notification = createNotification({ tweetSummary: { primaryTweet: { - id: 'tweet1', - author: { - username: 'author', - displayName: 'Author', - avatarUrl: null, - }, - content: '', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 1, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, + ...createNotification().tweetSummary.primaryTweet, + content: 'Hello @testuser', }, totalCount: 1, subjectIds: ['tweet1'], @@ -242,60 +207,45 @@ describe('TweetLikeNotificationItem', () => { ); - expect(getByText(/User One/)).toBeTruthy(); - }); + const mention = getByText('@testuser'); + fireEvent.press(mention); - it('handles tweet press correctly', () => { - const mockNavigate = jest.fn(); - (useRootNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'testuser' }, }); - - const notification = createNotification(); - render(); - - // Component has actor avatar that can be pressed - expect(mockOnPress).toBeDefined(); }); - it('handles hashtag press in tweet content', () => { - const mockRootNavigate = jest.fn(); - (useRootNavigation as jest.Mock).mockReturnValue({ - navigate: mockRootNavigate, - }); - + it('renders tweet with media', () => { const notification = createNotification({ tweetSummary: { primaryTweet: { - id: 'tweet1', - author: { - username: 'author', - displayName: 'Author', - avatarUrl: null, - }, - content: 'Hello #testhashtag this is a test', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 1, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, + ...createNotification().tweetSummary.primaryTweet, + content: 'Tweet with media', + media: [ + { + type: 'IMAGE', + url: 'https://example.com/image.jpg', + altText: 'Test image', + width: 100, + height: 100, + }, + ], }, totalCount: 1, subjectIds: ['tweet1'], }, }); - render(); - expect(mockRootNavigate).toBeDefined(); + const { getByText, getByTestId } = render( + + ); + + expect(getByText('Tweet with media')).toBeTruthy(); + expect(getByTestId('tweet-media-image')).toBeTruthy(); }); - it('handles case with no primary actor gracefully', () => { + it('handles empty actors array gracefully', () => { const notification = createNotification({ actorSummary: { previewActors: [], @@ -303,10 +253,17 @@ describe('TweetLikeNotificationItem', () => { }, }); - const { getByText } = render( + const { getByText, getByTestId } = render( ); expect(getByText(/liked your tweet/)).toBeTruthy(); + + // Pressing avatar should not navigate when no primary actor + const avatarPressable = getByTestId('actor-avatar-pressable'); + fireEvent.press(avatarPressable); + + // Should not have called rootNavigate for profile since no actor + expect(mockRootNavigate).not.toHaveBeenCalledWith(ROOT.PROFILE, expect.anything()); }); }); diff --git a/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx index be1a4cb48..15a4c8654 100644 --- a/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx +++ b/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx @@ -1,9 +1,10 @@ import { useNavigation } from '@react-navigation/native'; -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; import { TweetMentionNotificationItem } from '@/components/notifications/types/TweetMentionNotificationItem'; import { useRootNavigation } from '@/hooks/navigation/useRootNavigation'; import { NotificationType, TweetNotification } from '@/types/notifications'; +import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames'; jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'light' }), @@ -21,16 +22,29 @@ jest.mock('@/hooks/navigation/useRootNavigation', () => ({ })), })); -jest.mock('@/components/ui/ActorAvatarGroup', () => () => null); -jest.mock('@/components/ui/TweetMediaGrid', () => () => null); +jest.mock('@/components/ui/ActorAvatarGroup', () => { + const { Text } = jest.requireActual('react-native'); + const MockActorAvatarGroup = () => ActorAvatarGroup; + MockActorAvatarGroup.displayName = 'MockActorAvatarGroup'; + return MockActorAvatarGroup; +}); +jest.mock('@/components/ui/TweetMediaGrid', () => 'TweetMediaGrid'); jest.mock('@/components/notifications/types/TweetActionBar', () => ({ - TweetActionBar: () => null, + TweetActionBar: 'TweetActionBar', })); describe('TweetMentionNotificationItem', () => { - const mockOnPress = jest.fn(); + const mockOnPress = jest.fn((callback) => callback()); + const mockNavigate = jest.fn(); + const mockRootNavigate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate }); + }); - const createNotification = (): TweetNotification => ({ + const createNotification = (overrides?: Partial): TweetNotification => ({ id: '1', type: NotificationType.MENTION, isSeen: false, @@ -53,12 +67,19 @@ describe('TweetMentionNotificationItem', () => { username: 'author', displayName: 'Author', avatarUrl: null, + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, }, - content: 'Test tweet content', + content: 'Test tweet content mention @target and #hashtag', createdAt: '2024-01-01T00:00:00Z', replyCount: 0, retweetCount: 0, - likeCount: 1, + likeCount: 0, isLiked: false, isRetweeted: false, entities: { mentions: null, hashtags: null }, @@ -70,157 +91,116 @@ describe('TweetMentionNotificationItem', () => { totalCount: 1, subjectIds: ['tweet1'], }, + ...overrides, }); - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders mention notification', () => { + it('renders correctly and handles main press', () => { const notification = createNotification(); const { getByText } = render( ); - expect(getByText(/User One/)).toBeTruthy(); + expect(getByText('User One')).toBeTruthy(); expect(getByText(/mentioned you/)).toBeTruthy(); + expect(getByText('Test tweet content mention @target and #hashtag')).toBeTruthy(); + + // Pressing the notification body (or name text) goes to Tweet + const contentText = getByText('Test tweet content mention @target and #hashtag'); + fireEvent.press(contentText); + + expect(mockOnPress).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' }); }); - it('renders tweet content', () => { + it('navigates to user profile when actor avatar is pressed', () => { const notification = createNotification(); const { getByText } = render( ); - expect(getByText(/Test tweet content/)).toBeTruthy(); - }); + const avatar = getByText('ActorAvatarGroup'); + fireEvent.press(avatar); - it('renders notification with media', () => { - const notificationWithMedia = { - ...createNotification(), - tweetSummary: { - primaryTweet: { - ...createNotification().tweetSummary.primaryTweet, - media: [ - { - type: 'IMAGE' as const, - url: 'https://example.com/image.jpg', - altText: 'Test image', - width: 100, - height: 100, - }, - ], - }, - totalCount: 1, - subjectIds: ['tweet1'], - }, - }; + expect(mockOnPress).toHaveBeenCalled(); + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'user1' }, + }); + }); + it('handles mention press inside tweet content', () => { + const notification = createNotification(); const { getByText } = render( - + ); - expect(getByText(/User One/)).toBeTruthy(); - }); + const mention = getByText('@target'); + fireEvent.press(mention); - it('handles notification press correctly', () => { - const mockNavigate = jest.fn(); - (useNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'target' }, }); - - const notification = createNotification(); - render(); - - expect(mockOnPress).toBeDefined(); }); - it('handles mention press in tweet content', () => { - const mockRootNavigate = jest.fn(); - (useRootNavigation as jest.Mock).mockReturnValue({ - navigate: mockRootNavigate, - }); - + it('handles hashtag press inside tweet content', () => { const notification = createNotification(); - render(); - }); - - it('handles hashtag press in tweet content', () => { - const mockRootNavigate = jest.fn(); - (useRootNavigation as jest.Mock).mockReturnValue({ - navigate: mockRootNavigate, - }); + const { getByText } = render( + + ); - const notification = createNotification(); - render(); - expect(mockRootNavigate).toBeDefined(); - }); + const hashtag = getByText('#hashtag'); + fireEvent.press(hashtag); - it('handles actor press correctly', () => { - const mockRootNavigate = jest.fn(); - (useRootNavigation as jest.Mock).mockReturnValue({ - navigate: mockRootNavigate, + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, { + initialQuery: '#hashtag', + initialTab: 'top', }); - - const notification = createNotification(); - render(); - expect(mockOnPress).toBeDefined(); }); - it('handles case with no primary actor gracefully', () => { - const notificationNoActor = { - ...createNotification(), + it('handles missing primary actor gracefully', () => { + const notification = createNotification({ actorSummary: { previewActors: [], totalCount: 0, }, - }; + }); - const { getByText } = render( - + const { getByText, queryByText } = render( + ); expect(getByText(/mentioned you/)).toBeTruthy(); + // User One should not be present + expect(queryByText('User One')).toBeNull(); }); - it('renders with hashtags in content', () => { - const notificationWithHashtag = { - ...createNotification(), - tweetSummary: { - ...createNotification().tweetSummary, - primaryTweet: { - ...createNotification().tweetSummary.primaryTweet, - content: 'Test tweet with #hashtag content', - }, - }, - }; - - const { getByText } = render( - - ); - - expect(getByText(/Test tweet with #hashtag content/)).toBeTruthy(); - }); - - it('renders without media when media is null', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - const notificationWithHashtag = { - ...createNotification(), + it('renders with media', () => { + const notification = createNotification({ tweetSummary: { primaryTweet: { ...createNotification().tweetSummary.primaryTweet, - content: 'Test tweet with #hashtag', + media: [ + { + type: 'IMAGE', + url: 'https://example.com/image.jpg', + altText: 'Test image', + width: 100, + height: 100, + }, + ], }, totalCount: 1, subjectIds: ['tweet1'], }, - }; + }); - render( - - ); + render(); + }); - consoleSpy.mockRestore(); + it('renders seen styling', () => { + const notification = createNotification(); + notification.isSeen = true; + render(); }); }); diff --git a/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx index d22eb7666..8b13ae8fc 100644 --- a/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx +++ b/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx @@ -1,33 +1,49 @@ -import { render } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { fireEvent, render } from '@testing-library/react-native'; import { TweetQuoteNotificationItem } from '@/components/notifications/types/TweetQuoteNotificationItem'; +import { useRootNavigation } from '@/hooks/navigation/useRootNavigation'; import { NotificationType, TweetNotification } from '@/types/notifications'; +import { ROOT, TWEET } from '@/utils/navigation/routeNames'; jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'light' }), })); -const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - navigate: mockNavigate, - }), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), })); -const mockRootNavigate = jest.fn(); jest.mock('@/hooks/navigation/useRootNavigation', () => ({ - useRootNavigation: () => ({ - navigate: mockRootNavigate, - }), + useRootNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), })); -jest.mock('@/components/ui/ActorAvatarGroup', () => () => null); -jest.mock('@/components/notifications/types/TweetActionBar', () => ({ - TweetActionBar: () => null, -})); +jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup'); +jest.mock('@/components/notifications/types/TweetActionBar', () => { + const { Pressable, Text } = require('react-native'); + return { + TweetActionBar: ({ onReplyPress }: { onReplyPress: () => void }) => ( + + Reply + + ), + }; +}); describe('TweetQuoteNotificationItem', () => { - const mockOnPress = jest.fn(); + const mockOnPress = jest.fn((callback) => callback()); + const mockNavigate = jest.fn(); + const mockRootNavigate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate }); + }); const createNotification = (overrides?: Partial): TweetNotification => ({ id: '1', @@ -52,6 +68,13 @@ describe('TweetQuoteNotificationItem', () => { username: 'quoter', displayName: 'Quoter', avatarUrl: null, + relationship: { + follower: false, + following: false, + muted: false, + blockedBy: false, + blocking: false, + }, }, content: 'This is a quote tweet with commentary', createdAt: '2024-01-01T00:00:00Z', @@ -70,6 +93,13 @@ describe('TweetQuoteNotificationItem', () => { username: 'author', displayName: 'Original Author', avatarUrl: null, + relationship: { + follower: false, + following: false, + muted: false, + blockedBy: false, + blocking: false, + }, }, content: 'Original tweet content', createdAt: '2024-01-01T00:00:00Z', @@ -88,18 +118,21 @@ describe('TweetQuoteNotificationItem', () => { ...overrides, }); - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders single user quote notification', () => { + it('renders correctly and handles main press', () => { const notification = createNotification(); const { getByText } = render( ); - expect(getByText(/User One/)).toBeTruthy(); + expect(getByText('User One')).toBeTruthy(); expect(getByText(/quoted your tweet/)).toBeTruthy(); + expect(getByText('This is a quote tweet with commentary')).toBeTruthy(); + expect(getByText('Original tweet content')).toBeTruthy(); + + fireEvent.press(getByText('This is a quote tweet with commentary')); + + expect(mockOnPress).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'quoted-tweet1' }); }); it('renders multiple users quote notification', () => { @@ -121,70 +154,16 @@ describe('TweetQuoteNotificationItem', () => { ); - expect(getByText(/User One/)).toBeTruthy(); - expect(getByText(/2 others/)).toBeTruthy(); - }); - - it('renders quote tweet content', () => { - const notification = createNotification(); - const { getByText } = render( - - ); - - expect(getByText(/This is a quote tweet with commentary/)).toBeTruthy(); - }); - - it('renders original tweet content in preview card', () => { - const notification = createNotification(); - const { getByText } = render( - - ); - - expect(getByText(/Original tweet content/)).toBeTruthy(); - expect(getByText(/Original Author/)).toBeTruthy(); - }); - - it('renders without original tweet content', () => { - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - ...createNotification().tweetSummary.primaryTweet, - quotedTweet: { - id: 'original-tweet1', - author: { - username: 'author', - displayName: 'Original Author', - avatarUrl: null, - }, - content: '', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - }, - }, - totalCount: 1, - subjectIds: ['original-tweet1'], - }, - }); - - const { getByText } = render( - - ); - - expect(getByText(/Original Author/)).toBeTruthy(); + expect(getByText('User One')).toBeTruthy(); + expect(getByText(/and 2 others/)).toBeTruthy(); }); - it('handles case with no quoted tweet', () => { + it('handles hashtag press in quote tweet content', () => { const notification = createNotification({ tweetSummary: { primaryTweet: { ...createNotification().tweetSummary.primaryTweet, - quotedTweet: null, + content: 'Commentary with #hashtag', }, totalCount: 1, subjectIds: ['original-tweet1'], @@ -195,197 +174,39 @@ describe('TweetQuoteNotificationItem', () => { ); - expect(getByText(/User One/)).toBeTruthy(); - }); + fireEvent.press(getByText('#hashtag')); - it('renders quote notification without quote tweet content', () => { - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - ...createNotification().tweetSummary.primaryTweet, - content: '', - }, - totalCount: 1, - subjectIds: ['original-tweet1'], - }, + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, { + initialQuery: '#hashtag', + initialTab: 'top', }); - - const { getByText } = render( - - ); - - expect(getByText(/Original Author/)).toBeTruthy(); }); - it('handles actor press correctly', () => { + it('navigates to user profile when actor is pressed', () => { const notification = createNotification(); - render(); - expect(mockRootNavigate).toBeDefined(); - }); - - it('handles case with no primary actor gracefully', () => { - const notification = createNotification({ - actorSummary: { - previewActors: [], - totalCount: 0, - }, - }); - const { getByText } = render( ); - expect(getByText(/quoted your tweet/)).toBeTruthy(); + fireEvent.press(getByText('User One')); + expect(mockOnPress).toHaveBeenCalled(); }); - it('renders quote notification with media in quote tweet', () => { - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - id: 'quoted-tweet1', - author: { - username: 'quoter', - displayName: 'Quoter', - avatarUrl: null, - }, - content: 'Quote with image', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: [ - { - type: 'IMAGE', - url: 'https://example.com/image.jpg', - altText: 'Quote image', - width: 100, - height: 100, - }, - ], - replyToTweetId: null, - quoteToTweetId: 'original-tweet1', - quotedTweet: { - id: 'original-tweet1', - author: { - username: 'author', - displayName: 'Original Author', - avatarUrl: null, - }, - content: 'Original tweet', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - }, - }, - totalCount: 1, - subjectIds: ['original-tweet1'], - }, - }); - - const { getByText } = render( - - ); - - expect(getByText(/Quote with image/)).toBeTruthy(); - }); - - it('renders multiple quote tweets', () => { - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - id: 'quoted-tweet1', - author: { - username: 'quoter', - displayName: 'Quoter', - avatarUrl: null, - }, - content: 'Quote tweet', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, - }, - totalCount: 2, - subjectIds: ['original-tweet1', 'original-tweet2'], - }, - }); - - const { getByText } = render( - - ); - - expect(getByText(/Quote tweet/)).toBeTruthy(); - }); - - it('handles missing quoted tweet gracefully', () => { - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - id: 'quoted-tweet1', - author: { - username: 'quoter', - displayName: 'Quoter', - avatarUrl: null, - }, - content: 'Quote tweet', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 0, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, - }, - totalCount: 1, - subjectIds: ['original-tweet1'], - }, - }); - - const { getByText } = render( + it('handles reply press', () => { + const notification = createNotification(); + const { getByTestId } = render( ); - expect(getByText(/Quote tweet/)).toBeTruthy(); - }); + const replyButton = getByTestId('reply-button'); + fireEvent.press(replyButton); - it('handles mention press in quote content', () => { - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - ...createNotification().tweetSummary.primaryTweet, - content: 'Quote with @mention in it', - }, - totalCount: 1, - subjectIds: ['original-tweet1'], + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.TWEET, { + screen: TWEET.DETAIL, + params: { + tweetId: 'quoted-tweet1', + initialOpenComposer: true, }, }); - - render(); - }); - - it('handles notification press correctly', () => { - const notification = createNotification(); - render(); - - expect(mockOnPress).toBeDefined(); }); }); diff --git a/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx index 6b60d470e..784044429 100644 --- a/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx +++ b/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx @@ -1,14 +1,15 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-require-imports */ -import React from 'react'; - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render } from '@testing-library/react-native'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import { fireEvent, render } from '@testing-library/react-native'; import { TweetReplyNotificationItem } from '@/components/notifications/types/TweetReplyNotificationItem'; -import { ThemeProvider } from '@/hooks/useTheme'; +import { useRootNavigation } from '@/hooks/navigation/useRootNavigation'; import { NotificationType, TweetNotification } from '@/types/notifications'; +import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames'; + +jest.mock('@/hooks/useTheme', () => ({ + useTheme: () => ({ theme: 'light' }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})); jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(() => ({ @@ -26,54 +27,25 @@ jest.mock('lucide-react-native', () => ({ CornerUpLeft: () => null, })); -jest.mock('react-native-highlighter', () => { - const mockReact = require('react'); - const mockRN = require('react-native'); - const HighlightedText = (props: any) => { - return mockReact.createElement(mockRN.Text, { - ...props, - children: props.children, - }); - }; - HighlightedText.displayName = 'HighlightedText'; - return HighlightedText; -}); - -jest.mock('@/components/ui/ActorAvatarGroup', () => { - return () => null; -}); - -jest.mock('@/components/ui/TweetMediaGrid', () => { - return () => null; -}); - +jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup'); +jest.mock('@/components/ui/TweetMediaGrid', () => 'TweetMediaGrid'); jest.mock('@/components/notifications/types/TweetActionBar', () => ({ - TweetActionBar: () => null, + TweetActionBar: 'TweetActionBar', })); -const mockQueryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}); +describe('TweetReplyNotificationItem', () => { + const mockOnPress = jest.fn((callback) => callback()); + const mockNavigate = jest.fn(); + const mockRootNavigate = jest.fn(); -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate }); + }); -describe('TweetReplyNotificationItem', () => { - const mockNotification: TweetNotification = { - id: 'notif-1', + const createNotification = (overrides?: Partial): TweetNotification => ({ + id: '1', type: NotificationType.REPLY, isSeen: false, latestEventAt: '2024-01-01T00:00:00Z', @@ -82,7 +54,7 @@ describe('TweetReplyNotificationItem', () => { { username: 'replier1', displayName: 'Replier One', - avatarUrl: '', + avatarUrl: 'https://example.com/avatar.jpg', isFollowing: false, }, ], @@ -90,11 +62,18 @@ describe('TweetReplyNotificationItem', () => { }, tweetSummary: { primaryTweet: { - id: 'tweet-1', + id: 'reply-tweet1', author: { username: 'author1', displayName: 'Author One', - avatarUrl: '', + avatarUrl: null, + relationship: { + following: false, + follower: false, + muted: false, + blocking: false, + blockedBy: false, + }, }, content: 'This is a reply tweet', createdAt: '2024-01-01T00:00:00Z', @@ -110,30 +89,29 @@ describe('TweetReplyNotificationItem', () => { quotedTweet: null, }, totalCount: 1, - subjectIds: ['tweet-1'], + subjectIds: ['reply-tweet1'], }, - }; - - const mockOnPress = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); + ...overrides, }); - it('should render single reply notification', () => { + it('renders correctly and handles main press', () => { + const notification = createNotification(); const { getByText } = render( - - - + ); - expect(getByText(/Replier One replied to your tweet/)).toBeTruthy(); + expect(getByText('Replier One')).toBeTruthy(); + expect(getByText(/replied to your tweet/)).toBeTruthy(); expect(getByText('This is a reply tweet')).toBeTruthy(); + + fireEvent.press(getByText('This is a reply tweet')); + + expect(mockOnPress).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'reply-tweet1' }); }); - it('should render multiple replies notification', () => { - const multipleRepliesNotification: TweetNotification = { - ...mockNotification, + it('renders multiple replies notification', () => { + const notification = createNotification({ actorSummary: { previewActors: [ { @@ -151,152 +129,145 @@ describe('TweetReplyNotificationItem', () => { ], totalCount: 3, }, - }; - - const { getByText } = render( - - - - ); - - expect(getByText(/Replier One/)).toBeTruthy(); - expect(getByText(/2 others/)).toBeTruthy(); - expect(getByText(/replied to your tweet/)).toBeTruthy(); - }); + }); - it('should display tweet content', () => { const { getByText } = render( - - - + ); - expect(getByText('This is a reply tweet')).toBeTruthy(); + expect(getByText('Replier One')).toBeTruthy(); + expect(getByText(/and 2 others/)).toBeTruthy(); }); - it('should render notification with media', () => { - const notificationWithMedia: TweetNotification = { - ...mockNotification, + it('handles hashtag press in tweet content', () => { + const notification = createNotification({ tweetSummary: { primaryTweet: { - ...mockNotification.tweetSummary.primaryTweet, - media: [ - { - type: 'IMAGE', - url: 'https://example.com/image.jpg', - altText: 'Test image', - width: 100, - height: 100, - }, - ], + ...createNotification().tweetSummary.primaryTweet, + content: 'Reply with #hashtag', }, totalCount: 1, - subjectIds: ['tweet-1'], + subjectIds: ['reply-tweet1'], }, - }; + }); const { getByText } = render( - - - + ); - expect(getByText('This is a reply tweet')).toBeTruthy(); + fireEvent.press(getByText('#hashtag')); + + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, { + initialQuery: '#hashtag', + initialTab: 'top', + }); }); - it('should handle mention press in tweet content', () => { - const notificationWithMention: TweetNotification = { - ...mockNotification, - tweetSummary: { - primaryTweet: { - ...mockNotification.tweetSummary.primaryTweet, - content: 'Reply with @mention', - }, - totalCount: 1, - subjectIds: ['tweet-1'], - }, - }; + it('navigates to user profile when actor avatar is pressed', () => { + const notification = createNotification(); + const { getByTestId } = render( + + ); + + // Press the avatar pressable wrapper which triggers handleActorPress + const avatarPressable = getByTestId('actor-avatar-pressable'); + fireEvent.press(avatarPressable); - render( - - - + expect(mockOnPress).toHaveBeenCalled(); + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'replier1' }, + }); + }); + + it('renders without content when tweet content is empty', () => { + const notification = createNotification(); + notification.tweetSummary.primaryTweet.content = ''; + + const { getByText } = render( + ); + + expect(getByText(/replied to your tweet/)).toBeTruthy(); }); - it('should handle hashtag press in tweet content', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + it('renders seen styling', () => { + const notification = createNotification(); + notification.isSeen = true; + render(); + }); - const notificationWithHashtag: TweetNotification = { - ...mockNotification, + it('handles mention press in tweet content', () => { + const notification = createNotification({ tweetSummary: { primaryTweet: { - ...mockNotification.tweetSummary.primaryTweet, - content: 'Reply with #hashtag', + ...createNotification().tweetSummary.primaryTweet, + content: 'Reply to @mentioneduser', }, totalCount: 1, - subjectIds: ['tweet-1'], + subjectIds: ['reply-tweet1'], }, - }; + }); - render( - - - + const { getByText } = render( + ); - consoleSpy.mockRestore(); + const mention = getByText('@mentioneduser'); + fireEvent.press(mention); + + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'mentioneduser' }, + }); }); - it('should handle actor press correctly', () => { - render( - - - + it('renders tweet with media', () => { + const notification = createNotification({ + tweetSummary: { + primaryTweet: { + ...createNotification().tweetSummary.primaryTweet, + content: 'Reply with media', + media: [ + { + type: 'IMAGE', + url: 'https://example.com/image.jpg', + altText: 'Test image', + width: 100, + height: 100, + }, + ], + }, + totalCount: 1, + subjectIds: ['reply-tweet1'], + }, + }); + + const { getByText } = render( + ); - expect(mockOnPress).toBeDefined(); + expect(getByText('Reply with media')).toBeTruthy(); }); - it('should handle case with no primary actor gracefully', () => { - const notificationNoActor: TweetNotification = { - ...mockNotification, + it('handles empty actors array gracefully', () => { + const notification = createNotification({ actorSummary: { previewActors: [], totalCount: 0, }, - }; + }); - const { getByText } = render( - - - + const { getByText, getByTestId } = render( + ); expect(getByText(/replied to your tweet/)).toBeTruthy(); - }); - it('should render without content when tweet content is empty', () => { - const notificationEmptyContent: TweetNotification = { - ...mockNotification, - tweetSummary: { - ...mockNotification.tweetSummary, - primaryTweet: { - ...mockNotification.tweetSummary.primaryTweet, - content: '', - }, - }, - }; - - const { getByText } = render( - - - - ); + // Pressing avatar should not navigate when no primary actor + const avatarPressable = getByTestId('actor-avatar-pressable'); + fireEvent.press(avatarPressable); - expect(getByText(/Replier One replied to your tweet/)).toBeTruthy(); + expect(mockRootNavigate).not.toHaveBeenCalledWith(ROOT.PROFILE, expect.anything()); }); }); diff --git a/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx index b2972d830..16989ac9b 100644 --- a/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx +++ b/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx @@ -1,33 +1,36 @@ -import { render } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { fireEvent, render } from '@testing-library/react-native'; import { TweetRetweetNotificationItem } from '@/components/notifications/types/TweetRetweetNotificationItem'; +import { useRootNavigation } from '@/hooks/navigation/useRootNavigation'; import { NotificationType, TweetNotification } from '@/types/notifications'; +import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames'; jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'light' }), })); -const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - navigate: mockNavigate, - }), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), })); -const mockRootNavigate = jest.fn(); jest.mock('@/hooks/navigation/useRootNavigation', () => ({ - useRootNavigation: () => ({ - navigate: mockRootNavigate, - }), + useRootNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), })); -jest.mock('@/components/ui/ActorAvatarGroup', () => () => null); +jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup'); jest.mock('@/components/notifications/types/TweetActionBar', () => ({ - TweetActionBar: () => null, + TweetActionBar: 'TweetActionBar', })); describe('TweetRetweetNotificationItem', () => { - const mockOnPress = jest.fn(); + const mockOnPress = jest.fn((callback) => callback()); + const mockNavigate = jest.fn(); + const mockRootNavigate = jest.fn(); const createNotification = (overrides?: Partial): TweetNotification => ({ id: '1', @@ -52,6 +55,13 @@ describe('TweetRetweetNotificationItem', () => { username: 'author', displayName: 'Author', avatarUrl: null, + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, }, content: 'Test tweet content', createdAt: '2024-01-01T00:00:00Z', @@ -74,16 +84,24 @@ describe('TweetRetweetNotificationItem', () => { beforeEach(() => { jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate }); }); - it('renders single user retweet notification', () => { + it('renders correctly and handles main press', () => { const notification = createNotification(); const { getByText } = render( ); - expect(getByText(/User One/)).toBeTruthy(); + expect(getByText('User One')).toBeTruthy(); expect(getByText(/retweeted your tweet/)).toBeTruthy(); + expect(getByText('Test tweet content')).toBeTruthy(); + + fireEvent.press(getByText('Test tweet content')); + + expect(mockOnPress).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' }); }); it('renders multiple users retweet notification', () => { @@ -105,17 +123,8 @@ describe('TweetRetweetNotificationItem', () => { ); - expect(getByText(/User One/)).toBeTruthy(); - expect(getByText(/4 others/)).toBeTruthy(); - }); - - it('renders tweet content when available', () => { - const notification = createNotification(); - const { getByText } = render( - - ); - - expect(getByText(/Test tweet content/)).toBeTruthy(); + expect(getByText('User One')).toBeTruthy(); + expect(getByText(/and 4 others/)).toBeTruthy(); }); it('renders notification with media', () => { @@ -127,6 +136,13 @@ describe('TweetRetweetNotificationItem', () => { username: 'author', displayName: 'Author', avatarUrl: null, + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, }, content: 'Test tweet', createdAt: '2024-01-01T00:00:00Z', @@ -154,27 +170,12 @@ describe('TweetRetweetNotificationItem', () => { }, }); - const { getByText } = render( + const { getByText, getByTestId } = render( ); expect(getByText(/Test tweet/)).toBeTruthy(); - }); - - it('renders without errors', () => { - const notification = createNotification(); - const { getByText } = render( - - ); - - expect(getByText(/User One/)).toBeTruthy(); - }); - - it('handles notification press correctly', () => { - const notification = createNotification(); - render(); - - expect(mockOnPress).toBeDefined(); + expect(getByTestId('tweet-media-image')).toBeTruthy(); }); it('handles mention press in tweet content', () => { @@ -192,12 +193,27 @@ describe('TweetRetweetNotificationItem', () => { render(); }); - it('renders notification without content', () => { + it('handles case with no primary actor gracefully', () => { + const notification = createNotification({ + actorSummary: { + previewActors: [], + totalCount: 0, + }, + }); + + const { getByText } = render( + + ); + + expect(getByText(/retweeted your tweet/)).toBeTruthy(); + }); + + it('handles hashtag press in tweet content', () => { const notification = createNotification({ tweetSummary: { primaryTweet: { ...createNotification().tweetSummary.primaryTweet, - content: '', + content: 'Test tweet with #hashtag', }, totalCount: 1, subjectIds: ['tweet1'], @@ -208,22 +224,17 @@ describe('TweetRetweetNotificationItem', () => { ); - expect(getByText(/User One/)).toBeTruthy(); - }); + fireEvent.press(getByText('#hashtag')); - it('handles actor press correctly', () => { - const notification = createNotification(); - render(); - expect(mockRootNavigate).toBeDefined(); + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, { + initialQuery: '#hashtag', + initialTab: 'top', + }); }); - it('handles case with no primary actor gracefully', () => { - const notification = createNotification({ - actorSummary: { - previewActors: [], - totalCount: 0, - }, - }); + it('renders without content when tweet content is empty', () => { + const notification = createNotification(); + notification.tweetSummary.primaryTweet.content = ''; const { getByText } = render( @@ -232,102 +243,72 @@ describe('TweetRetweetNotificationItem', () => { expect(getByText(/retweeted your tweet/)).toBeTruthy(); }); - it('handles hashtag press in tweet content', () => { - const notification = createNotification({ - tweetSummary: { - primaryTweet: { - id: 'tweet1', - author: { - username: 'author', - displayName: 'Author', - avatarUrl: null, - }, - content: 'Test tweet with #hashtag', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 1, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, - }, - totalCount: 1, - subjectIds: ['tweet1'], - }, + it('navigates to user profile when actor avatar is pressed', () => { + const notification = createNotification(); + const { getByTestId } = render( + + ); + + // Press the avatar pressable wrapper which triggers handleActorPress + const avatarPressable = getByTestId('actor-avatar-pressable'); + fireEvent.press(avatarPressable); + + expect(mockOnPress).toHaveBeenCalled(); + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'user1' }, }); + }); + it('renders seen styling', () => { + const notification = createNotification(); + notification.isSeen = true; render(); - expect(mockRootNavigate).toBeDefined(); }); it('handles mention press in tweet content', () => { const notification = createNotification({ tweetSummary: { primaryTweet: { - id: 'tweet1', - author: { - username: 'author', - displayName: 'Author', - avatarUrl: null, - }, - content: 'Test tweet with @mention', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 1, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, + ...createNotification().tweetSummary.primaryTweet, + content: 'Tweet with @mentioneduser', }, totalCount: 1, subjectIds: ['tweet1'], }, }); - render(); - expect(mockRootNavigate).toBeDefined(); + const { getByText } = render( + + ); + + const mention = getByText('@mentioneduser'); + fireEvent.press(mention); + + expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, { + screen: PROFILE.USER_PROFILE, + params: { username: 'mentioneduser' }, + }); }); - it('renders without content when tweet content is empty', () => { + it('handles empty actors array gracefully', () => { const notification = createNotification({ - tweetSummary: { - primaryTweet: { - id: 'tweet1', - author: { - username: 'author', - displayName: 'Author', - avatarUrl: null, - }, - content: '', - createdAt: '2024-01-01T00:00:00Z', - replyCount: 0, - retweetCount: 1, - likeCount: 0, - isLiked: false, - isRetweeted: false, - entities: { mentions: null, hashtags: null }, - media: null, - replyToTweetId: null, - quoteToTweetId: null, - quotedTweet: null, - }, - totalCount: 1, - subjectIds: ['tweet1'], + actorSummary: { + previewActors: [], + totalCount: 0, }, }); - const { getByText } = render( + const { getByText, getByTestId } = render( ); - expect(getByText(/User One/)).toBeTruthy(); + expect(getByText(/retweeted your tweet/)).toBeTruthy(); + + // Pressing avatar should not navigate when no primary actor + const avatarPressable = getByTestId('actor-avatar-pressable'); + fireEvent.press(avatarPressable); + + expect(mockRootNavigate).not.toHaveBeenCalledWith(ROOT.PROFILE, expect.anything()); }); }); diff --git a/src/__tests__/components/profile/Banner.test.tsx b/src/__tests__/components/profile/Banner.test.tsx index 931068ea3..8443f3582 100644 --- a/src/__tests__/components/profile/Banner.test.tsx +++ b/src/__tests__/components/profile/Banner.test.tsx @@ -1,10 +1,11 @@ -import { ImageBackground, Share, View } from 'react-native'; +import { Alert, ImageBackground, Share, View } from 'react-native'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, waitFor } from '@testing-library/react-native'; import ProfileBanner from '@/components/profile/Banner'; import { mockUsers } from '@/mocks/data/userMock'; +import * as userStore from '@/stores/userStore'; const mockUsername = 'johndoe'; @@ -23,6 +24,10 @@ jest.mock('@/navigation/navigationRef', () => ({ }, })); +jest.mock('@/hooks/profile/useBlockMutation'); +jest.mock('@/hooks/profile/useMuteMutation'); +jest.mock('@/stores/userStore'); + const mockQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -38,11 +43,16 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => mockNavigation, })); -describe('ProfileBanner', () => { +describe('ProfileBanner Component', () => { beforeEach(() => { jest.clearAllMocks(); jest.spyOn(Share, 'share').mockResolvedValue({ action: Share.sharedAction }); jest.spyOn(console, 'error').mockImplementation(() => {}); + + (userStore.useUserStore as unknown as jest.Mock).mockImplementation( + (selector: (state: { user: { username: string } }) => unknown) => + selector({ user: { username: 'different-user' } }) + ); }); afterEach(() => { @@ -227,7 +237,7 @@ describe('ProfileBanner', () => { it('shares profile with all fallbacks when both are undefined', async () => { const { getByTestId } = render( - + ); @@ -345,3 +355,410 @@ describe('ProfileBanner', () => { expect(searchButton).toBeTruthy(); }); }); + +describe('Block and Mute functionality', () => { + const mockBlockMutate = jest.fn(); + const mockMuteMutate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + const mockUseBlockMutation = jest.requireMock('@/hooks/profile/useBlockMutation'); + const mockUseMuteMutation = jest.requireMock('@/hooks/profile/useMuteMutation'); + + mockUseBlockMutation.useBlockMutation.mockReturnValue({ + mutate: mockBlockMutate, + isPending: false, + }); + + mockUseMuteMutation.useMuteMutation.mockReturnValue({ + mutate: mockMuteMutate, + isPending: false, + }); + + (userStore.useUserStore as unknown as jest.Mock).mockImplementation( + (selector: (state: { user: { username: string } }) => unknown) => + selector({ user: { username: 'different-user' } }) + ); + }); + + it('shows block modal when block button is pressed', async () => { + const otherUserProfile = { + ...mockUsers[mockUsername], + username: 'otheruser', + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const { getByTestId, getByText } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + + const blockButton = getByText('Block'); + fireEvent.press(blockButton); + + await waitFor(() => { + expect(getByTestId('backdrop-area')).toBeTruthy(); + }); + }); + + it('blocks a user when confirm is pressed in block modal', async () => { + const otherUserProfile = { + ...mockUsers[mockUsername], + username: 'otheruser', + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const { getByTestId, getByText } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + + const blockButton = getByText('Block'); + fireEvent.press(blockButton); + + await waitFor(() => { + expect(getByTestId('backdrop-area')).toBeTruthy(); + }); + + const confirmButton = getByTestId('dialog-confirm-button'); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockBlockMutate).toHaveBeenCalledWith( + { + username: 'otheruser', + block: true, + previous: false, + }, + expect.any(Object) + ); + }); + }); + + it('mutes a user when mute button is pressed', async () => { + const otherUserProfile = { + ...mockUsers[mockUsername], + username: 'otheruser', + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const { getByTestId, getByText } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + + const muteButton = getByText('Mute'); + fireEvent.press(muteButton); + + await waitFor(() => { + expect(getByTestId('backdrop-area')).toBeTruthy(); + }); + + const confirmButton = getByTestId('dialog-confirm-button'); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockMuteMutate).toHaveBeenCalledWith( + { + username: 'otheruser', + mute: true, + previous: false, + }, + expect.any(Object) + ); + }); + }); + + it('shows mute and block actions for other user profile', async () => { + const otherUserProfile = { + ...mockUsers[mockUsername], + username: 'otheruser', + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const { getByTestId, getByText } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + + await waitFor(() => { + expect(getByText('Share')).toBeTruthy(); + expect(getByText('Mute')).toBeTruthy(); + expect(getByText('Block')).toBeTruthy(); + }); + }); + + it('shows only share action for your own profile', async () => { + (userStore.useUserStore as unknown as jest.Mock).mockImplementation( + (selector: (state: { user: { username: string } }) => unknown) => + selector({ user: { username: 'johndoe' } }) + ); + + const { getByTestId, getByText, queryByText } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + + await waitFor(() => { + expect(getByText('Share')).toBeTruthy(); + expect(queryByText('Mute')).toBeNull(); + expect(queryByText('Block')).toBeNull(); + }); + }); + + it('does not show mute option for blocked user', async () => { + const blockedUserProfile = { + ...mockUsers[mockUsername], + username: 'blockeduser', + relationship: { + blocking: true, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const { getByTestId, getByText, queryByText } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + + await waitFor(() => { + expect(getByText('Share')).toBeTruthy(); + expect(queryByText('Mute')).toBeNull(); + expect(getByText('Unblock')).toBeTruthy(); + }); + }); + + it('shows unblock option for blocked user', async () => { + const blockedUserProfile = { + ...mockUsers[mockUsername], + username: 'blockeduser', + relationship: { + blocking: true, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const { getByTestId, getByText } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + + await waitFor(() => { + expect(getByText('Unblock')).toBeTruthy(); + }); + + const unblockButton = getByText('Unblock'); + fireEvent.press(unblockButton); + + await waitFor(() => { + expect(getByTestId('backdrop-area')).toBeTruthy(); + }); + + const confirmButton = getByTestId('dialog-confirm-button'); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockBlockMutate).toHaveBeenCalledWith( + { + username: 'blockeduser', + block: false, + previous: true, + }, + expect.any(Object) + ); + }); + }); + + it('closes block modal before showing alert on block mutation error', async () => { + const mockUseBlockMutation = jest.requireMock('@/hooks/profile/useBlockMutation'); + + const otherUserProfile = { + ...mockUsers[mockUsername], + username: 'otheruser', + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const alertSpy = jest.spyOn(Alert, 'alert'); + + mockUseBlockMutation.useBlockMutation.mockReturnValue({ + mutate: jest.fn((variables, options) => { + options?.onError({ message: 'Network error' }); + }), + isPending: false, + }); + + const { getByTestId, getByText, queryByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + fireEvent.press(getByText('Block')); + + await waitFor(() => { + expect(getByTestId('backdrop-area')).toBeTruthy(); + }); + + const confirmButton = getByTestId('dialog-confirm-button'); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(queryByTestId('backdrop-area')).toBeNull(); + expect(alertSpy).toHaveBeenCalledWith('Unable to unblock account', 'Network error'); + }); + + alertSpy.mockRestore(); + }); + + it('closes mute modal before showing alert on mute mutation error', async () => { + const mockUseMuteMutation = jest.requireMock('@/hooks/profile/useMuteMutation'); + + const otherUserProfile = { + ...mockUsers[mockUsername], + username: 'otheruser', + relationship: { + blocking: false, + blockedBy: false, + muted: false, + following: false, + follower: false, + }, + }; + + const alertSpy = jest.spyOn(Alert, 'alert'); + + mockUseMuteMutation.useMuteMutation.mockReturnValue({ + mutate: jest.fn((variables, options) => { + options?.onError({ message: 'Unable to reach server' }); + }), + isPending: false, + }); + + const { getByTestId, getByText, queryByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('menu-dropdown-button')); + fireEvent.press(getByText('Mute')); + + await waitFor(() => { + expect(getByTestId('backdrop-area')).toBeTruthy(); + }); + + const confirmButton = getByTestId('dialog-confirm-button'); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(queryByTestId('backdrop-area')).toBeNull(); + expect(alertSpy).toHaveBeenCalledWith('Unable to mute account', 'Unable to reach server'); + }); + + alertSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/components/profile/FollowButton.test.tsx b/src/__tests__/components/profile/FollowButton.test.tsx index c7101e126..2ea082521 100644 --- a/src/__tests__/components/profile/FollowButton.test.tsx +++ b/src/__tests__/components/profile/FollowButton.test.tsx @@ -282,7 +282,7 @@ describe('FollowButton', () => { }); it('handles null isFollowing prop as false', () => { - const { getByText } = render(); + const { getByText } = render(); expect(getByText('Follow')).toBeTruthy(); }); @@ -301,7 +301,7 @@ describe('FollowButton', () => { it('handles followsYou being null', () => { const { getByText } = render( - + ); expect(getByText('Follow')).toBeTruthy(); diff --git a/src/__tests__/components/profile/MuteButton.test.tsx b/src/__tests__/components/profile/MuteButton.test.tsx new file mode 100644 index 000000000..9e63b7f3d --- /dev/null +++ b/src/__tests__/components/profile/MuteButton.test.tsx @@ -0,0 +1,178 @@ +import { Alert } from 'react-native'; + +import { fireEvent, render } from '@testing-library/react-native'; + +import MuteButton from '@/components/profile/MuteButton'; + +const mockMuteMutate = jest.fn(); +let mockMuteIsPending = false; + +jest.mock('@/hooks/profile/useMuteMutation', () => ({ + useMuteMutation: () => ({ + mutate: mockMuteMutate, + isPending: mockMuteIsPending, + }), +})); + +jest.mock('@/hooks/useTheme', () => ({ + useTheme: () => ({ theme: 'light' }), +})); + +describe('MuteButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMuteIsPending = false; + }); + + describe('Rendering Logic', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows unmuted icon when not muted and canMute is true', () => { + const { getByTestId } = render( + + ); + + const muteButton = getByTestId('mute-button-unmuted'); + expect(muteButton).toBeTruthy(); + }); + + it('shows muted icon when isMuted is true', () => { + const { getByTestId } = render(); + + const unmuteButton = getByTestId('mute-button-muted'); + expect(unmuteButton).toBeTruthy(); + }); + + it('does not show mute button when canMute is false and not muted', () => { + const { queryByTestId } = render( + + ); + + expect(queryByTestId('mute-button-unmuted')).toBeNull(); + expect(queryByTestId('mute-button-muted')).toBeNull(); + }); + + it('shows unmute button even when canMute is false if user is already muted', () => { + const { getByTestId } = render( + + ); + + const unmuteButton = getByTestId('mute-button-muted'); + expect(unmuteButton).toBeTruthy(); + }); + }); + + describe('Mute Functionality', () => { + it('handles mute when unmuted button is pressed', () => { + const { getByTestId } = render( + + ); + + const muteButton = getByTestId('mute-button-unmuted'); + fireEvent.press(muteButton); + + expect(mockMuteMutate).toHaveBeenCalledWith( + { + username: 'testuser', + mute: true, + previous: false, + }, + expect.any(Object) + ); + }); + + it('handles unmute when muted button is pressed', () => { + const { getByTestId } = render( + + ); + + const unmuteButton = getByTestId('mute-button-muted'); + fireEvent.press(unmuteButton); + + expect(mockMuteMutate).toHaveBeenCalledWith( + { + username: 'testuser', + mute: false, + previous: true, + }, + expect.any(Object) + ); + }); + + it('does not call mutation if isPending is true', () => { + mockMuteIsPending = true; + + const { getByTestId } = render( + + ); + + const muteButton = getByTestId('mute-button-unmuted'); + fireEvent.press(muteButton); + + expect(mockMuteMutate).not.toHaveBeenCalled(); + }); + + it('does not call mutation if previous state equals new state', () => { + const { getByTestId } = render( + + ); + + const unmuteButton = getByTestId('mute-button-muted'); + fireEvent.press(unmuteButton); + + expect(mockMuteMutate).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('shows alert on mute error', () => { + const spyAlert = jest.spyOn(Alert, 'alert'); + + mockMuteMutate.mockImplementation( + ( + args: { username: string; mute: boolean; previous: boolean }, + options: { onError?: (error: { message?: string }) => void } + ) => { + options?.onError?.({ message: 'Network error' }); + } + ); + + const { getByTestId } = render( + + ); + + const muteButton = getByTestId('mute-button-unmuted'); + fireEvent.press(muteButton); + + expect(spyAlert).toHaveBeenCalledWith('Unable to mute account', 'Network error'); + + spyAlert.mockRestore(); + }); + + it('shows default error message when error message is missing', () => { + const spyAlert = jest.spyOn(Alert, 'alert'); + + mockMuteMutate.mockImplementation( + ( + args: { username: string; mute: boolean; previous: boolean }, + options: { onError?: (error: Record) => void } + ) => { + options?.onError?.({}); + } + ); + + const { getByTestId } = render( + + ); + + const muteButton = getByTestId('mute-button-unmuted'); + fireEvent.press(muteButton); + + expect(spyAlert).toHaveBeenCalledWith('Unable to mute account', 'Please try again.'); + + spyAlert.mockRestore(); + }); + }); +}); diff --git a/src/__tests__/components/profile/ProfileHeader.test.tsx b/src/__tests__/components/profile/ProfileHeader.test.tsx index 6fcb61548..9dd7bbfa1 100644 --- a/src/__tests__/components/profile/ProfileHeader.test.tsx +++ b/src/__tests__/components/profile/ProfileHeader.test.tsx @@ -16,16 +16,28 @@ import { mockUsers } from '@/mocks/data/userMock'; import { createOrGetConversationByUsername, dmKeys } from '@/services/dm'; import { useUserStore } from '@/stores/userStore'; import { Conversation } from '@/types/dm'; -import { PROFILE, ROOT } from '@/utils/navigation/routeNames'; +import { PROFILE, PROFILE_SETUP, ROOT } from '@/utils/navigation/routeNames'; const mockNavigate = jest.fn(); const mockPush = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - navigate: mockNavigate, - push: mockPush, - }), -})); +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + createNavigationContainerRef: () => ({ + isReady: jest.fn(() => false), + getCurrentRoute: jest.fn(() => null), + navigate: jest.fn(), + dispatch: jest.fn(), + goBack: jest.fn(), + current: null, + }), + useNavigation: () => ({ + navigate: mockNavigate, + push: mockPush, + }), + }; +}); jest.mock('@/stores/userStore'); jest.mock('@/hooks/profile/useBlockMutation'); @@ -61,7 +73,7 @@ jest.mock('@/components/profile/ProfileInfo', () => { ); }); -// Mock Stats to allow testing the click handlers passed to it + jest.mock('@/components/profile/Statistics', () => { const { View, Button } = require('react-native'); return (props: any) => ( @@ -78,14 +90,14 @@ jest.mock('@/components/ui', () => ({ })); jest.mock('@/components/profile/BlockModal', () => { - // We simulate the modal being visible and having a confirm button - const { View, Button } = require('react-native'); - return (props: any) => - props.modalVisible ? ( - -