import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { TripCards } from '../index'; import { TripCardsElement } from '../types'; import * as mockDataModule from '../mockData'; global.fetch = jest.fn(); // Mock ResizeObserver global.ResizeObserver = class ResizeObserver { public observe(): void { // Mock implementation } public unobserve(): void { // Mock implementation } public disconnect(): void { // Mock implementation } }; describe('TripCards', () => { const mockCards = [ { cruise_id: '1', image: 'image1.jpg', headline: 'Mock Cruise 1', date: 'Jan 2024', duration: '7 nights', destination: 'Route 1', ship: 'Ship 1', price: '£1000', original_price: undefined, logo: 'logo1.png', logo_url: 'https://example.com', logo_name: 'Mock Line 1', cta_url: 'link1', cta_text: 'View' }, { cruise_id: '2', image: 'image2.jpg', headline: 'Mock Cruise 2', date: 'Feb 2024', duration: '14 nights', destination: 'Route 2', ship: 'Ship 2', price: '£2000', original_price: undefined, logo: 'logo2.png', logo_url: 'https://example.com', logo_name: 'Mock Line 2', cta_url: 'link2', cta_text: 'View' }, { cruise_id: '3', image: 'image3.jpg', headline: 'Mock Cruise 3', date: 'Mar 2024', duration: '10 nights', destination: 'Route 3', ship: 'Ship 3', price: '£1500', original_price: undefined, logo: 'logo3.png', logo_url: 'https://example.com', logo_name: 'Mock Line 3', cta_url: 'link3', cta_text: 'View' } ]; const mockElement: TripCardsElement = { title: 'Test Cruises', description: 'Test Description', tripcards: btoa(JSON.stringify([1, 2, 3])), titleurl: 'https://example.com' }; const RealDate = global.Date; beforeEach(() => { const fixedDate = new RealDate('2025-12-01T00:00:00Z'); // tslint:disable-next-line const MockDate = class extends RealDate { constructor(...args: any[]) { if (args.length === 0) { super(fixedDate.getTime()); } else { // @ts-ignore super(...args); } } // tslint:disable-next-line static now() { return fixedDate.getTime(); } } as DateConstructor; global.Date = MockDate; jest.clearAllMocks(); // Mock matchMedia for each test Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: jest.fn().mockImplementation(query => ({ matches: false, media: query, onchange: null, addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn() })) }); jest .spyOn(mockDataModule, 'getMockTripCards') .mockImplementation((count?: number) => { return count ? mockCards.slice(0, count) : mockCards; }); Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 }); }); afterEach(() => { global.Date = RealDate; }); describe('Mock Data Mode', () => { it('renders cards when useMockData is true', async () => { render(); await waitFor(() => { expect(screen.getByText('Mock Cruise 1')).toBeInTheDocument(); expect(screen.getByText('Mock Cruise 2')).toBeInTheDocument(); }); }); it('renders title and description', async () => { render(); await waitFor(() => { expect(screen.getByText('Test Cruises')).toBeInTheDocument(); expect(screen.getByText('Test Description')).toBeInTheDocument(); }); }); it('respects the number of IDs', async () => { render(); await waitFor(() => { expect(mockDataModule.getMockTripCards).toHaveBeenCalledWith(3); }); }); }); describe('Error Handling', () => { it('returns null when no IDs are provided', async () => { const emptyElement: TripCardsElement = { title: 'Test', description: 'Test', tripcards: '', titleurl: undefined }; const { container } = render(); await waitFor(() => { expect(container.firstChild).toBeNull(); }); }); it('returns null when fetch fails', async () => { (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); const { container } = render(); await waitFor(() => { expect(container.firstChild).toBeNull(); }); }); it('returns null when response is not ok', async () => { (global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 404 }); const { container } = render(); await waitFor(() => { expect(container.firstChild).toBeNull(); }); }); it('returns null when API response format is invalid', async () => { (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => ({ invalid: 'data' }) }); const { container } = render(); await waitFor(() => { expect(container.firstChild).toBeNull(); }); }); }); describe('API Data Fetching', () => { it('fetches and displays cruise data from API', async () => { const mockApiResponse = { results: [ { post_id: 1, cruise_id: 1, cruise_title: 'API Cruise 1', cruise_line: { name: 'API Line', link: 'http://line.com', logo: 'logo.png' }, ship: { name: 'API Ship', link: 'http://ship.com', image: 'api-image1.jpg' }, departs: '2099-03-01', ends: '2099-03-11', date_label: 'March 2024', destination: ['Port 1', 'Port 3'], duration: 10, itinerary: ['Port 1', 'Port 2', 'Port 3'], prices: { cheapest_price: 1500, inside: 1500, outside: 1800, balcony: 2000, suite: 2500 }, was_prices: { inside: 2000, outside: 2200, balcony: 2500, suite: 3000 }, link: 'api-link', campaigns: ['Special'], extras: { date: 'March 2024', duration: '10 nights', destination: 'Mediterranean', info: 'Additional info' } }, { post_id: 2, cruise_id: 2, cruise_title: 'API Cruise 2', cruise_line: { name: 'API Line', link: 'http://line.com', logo: 'logo.png' }, ship: { name: 'API Ship 2', link: 'http://ship.com', image: 'api-image2.jpg' }, departs: '2099-04-01', ends: '2099-04-11', date_label: 'April 2024', destination: ['Port 2', 'Port 4'], duration: 10, itinerary: ['Port 2', 'Port 3', 'Port 4'], prices: { cheapest_price: 1600, inside: 1600, outside: 1900, balcony: 2100, suite: 2600 }, was_prices: { inside: 0, outside: 0, balcony: 0, suite: 0 }, link: 'api-link2', campaigns: [], extras: { date: 'April 2024', duration: '10 nights', destination: 'Caribbean', info: 'Additional info' } }, { post_id: 3, cruise_id: 3, cruise_title: 'API Cruise 3', cruise_line: { name: 'API Line', link: 'http://line.com', logo: 'logo.png' }, ship: { name: 'API Ship 3', link: 'http://ship.com', image: 'api-image3.jpg' }, departs: '2099-05-01', ends: '2099-05-11', date_label: 'May 2024', destination: ['Port 3', 'Port 5'], duration: 10, itinerary: ['Port 3', 'Port 4', 'Port 5'], prices: { cheapest_price: 1700, inside: 1700, outside: 2000, balcony: 2200, suite: 2700 }, was_prices: { inside: 2100, outside: 2400, balcony: 2600, suite: 3100 }, link: 'api-link3', campaigns: [], extras: { date: 'May 2024', duration: '10 nights', destination: 'Alaska', info: 'Additional info' } } ] }; (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => mockApiResponse }); render(); await waitFor(() => { expect(screen.getByText('API Cruise 1')).toBeInTheDocument(); }); }); it('sends correct request to API', async () => { const mockApiResponse = { results: mockCards.map((card, index) => ({ post_id: index + 1, cruise_id: parseInt(card.cruise_id, 10), cruise_title: card.headline, cruise_line: { name: 'Test Line', link: card.logo_url, logo: card.logo }, ship: { name: card.ship, link: 'http://ship.com', image: card.image }, departs: '2026-01-01', ends: '2026-01-11', date_label: card.date, destination: ['Test Port'], duration: 10, itinerary: ['Port 1', 'Port 2', 'Port 3'], prices: { cheapest_price: 1500, inside: 1500, outside: 1800, balcony: 2000, suite: 2500 }, was_prices: { inside: 0, outside: 0, balcony: 0, suite: 0 }, link: card.cta_url, campaigns: [], extras: { date: card.date, duration: card.duration, destination: 'Test', info: 'Info' } })) }; (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => mockApiResponse }); render(); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( 'https://www.staging-thetimes.com/holidays/wp-admin/admin-ajax.php', expect.objectContaining({ method: 'POST', body: expect.any(FormData) }) ); }); }); }); describe('Layout', () => { it('uses static grid for exactly 2 cards', async () => { const twoCardElement: TripCardsElement = { ...mockElement, tripcards: btoa(JSON.stringify([1, 2])) }; render(); await waitFor(() => { expect(screen.getByText('Mock Cruise 1')).toBeInTheDocument(); }); expect(screen.queryByLabelText('Previous items')).not.toBeInTheDocument(); }); it('uses carousel for more than 2 cards', async () => { const threeCards = [ ...mockCards, { cruise_id: '3', image: 'image3.jpg', headline: 'Mock Cruise 3', date: 'Mar 2024', duration: '21 nights', destination: 'Route 3', ship: 'Ship 3', price: '£3000', original_price: undefined, logo: 'logo3.png', logo_url: 'https://example.com', logo_name: 'Mock Line 3', cta_url: 'link3', cta_text: 'View' } ]; jest .spyOn(mockDataModule, 'getMockTripCards') .mockReturnValue(threeCards); render(); await waitFor(() => { expect(screen.getByText('Mock Cruise 1')).toBeInTheDocument(); expect(screen.getByLabelText('Previous items')).toBeInTheDocument(); expect(screen.getByLabelText('Next items')).toBeInTheDocument(); }); }); }); describe('Responsive Behavior', () => { it('uses matchMedia for responsive breakpoint detection', () => { const mockMediaQuery = { matches: false, media: '(max-width: 767px)', onchange: null, addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn() }; const matchMediaSpy = jest .spyOn(window, 'matchMedia') .mockReturnValue(mockMediaQuery as any); const { unmount } = render( ); // Verify matchMedia was called with the correct query expect(matchMediaSpy).toHaveBeenCalledWith('(max-width: 767px)'); // Verify addEventListener was called on the media query expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith( 'change', expect.any(Function) ); unmount(); // Verify removeEventListener was called on cleanup expect(mockMediaQuery.removeEventListener).toHaveBeenCalledWith( 'change', expect.any(Function) ); matchMediaSpy.mockRestore(); }); }); describe('Props', () => { it('renders empty title when not provided', async () => { const elementWithoutTitle: TripCardsElement = { ...mockElement, title: undefined }; render(); await waitFor(() => { const titleElement = screen.getByTestId('trip-cards-title'); expect(titleElement).toBeInTheDocument(); expect(titleElement).toBeEmptyDOMElement(); }); }); it('renders empty description when not provided', async () => { const elementWithoutDesc: TripCardsElement = { ...mockElement, description: undefined }; render(); await waitFor(() => { const descElement = screen.getByTestId('trip-cards-subtitle'); expect(descElement).toBeInTheDocument(); expect(descElement).toBeEmptyDOMElement(); }); }); it('renders title link when provided', async () => { render(); await waitFor(() => { const link = screen.getByTestId('title-link'); expect(link).toHaveAttribute('href', 'https://example.com'); expect(link).toHaveAttribute('target', '_blank'); }); }); it('does not render title link when not provided', async () => { const elementWithoutLink: TripCardsElement = { ...mockElement, titleurl: undefined }; render(); await waitFor(() => { expect(screen.getByText('Mock Cruise 1')).toBeInTheDocument(); }); const links = screen.getAllByRole('link'); expect(links.length).toBeGreaterThan(0); }); }); });