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);
});
});
});