From b7dd89f177d267c97241f293bfd95a9c4836353f Mon Sep 17 00:00:00 2001 From: Emaad Date: Sat, 7 Mar 2026 15:48:26 +0530 Subject: [PATCH 1/3] feat: enhance test coverage and add new utilities - Add comprehensive test suites for useDebounce hook and queryParser utility - Expand MovieDetailsScreen test coverage significantly - Update movieSearch e2e tests with additional scenarios - Minor updates to SearchScreen and App components - Update Detox configuration --- .detoxrc.js | 4 +- App.tsx | 1 + e2e/movieSearch.e2e.ts | 73 ++++- src/hooks/__tests__/useDebounce.test.ts | 112 ++++++++ src/screens/SearchScreen.tsx | 1 + .../__tests__/MovieDetailsScreen.test.tsx | 258 +++++++++++++++--- src/utils/__tests__/queryParser.test.ts | 215 +++++++++++++++ 7 files changed, 617 insertions(+), 47 deletions(-) create mode 100644 src/hooks/__tests__/useDebounce.test.ts create mode 100644 src/utils/__tests__/queryParser.test.ts diff --git a/.detoxrc.js b/.detoxrc.js index 44afcad..0f39dca 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -35,7 +35,7 @@ module.exports = { simulator: { type: 'ios.simulator', device: { - type: 'iPhone 15 Pro', + type: 'iPhone 16', }, }, attached: { @@ -47,7 +47,7 @@ module.exports = { emulator: { type: 'android.emulator', device: { - avdName: 'Pixel_7_Pro_API_34', + avdName: 'Medium_Phone', }, }, }, diff --git a/App.tsx b/App.tsx index 5fb21de..63dce63 100644 --- a/App.tsx +++ b/App.tsx @@ -65,6 +65,7 @@ const CustomHeader = ({ title, onGoBack }: { title: string; onGoBack?: () => voi }}> {onGoBack && ( { }); it('should allow typing in search input', async () => { - const searchInput = element(by.text('Search movies...')); + const searchInput = element(by.id('search-input')); await detoxExpect(searchInput).toBeVisible(); await searchInput.tap(); @@ -27,7 +27,7 @@ describe('Movie Search E2E', () => { }); it('should display clear button when search has text', async () => { - const searchInput = element(by.text('Search movies...')); + const searchInput = element(by.id('search-input')); await searchInput.tap(); await searchInput.typeText('Matrix'); @@ -38,7 +38,7 @@ describe('Movie Search E2E', () => { }); it('should clear search when clear button is pressed', async () => { - const searchInput = element(by.text('Search movies...')); + const searchInput = element(by.id('search-input')); await searchInput.tap(); await searchInput.typeText('Matrix'); @@ -85,12 +85,13 @@ describe('Movie Search E2E', () => { .toBeVisible() .withTimeout(5000); - // Scroll down + // Scroll down — removeClippedSubviews removes off-screen items from the native + // hierarchy, so we verify the first currently-visible card after scrolling const movieList = element(by.id('movie-list')); - await movieList.scroll(500, 'down'); + await movieList.scroll(1200, 'down'); - // Should load more movies (pagination) - await waitFor(element(by.id('movie-card')).atIndex(10)) + // Cards should still be visible after scrolling (list survives scroll + pagination) + await waitFor(element(by.id('movie-card')).atIndex(0)) .toBeVisible() .withTimeout(5000); }); @@ -112,6 +113,62 @@ describe('Movie Search E2E', () => { }); }); +describe('Smart Search (Discover Mode) E2E', () => { + beforeAll(async () => { + await device.launchApp(); + }); + + beforeEach(async () => { + await device.reloadReactNative(); + }); + + it('should show Search Results header when a genre keyword is typed', async () => { + const searchInput = element(by.id('search-input')); + await searchInput.tap(); + await searchInput.typeText('action'); + + await waitFor(element(by.text('Search Results'))) + .toBeVisible() + .withTimeout(3000); + }); + + it('should load movie cards for a genre + year query', async () => { + const searchInput = element(by.id('search-input')); + await searchInput.tap(); + await searchInput.typeText('action 2020'); + + // Header flips to Search Results after debounce + await waitFor(element(by.text('Search Results'))) + .toBeVisible() + .withTimeout(3000); + + // Discover API returns real results + await waitFor(element(by.id('movie-card')).atIndex(0)) + .toBeVisible() + .withTimeout(8000); + }); + + it('should return to Popular Movies after clearing a smart search', async () => { + const searchInput = element(by.id('search-input')); + await searchInput.tap(); + await searchInput.typeText('horror'); + + await waitFor(element(by.text('Search Results'))) + .toBeVisible() + .withTimeout(3000); + + await waitFor(element(by.text('✕'))) + .toBeVisible() + .withTimeout(1000); + + await element(by.text('✕')).tap(); + + await waitFor(element(by.text('Popular Movies'))) + .toBeVisible() + .withTimeout(3000); + }); +}); + describe('Movie Details E2E', () => { beforeAll(async () => { await device.launchApp(); @@ -155,7 +212,7 @@ describe('Movie Details E2E', () => { it('should navigate back to search screen', async () => { // Go back (platform-specific) if (device.getPlatform() === 'ios') { - await element(by.traits(['button']).and(by.label('Back'))).tap(); + await element(by.id('back-button')).tap(); } else { await device.pressBack(); } diff --git a/src/hooks/__tests__/useDebounce.test.ts b/src/hooks/__tests__/useDebounce.test.ts new file mode 100644 index 0000000..bc71320 --- /dev/null +++ b/src/hooks/__tests__/useDebounce.test.ts @@ -0,0 +1,112 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useDebounce } from '../useDebounce'; + +describe('useDebounce', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the initial value immediately without waiting', () => { + const { result } = renderHook(() => useDebounce('hello', 500)); + expect(result.current).toBe('hello'); + }); + + it('does not update the value before the delay has elapsed', () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => useDebounce(value, 500), + { initialProps: { value: 'initial' } } + ); + + rerender({ value: 'updated' }); + jest.advanceTimersByTime(499); + + expect(result.current).toBe('initial'); + }); + + it('updates the value after the full delay has elapsed', () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => useDebounce(value, 500), + { initialProps: { value: 'initial' } } + ); + + rerender({ value: 'updated' }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe('updated'); + }); + + it('resets the timer when the value changes before the delay fires', () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => useDebounce(value, 500), + { initialProps: { value: 'initial' } } + ); + + // First change at t=0 + rerender({ value: 'first' }); + jest.advanceTimersByTime(300); // t=300 — timer not yet fired + + // Second change at t=300, resets the 500ms clock + rerender({ value: 'second' }); + jest.advanceTimersByTime(300); // t=600, but only 300ms since last change + + expect(result.current).toBe('initial'); // still no update + + act(() => { + jest.advanceTimersByTime(200); // t=800, 500ms since 'second' + }); + + expect(result.current).toBe('second'); + // 'first' was never emitted + }); + + it('respects a custom delay value', () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => useDebounce(value, 1000), + { initialProps: { value: 'initial' } } + ); + + rerender({ value: 'updated' }); + + act(() => { jest.advanceTimersByTime(999); }); + expect(result.current).toBe('initial'); + + act(() => { jest.advanceTimersByTime(1); }); + expect(result.current).toBe('updated'); + }); + + it('works with number values', () => { + const { result, rerender } = renderHook( + ({ value }: { value: number }) => useDebounce(value, 300), + { initialProps: { value: 0 } } + ); + + rerender({ value: 42 }); + + act(() => { jest.advanceTimersByTime(300); }); + + expect(result.current).toBe(42); + }); + + it('works with object values', () => { + const initial = { page: 1 }; + const updated = { page: 2 }; + + const { result, rerender } = renderHook( + ({ value }: { value: typeof initial }) => useDebounce(value, 300), + { initialProps: { value: initial } } + ); + + rerender({ value: updated }); + + act(() => { jest.advanceTimersByTime(300); }); + + expect(result.current).toEqual({ page: 2 }); + }); +}); diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 6b75f19..4970c66 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -181,6 +181,7 @@ const SearchScreen: React.FC = ({ navigation }) => { > 🔍 ({ ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), + useNavigation: () => ({ navigate: jest.fn() }), })); +// Mock the query hook so we can control loading/error/data states +const mockRefetch = jest.fn(); +jest.mock('../../services/tmdb.api', () => { + const actual = jest.requireActual('../../services/tmdb.api'); + return { + ...actual, + useGetMovieDetailsQuery: jest.fn(), + }; +}); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { useGetMovieDetailsQuery } = require('../../services/tmdb.api'); + +const MOCK_MOVIE = { + id: 1, + title: 'The Matrix', + tagline: 'Welcome to the Real World', + overview: 'A computer hacker learns about the true nature of reality.', + poster_path: '/test-poster.jpg', + backdrop_path: '/test-backdrop.jpg', + release_date: '1999-03-31', + vote_average: 8.7, + vote_count: 15000, + popularity: 100.0, + runtime: 136, + status: 'Released', + budget: 63000000, + revenue: 463517383, + original_language: 'en', + genres: [ + { id: 28, name: 'Action' }, + { id: 878, name: 'Science Fiction' }, + ], + production_companies: [{ id: 79, name: 'Village Roadshow Pictures' }], +}; + describe('MovieDetailsScreen', () => { - let store: any; + let store: ReturnType; beforeEach(() => { jest.clearAllMocks(); - store = configureStore({ reducer: { movie: movieReducer, @@ -33,47 +64,200 @@ describe('MovieDetailsScreen', () => { afterEach(() => { cleanup(); - jest.clearAllTimers(); - // Clear RTK Query cache to prevent async operations after test completion store.dispatch(tmdbApi.util.resetApiState()); }); - const renderWithProviders = (component: React.ReactElement) => { - return render({component}); - }; + const render$ = (component: React.ReactElement) => + render({component}); - it('should render loading state initially', () => { - const { getByText } = renderWithProviders( - - ); + const routeProps = { navigation: {} as any, route: { params: { movieId: 1 } } as any }; + // ── Loading ──────────────────────────────────────────────────────────────── + + it('shows loading indicator while fetching', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: undefined, + isLoading: true, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); expect(getByText('Loading movie details...')).toBeTruthy(); }); - it('should render component structure correctly', () => { - const { UNSAFE_root } = renderWithProviders( - - ); + // ── Error ────────────────────────────────────────────────────────────────── + + it('shows error view when the fetch fails', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: { status: 500 }, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('Failed to load movie details. Please try again.')).toBeTruthy(); + }); + + it('calls refetch when Retry is pressed', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: { status: 500 }, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + fireEvent.press(getByText('Retry')); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + // ── Loaded — title & tagline ─────────────────────────────────────────────── + + it('displays movie title and tagline', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: MOCK_MOVIE, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('The Matrix')).toBeTruthy(); + expect(getByText('"Welcome to the Real World"')).toBeTruthy(); + }); + + // ── Loaded — meta row ───────────────────────────────────────────────────── + + it('displays rating, release year, and formatted duration', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: MOCK_MOVIE, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('8.7')).toBeTruthy(); + expect(getByText('1999')).toBeTruthy(); + expect(getByText('2h 16m')).toBeTruthy(); // 136 min = 2h 16m + }); + + it('shows N/A duration when runtime is null', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: { ...MOCK_MOVIE, runtime: null }, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('N/A')).toBeTruthy(); + }); + + // ── Loaded — genres ─────────────────────────────────────────────────────── + + it('renders genre chips for each genre', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: MOCK_MOVIE, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('Action')).toBeTruthy(); + expect(getByText('Science Fiction')).toBeTruthy(); + }); + + // ── Loaded — overview ───────────────────────────────────────────────────── + + it('displays Overview section heading and text', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: MOCK_MOVIE, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('Overview')).toBeTruthy(); + expect( + getByText('A computer hacker learns about the true nature of reality.') + ).toBeTruthy(); + }); + + it('shows "No overview available." when overview is empty', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: { ...MOCK_MOVIE, overview: '' }, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('No overview available.')).toBeTruthy(); + }); + + // ── Loaded — additional info ─────────────────────────────────────────────── + + it('displays status, budget, revenue, language, and popularity', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: MOCK_MOVIE, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('Additional Info')).toBeTruthy(); + expect(getByText('Released')).toBeTruthy(); + expect(getByText('$63.0M')).toBeTruthy(); // budget + expect(getByText('$463.5M')).toBeTruthy(); // revenue + expect(getByText('EN')).toBeTruthy(); + }); + + it('hides Budget and Revenue rows when values are zero', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: { ...MOCK_MOVIE, budget: 0, revenue: 0 }, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); - // Verify the component renders without crashing - expect(UNSAFE_root).toBeDefined(); + const { queryByText } = render$(); + expect(queryByText('Budget')).toBeNull(); + expect(queryByText('Revenue')).toBeNull(); }); - it('should dispatch Redux action when movie title is available', () => { - // This test verifies the component structure - const { UNSAFE_root } = renderWithProviders( - - ); + // ── Loaded — production companies ───────────────────────────────────────── + + it('displays production companies', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: MOCK_MOVIE, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + const { getByText } = render$(); + expect(getByText('Production Companies')).toBeTruthy(); + expect(getByText('• Village Roadshow Pictures')).toBeTruthy(); + }); + + // ── Redux integration ───────────────────────────────────────────────────── + + it('dispatches movie title to Redux store on load', () => { + useGetMovieDetailsQuery.mockReturnValue({ + data: MOCK_MOVIE, + isLoading: false, + error: undefined, + refetch: mockRefetch, + }); + + render$(); - expect(UNSAFE_root).toBeDefined(); + expect((store.getState() as any).movie.currentMovieTitle).toBe('The Matrix'); }); }); diff --git a/src/utils/__tests__/queryParser.test.ts b/src/utils/__tests__/queryParser.test.ts new file mode 100644 index 0000000..2d7ae01 --- /dev/null +++ b/src/utils/__tests__/queryParser.test.ts @@ -0,0 +1,215 @@ +import { + parseSearchQuery, + toDiscoverParams, + getQueryDescription, +} from '../queryParser'; +import { GENRE_IDS } from '../../constants/genres'; + +describe('parseSearchQuery', () => { + it('returns useDiscover:false for empty string', () => { + expect(parseSearchQuery('')).toEqual({ useDiscover: false }); + }); + + it('returns useDiscover:false for whitespace-only input', () => { + expect(parseSearchQuery(' ')).toEqual({ useDiscover: false }); + }); + + it('treats plain text as textQuery without enabling discover', () => { + const result = parseSearchQuery('Inception'); + expect(result.textQuery).toBe('Inception'); + expect(result.useDiscover).toBe(false); + }); + + describe('year extraction', () => { + it('extracts a four-digit year', () => { + const result = parseSearchQuery('movies 2020'); + expect(result.year).toBe(2020); + expect(result.useDiscover).toBe(true); + }); + + it('extracts only the first year when multiple are present', () => { + const result = parseSearchQuery('2019 2020'); + expect(result.year).toBe(2019); + }); + + it('leaves non-year residual as textQuery', () => { + const result = parseSearchQuery('Batman 2022'); + expect(result.year).toBe(2022); + expect(result.textQuery).toBe('Batman'); + }); + }); + + describe('rating extraction', () => { + it('extracts rating from "rating>N" pattern', () => { + const result = parseSearchQuery('rating>7'); + expect(result.minRating).toBe(7); + expect(result.useDiscover).toBe(true); + }); + + it('extracts rating from "rating>=N" pattern', () => { + const result = parseSearchQuery('rating>=8'); + expect(result.minRating).toBe(8); + }); + + it('extracts rating from "N+" pattern', () => { + const result = parseSearchQuery('7+'); + expect(result.minRating).toBe(7); + expect(result.useDiscover).toBe(true); + }); + + it('extracts decimal ratings', () => { + const result = parseSearchQuery('rating>7.5'); + expect(result.minRating).toBe(7.5); + }); + }); + + describe('runtime extraction', () => { + it('extracts runtime from "longer than N" pattern', () => { + const result = parseSearchQuery('longer than 120'); + expect(result.minRuntime).toBe(120); + expect(result.useDiscover).toBe(true); + }); + + // Note: "runtime>90" is shadowed by the generic ">N" rating pattern which + // runs first and captures the ">90" portion as minRating. Use "longer than N" + // or ">Nmin" forms for unambiguous runtime queries. + it('generic ">N" falls through to rating when no "min" unit is present', () => { + const result = parseSearchQuery('runtime>90'); + // The ">90" is captured as a rating filter — minRuntime is NOT set + expect(result.minRuntime).toBeUndefined(); + expect(result.minRating).toBe(90); + }); + }); + + describe('genre extraction', () => { + it('detects action genre', () => { + const result = parseSearchQuery('action'); + expect(result.genres).toContain(String(GENRE_IDS.ACTION)); + expect(result.useDiscover).toBe(true); + }); + + it('detects comedy genre', () => { + const result = parseSearchQuery('comedy'); + expect(result.genres).toContain(String(GENRE_IDS.COMEDY)); + }); + + it('detects horror genre', () => { + const result = parseSearchQuery('horror'); + expect(result.genres).toContain(String(GENRE_IDS.HORROR)); + }); + + it('detects multiple genres in one query', () => { + const result = parseSearchQuery('action comedy'); + expect(result.genres).toContain(String(GENRE_IDS.ACTION)); + expect(result.genres).toContain(String(GENRE_IDS.COMEDY)); + }); + }); + + describe('combined queries', () => { + it('parses genre + year together', () => { + const result = parseSearchQuery('action 2020'); + expect(result.year).toBe(2020); + expect(result.genres).toContain(String(GENRE_IDS.ACTION)); + expect(result.useDiscover).toBe(true); + }); + + it('parses genre + year + rating together', () => { + const result = parseSearchQuery('action 2020 rating>7'); + expect(result.year).toBe(2020); + expect(result.minRating).toBe(7); + expect(result.genres).toContain(String(GENRE_IDS.ACTION)); + expect(result.useDiscover).toBe(true); + }); + }); +}); + +describe('toDiscoverParams', () => { + it('always sets sort_by to popularity.desc', () => { + expect(toDiscoverParams({ useDiscover: false }).sort_by).toBe('popularity.desc'); + }); + + it('maps year to primary_release_year', () => { + const params = toDiscoverParams({ useDiscover: true, year: 2020 }); + expect(params.primary_release_year).toBe(2020); + }); + + it('maps minRating and adds a minimum vote count', () => { + const params = toDiscoverParams({ useDiscover: true, minRating: 7 }); + expect(params['vote_average.gte']).toBe(7); + expect(params['vote_count.gte']).toBe(100); + }); + + it('maps maxRating to vote_average.lte', () => { + const params = toDiscoverParams({ useDiscover: true, maxRating: 9 }); + expect(params['vote_average.lte']).toBe(9); + }); + + it('maps genres array to comma-separated with_genres string', () => { + const params = toDiscoverParams({ useDiscover: true, genres: ['28', '35'] }); + expect(params.with_genres).toBe('28,35'); + }); + + it('maps minRuntime to with_runtime.gte', () => { + const params = toDiscoverParams({ useDiscover: true, minRuntime: 90 }); + expect(params['with_runtime.gte']).toBe(90); + }); + + it('maps maxRuntime to with_runtime.lte', () => { + const params = toDiscoverParams({ useDiscover: true, maxRuntime: 180 }); + expect(params['with_runtime.lte']).toBe(180); + }); + + it('omits optional fields when not present in parsed query', () => { + const params = toDiscoverParams({ useDiscover: false }); + expect(params.primary_release_year).toBeUndefined(); + expect(params['vote_average.gte']).toBeUndefined(); + expect(params.with_genres).toBeUndefined(); + expect(params['with_runtime.gte']).toBeUndefined(); + }); +}); + +describe('getQueryDescription', () => { + it('returns "All movies" for an empty parsed query', () => { + expect(getQueryDescription({ useDiscover: false })).toBe('All movies'); + }); + + it('includes textQuery surrounded by quotes', () => { + const desc = getQueryDescription({ useDiscover: false, textQuery: 'Batman' }); + expect(desc).toContain('"Batman"'); + }); + + it('includes year in description', () => { + const desc = getQueryDescription({ useDiscover: true, year: 2020 }); + expect(desc).toContain('2020'); + }); + + it('includes genre IDs in description', () => { + const desc = getQueryDescription({ + useDiscover: true, + genres: [String(GENRE_IDS.ACTION)], + }); + expect(desc).toContain(String(GENRE_IDS.ACTION)); + }); + + it('includes minRating in description', () => { + const desc = getQueryDescription({ useDiscover: true, minRating: 7 }); + expect(desc).toContain('7'); + }); + + it('includes runtime with "min" unit', () => { + const desc = getQueryDescription({ useDiscover: true, minRuntime: 90 }); + expect(desc).toContain('90 min'); + }); + + it('combines all filters in a single string', () => { + const desc = getQueryDescription({ + useDiscover: true, + textQuery: 'Batman', + year: 2022, + minRating: 7, + }); + expect(desc).toContain('"Batman"'); + expect(desc).toContain('2022'); + expect(desc).toContain('7'); + }); +}); From 200158cde8f3f57e115fc9ce5351a53ab5e5e35d Mon Sep 17 00:00:00 2001 From: Emaad Date: Sat, 7 Mar 2026 16:05:16 +0530 Subject: [PATCH 2/3] fix: update test imports and dependencies - Update package.json dependency version - Fix import paths in integration and SearchScreen tests - Minor test file adjustments --- package.json | 2 +- src/__tests__/integration/user-flow.integration.test.tsx | 6 +++--- src/screens/__tests__/SearchScreen.test.tsx | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4d99897..f1fbd5b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:detox:android": "detox test --configuration android.emu.debug", "test:maestro": "maestro test .maestro/", "test:maestro:studio": "maestro studio", - "test:all": "npm run test:jest && npm run test:maestro && npm run test:detox:ios", + "test:all": "npm run test:jest && npm run test:detox:ios", "build:e2e:ios": "detox build --configuration ios.sim.debug", "build:e2e:android": "detox build --configuration android.emu.debug", "postinstall": "patch-package && sed -i '' 's|https://boostorg.jfrog.io/artifactory/main/release/1.76.0/source/boost_1_76_0.tar.bz2|https://sourceforge.net/projects/boost/files/boost/1.76.0/boost_1_76_0.tar.bz2|g' node_modules/react-native/third-party-podspecs/boost.podspec", diff --git a/src/__tests__/integration/user-flow.integration.test.tsx b/src/__tests__/integration/user-flow.integration.test.tsx index 45c9262..67093aa 100644 --- a/src/__tests__/integration/user-flow.integration.test.tsx +++ b/src/__tests__/integration/user-flow.integration.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent, waitFor, cleanup } from '@testing-library/react-native'; +import { render, fireEvent, waitFor, cleanup, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import SearchScreen from '../../screens/SearchScreen'; @@ -112,7 +112,7 @@ describe('User Flow Integration Tests', () => { expect(searchInput.props.value).toBe('Matrix'); // Fast-forward time to trigger debounce - jest.advanceTimersByTime(500); + act(() => { jest.advanceTimersByTime(500); }); }); }); @@ -278,7 +278,7 @@ describe('User Flow Integration Tests', () => { expect(searchInput.props.value).toBe('Inception'); // Step 3: Wait for debounce - jest.advanceTimersByTime(500); + act(() => { jest.advanceTimersByTime(500); }); // Step 4: User sees results header (either Popular Movies or Search Results) // After debounce, one of these should be visible diff --git a/src/screens/__tests__/SearchScreen.test.tsx b/src/screens/__tests__/SearchScreen.test.tsx index 9f267b9..940953c 100644 --- a/src/screens/__tests__/SearchScreen.test.tsx +++ b/src/screens/__tests__/SearchScreen.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent, waitFor, cleanup } from '@testing-library/react-native'; +import { render, fireEvent, waitFor, cleanup, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import SearchScreen from '../SearchScreen'; @@ -132,7 +132,7 @@ describe('SearchScreen', () => { fireEvent.changeText(searchInput, 'Matrix'); // Fast-forward debounce timer - jest.advanceTimersByTime(500); + act(() => { jest.advanceTimersByTime(500); }); // Initially shows Popular Movies, will change after API call completes expect(queryByText('Popular Movies') || queryByText('Search Results')).toBeTruthy(); @@ -152,7 +152,7 @@ describe('SearchScreen', () => { expect(searchInput.props.value).toBe('Matrix'); // Fast-forward time to trigger debounce - jest.advanceTimersByTime(500); + act(() => { jest.advanceTimersByTime(500); }); }); it('should display empty state when no search query', () => { From c457d4a274deab0286bd27ae744f380436d76ec2 Mon Sep 17 00:00:00 2001 From: Emaad Date: Sat, 7 Mar 2026 16:25:27 +0530 Subject: [PATCH 3/3] refactor: clean up configuration and improve test structure - Remove unused API configuration properties - Update Android manifest permissions - Refactor SearchScreen and proxy API for better maintainability - Standardize test imports and structure across integration tests - Update documentation and dependencies --- README.md | 16 ++++++++-------- android/app/src/main/AndroidManifest.xml | 3 +-- e2e/movieSearch.e2e.ts | 10 +++++----- package.json | 3 +-- .../integration/user-flow.integration.test.tsx | 12 ++++++------ src/config/api.config.ts | 7 ------- src/screens/SearchScreen.tsx | 3 ++- src/screens/__tests__/SearchScreen.test.tsx | 6 +++--- tmdb-proxy/api/proxy.ts | 11 ++++------- 9 files changed, 30 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 1c4c526..7c3985d 100644 --- a/README.md +++ b/README.md @@ -579,28 +579,28 @@ See [TESTING.md](./TESTING.md) for testing-specific troubleshooting. ## Screenshots -# IOS Homepage-Simulator +### IOS Homepage-Simulator ![IOS Homepage-Simulator](image.png) -# IOS Search-Feature-Simulator +### IOS Search-Feature-Simulator ![IOS Search-Feature-Simulator](image-1.png) -# IOS Movie-Details-Simulator +### IOS Movie-Details-Simulator ![IOS Movie-Details-Simulator](image-2.png) -# IOS Movie-Details-Part-2-Simulator +### IOS Movie-Details-Part-2-Simulator ![IOS Movie-Details-Part-2-Simulator](image-3.png) -# Android Homepage-Simulator +### Android Homepage-Simulator ![Android Homepage-Simulator](image-4.png) -# Android Search-Feature-Simulator +### Android Search-Feature-Simulator ![Android Search-Feature-Simulator](image-5.png) -# Android Movie-Details-Simulator +### Android Movie-Details-Simulator ![Android Movie-Details-Simulator](image-6.png) -# Android Movie-Details-Part-2-Simulator +### Android Movie-Details-Part-2-Simulator ![Android Movie-Details-Part-2-Simulator](image-7.png) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a2d1d5a..f9fabb2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,8 +9,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" - android:networkSecurityConfig="@xml/network_security_config" - android:usesCleartextTraffic="true"> + android:networkSecurityConfig="@xml/network_security_config"> { await searchInput.typeText('Matrix'); // Wait for clear button to appear - await waitFor(element(by.text('✕'))) + await waitFor(element(by.id('clear-search-button'))) .toBeVisible() .withTimeout(1000); }); @@ -42,11 +42,11 @@ describe('Movie Search E2E', () => { await searchInput.tap(); await searchInput.typeText('Matrix'); - await waitFor(element(by.text('✕'))) + await waitFor(element(by.id('clear-search-button'))) .toBeVisible() .withTimeout(1000); - const clearButton = element(by.text('✕')); + const clearButton = element(by.id('clear-search-button')); await clearButton.tap(); // Should return to Popular Movies @@ -157,11 +157,11 @@ describe('Smart Search (Discover Mode) E2E', () => { .toBeVisible() .withTimeout(3000); - await waitFor(element(by.text('✕'))) + await waitFor(element(by.id('clear-search-button'))) .toBeVisible() .withTimeout(1000); - await element(by.text('✕')).tap(); + await element(by.id('clear-search-button')).tap(); await waitFor(element(by.text('Popular Movies'))) .toBeVisible() diff --git a/package.json b/package.json index f1fbd5b..9420a85 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,7 @@ "react": "18.2.0", "react-native": "0.72.6", "react-native-config": "^1.4.6", - "react-native-dotenv": "^3.4.11", - "react-native-fast-image": "^8.6.3", +"react-native-fast-image": "^8.6.3", "react-native-haptic-feedback": "^2.3.3", "react-native-safe-area-context": "4.8.2", "react-native-screens": "3.29.0", diff --git a/src/__tests__/integration/user-flow.integration.test.tsx b/src/__tests__/integration/user-flow.integration.test.tsx index 67093aa..5b3eec2 100644 --- a/src/__tests__/integration/user-flow.integration.test.tsx +++ b/src/__tests__/integration/user-flow.integration.test.tsx @@ -66,7 +66,7 @@ describe('User Flow Integration Tests', () => { }); it('should show clear button when text is entered', async () => { - const { getByPlaceholderText, getByText } = renderWithProviders( + const { getByPlaceholderText, getByTestId } = renderWithProviders( ); @@ -74,12 +74,12 @@ describe('User Flow Integration Tests', () => { fireEvent.changeText(searchInput, 'Matrix'); await waitFor(() => { - expect(getByText('✕')).toBeTruthy(); + expect(getByTestId('clear-search-button')).toBeTruthy(); }); }); it('should clear search when clear button is pressed', async () => { - const { getByPlaceholderText, getByText } = renderWithProviders( + const { getByPlaceholderText, getByTestId } = renderWithProviders( ); @@ -87,7 +87,7 @@ describe('User Flow Integration Tests', () => { fireEvent.changeText(searchInput, 'Matrix'); await waitFor(() => { - const clearButton = getByText('✕'); + const clearButton = getByTestId('clear-search-button'); fireEvent.press(clearButton); }); @@ -265,7 +265,7 @@ describe('User Flow Integration Tests', () => { describe('Complete User Journey', () => { it('should simulate complete search-to-view flow', async () => { - const { getByPlaceholderText, getByText } = renderWithProviders( + const { getByPlaceholderText, getByText, getByTestId } = renderWithProviders( ); @@ -301,7 +301,7 @@ describe('User Flow Integration Tests', () => { expect(hasPopularMovies() || hasSearchResults()).toBe(true); // Step 5: User can clear search - const clearButton = getByText('✕'); + const clearButton = getByTestId('clear-search-button'); fireEvent.press(clearButton); expect(searchInput.props.value).toBe(''); }); diff --git a/src/config/api.config.ts b/src/config/api.config.ts index 237c1d0..4251cad 100644 --- a/src/config/api.config.ts +++ b/src/config/api.config.ts @@ -10,11 +10,6 @@ import Config from 'react-native-config'; -// Debug: Log all config values -if (__DEV__) { - console.log('🔧 react-native-config values:', JSON.stringify(Config, null, 2)); -} - // Check if proxy should be used const useProxy = Config.USE_PROXY === 'true' && !!Config.TMDB_PROXY_URL; @@ -32,8 +27,6 @@ export const API_CONFIG = { PROXY_SECRET: Config.PROXY_SECRET || '', } as const; -// Debug: Log API config if (__DEV__) { - console.log('🔑 API_CONFIG:', JSON.stringify(API_CONFIG, null, 2)); console.log('🌐 Using proxy:', useProxy ? 'YES' : 'NO'); } diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 4970c66..a39ab5c 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -155,7 +155,7 @@ const SearchScreen: React.FC = ({ navigation }) => { accessibilityRole="text" > 🎬 - Loading.... + Search for movies Type in the search bar above ); @@ -195,6 +195,7 @@ const SearchScreen: React.FC = ({ navigation }) => { /> {searchQuery.length > 0 && ( { }); it('should clear search query when clear button is pressed', async () => { - const { getByPlaceholderText, getByText } = renderWithProviders( + const { getByPlaceholderText, getByTestId } = renderWithProviders( ); @@ -114,10 +114,10 @@ describe('SearchScreen', () => { fireEvent.changeText(searchInput, 'Matrix'); await waitFor(() => { - expect(getByText('✕')).toBeTruthy(); + expect(getByTestId('clear-search-button')).toBeTruthy(); }); - const clearButton = getByText('✕'); + const clearButton = getByTestId('clear-search-button'); fireEvent.press(clearButton); expect(searchInput.props.value).toBe(''); diff --git a/tmdb-proxy/api/proxy.ts b/tmdb-proxy/api/proxy.ts index 8767394..6001185 100644 --- a/tmdb-proxy/api/proxy.ts +++ b/tmdb-proxy/api/proxy.ts @@ -16,18 +16,15 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(200).end(); } - // Validate proxy secret to prevent unauthorized usage - if (PROXY_SECRET) { - const clientSecret = req.headers['x-proxy-secret'] || req.query.proxy_secret; - if (clientSecret !== PROXY_SECRET) { - return res.status(401).json({ error: 'Unauthorized' }); - } + // Validate proxy secret — fail closed: reject if secret not configured or mismatch + if (!PROXY_SECRET || req.headers['x-proxy-secret'] !== PROXY_SECRET) { + return res.status(401).json({ error: 'Unauthorized' }); } try { // Get the path from query parameter const { path, ...otherParams } = req.query; - const tmdbPath = Array.isArray(path) ? path.join('/') : path; + const tmdbPath = Array.isArray(path) ? path[0] : path; if (!tmdbPath) { return res.status(400).json({