From 0dd11b572d8f5e5aa7e4bfd48eb1e0884849684c Mon Sep 17 00:00:00 2001 From: soufiane Date: Mon, 1 Dec 2025 21:52:04 +0100 Subject: [PATCH] test: add unit tests for new components to improve coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests for useClickOutside hook - Add tests for UserDropdown component - Add tests for TicketPrizeDisplay component - Add tests for TicketTableRow component - Increase test count from 181 to 222 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- __tests__/components/UserDropdown.test.tsx | 211 ++++++++++++++++++ .../components/ui/TicketPrizeDisplay.test.tsx | 73 ++++++ .../components/ui/TicketTableRow.test.tsx | 194 ++++++++++++++++ __tests__/hooks/useClickOutside.test.ts | 103 +++++++++ 4 files changed, 581 insertions(+) create mode 100644 __tests__/components/UserDropdown.test.tsx create mode 100644 __tests__/components/ui/TicketPrizeDisplay.test.tsx create mode 100644 __tests__/components/ui/TicketTableRow.test.tsx create mode 100644 __tests__/hooks/useClickOutside.test.ts diff --git a/__tests__/components/UserDropdown.test.tsx b/__tests__/components/UserDropdown.test.tsx new file mode 100644 index 0000000..c13b978 --- /dev/null +++ b/__tests__/components/UserDropdown.test.tsx @@ -0,0 +1,211 @@ +/** + * Tests for the UserDropdown component + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { UserDropdown } from '@/components/UserDropdown'; + +// Mock next/link +jest.mock('next/link', () => { + return ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ); +}); + +describe('UserDropdown', () => { + const mockUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + }; + + const mockOnLogout = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render user initials', () => { + render( + + ); + + expect(screen.getByText('JD')).toBeInTheDocument(); + }); + + it('should render user name and email', () => { + render( + + ); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('john.doe@example.com')).toBeInTheDocument(); + }); + + it('should toggle dropdown on click', () => { + render( + + ); + + // Dropdown should be closed initially + expect(screen.queryByText('Profil')).not.toBeInTheDocument(); + + // Click to open - get the main trigger button (contains user name) + const triggerButton = screen.getByText('John Doe').closest('button'); + fireEvent.click(triggerButton!); + expect(screen.getByText('Profil')).toBeInTheDocument(); + expect(screen.getByText('Deconnexion')).toBeInTheDocument(); + + // Click to close + fireEvent.click(triggerButton!); + expect(screen.queryByText('Profil')).not.toBeInTheDocument(); + }); + + it('should render profile link with correct path', () => { + render( + + ); + + const triggerButton = screen.getByText('John Doe').closest('button'); + fireEvent.click(triggerButton!); + + const profileLink = screen.getByText('Profil').closest('a'); + expect(profileLink).toHaveAttribute('href', '/admin/profil'); + }); + + it('should call onLogout when logout button is clicked', () => { + render( + + ); + + const triggerButton = screen.getByText('John Doe').closest('button'); + fireEvent.click(triggerButton!); + fireEvent.click(screen.getByText('Deconnexion')); + + expect(mockOnLogout).toHaveBeenCalledTimes(1); + }); + + it('should have onClick handler on profile link', () => { + render( + + ); + + const triggerButton = screen.getByText('John Doe').closest('button'); + fireEvent.click(triggerButton!); + + const profileLink = screen.getByText('Profil').closest('a'); + expect(profileLink).toBeInTheDocument(); + expect(profileLink).toHaveAttribute('href', '/profil'); + }); + + it('should apply blue accent color by default', () => { + const { container } = render( + + ); + + const avatar = container.querySelector('.bg-blue-600'); + expect(avatar).toBeInTheDocument(); + }); + + it('should apply green accent color when specified', () => { + const { container } = render( + + ); + + const avatar = container.querySelector('.bg-green-600'); + expect(avatar).toBeInTheDocument(); + }); + + it('should handle null user gracefully', () => { + const { container } = render( + + ); + + // Should render empty initials + const avatarDiv = container.querySelector('.bg-blue-600'); + expect(avatarDiv).toBeInTheDocument(); + }); + + it('should handle user with missing firstName', () => { + render( + + ); + + expect(screen.getByText('D')).toBeInTheDocument(); + }); + + it('should apply custom className', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should rotate chevron icon when dropdown is open', () => { + const { container } = render( + + ); + + const chevron = container.querySelector('.w-4.h-4.text-gray-500'); + expect(chevron).not.toHaveClass('rotate-180'); + + const triggerButton = screen.getByText('John Doe').closest('button'); + fireEvent.click(triggerButton!); + + const rotatedChevron = container.querySelector('.rotate-180'); + expect(rotatedChevron).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/ui/TicketPrizeDisplay.test.tsx b/__tests__/components/ui/TicketPrizeDisplay.test.tsx new file mode 100644 index 0000000..3155e15 --- /dev/null +++ b/__tests__/components/ui/TicketPrizeDisplay.test.tsx @@ -0,0 +1,73 @@ +/** + * Tests for the TicketPrizeDisplay component + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TicketPrizeDisplay } from '@/components/ui/TicketPrizeDisplay'; + +describe('TicketPrizeDisplay', () => { + it('should render prize name for INFUSEUR', () => { + render(); + expect(screen.getByText('Infuseur à thé')).toBeInTheDocument(); + }); + + it('should render prize name for THE_SIGNATURE', () => { + render(); + expect(screen.getByText('Thé signature 100g')).toBeInTheDocument(); + }); + + it('should render prize name for COFFRET_DECOUVERTE', () => { + render(); + expect(screen.getByText('Coffret découverte 39€')).toBeInTheDocument(); + }); + + it('should render prize name for COFFRET_PRESTIGE', () => { + render(); + expect(screen.getByText('Coffret prestige 69€')).toBeInTheDocument(); + }); + + it('should render prize name for THE_GRATUIT', () => { + render(); + expect(screen.getByText('Thé gratuit en magasin')).toBeInTheDocument(); + }); + + it('should render icon for each prize type', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should return null for unknown prize type', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should apply custom className', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should render with prize color styling', () => { + const { container } = render(); + const iconContainer = container.querySelector('.rounded-full'); + expect(iconContainer).toBeInTheDocument(); + }); + + it('should have flex layout with gap', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('flex'); + expect(container.firstChild).toHaveClass('items-center'); + expect(container.firstChild).toHaveClass('gap-3'); + }); + + it('should render prize name with correct text styling', () => { + render(); + const nameElement = screen.getByText('Infuseur à thé'); + expect(nameElement).toHaveClass('text-sm'); + expect(nameElement).toHaveClass('font-medium'); + }); +}); diff --git a/__tests__/components/ui/TicketTableRow.test.tsx b/__tests__/components/ui/TicketTableRow.test.tsx new file mode 100644 index 0000000..36c7fe1 --- /dev/null +++ b/__tests__/components/ui/TicketTableRow.test.tsx @@ -0,0 +1,194 @@ +/** + * Tests for the TicketTableRow component + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TicketTableRow } from '@/components/ui/TicketTableRow'; +import { Ticket } from '@/types'; + +describe('TicketTableRow', () => { + const mockTicket: Ticket = { + id: '1', + code: 'ABC123', + status: 'PENDING', + playedAt: '2024-01-15T10:30:00Z', + claimedAt: null, + prize: { + id: '1', + type: 'INFUSEUR', + name: 'Infuseur à thé', + value: 10, + }, + }; + + it('should render ticket code', () => { + render( + + + + +
+ ); + expect(screen.getByText('ABC123')).toBeInTheDocument(); + }); + + it('should render ticket status badge', () => { + render( + + + + +
+ ); + expect(screen.getByText('En attente')).toBeInTheDocument(); + }); + + it('should render prize display when prize exists', () => { + render( + + + + +
+ ); + expect(screen.getByText('Infuseur à thé')).toBeInTheDocument(); + }); + + it('should render played date', () => { + render( + + + + +
+ ); + // French date format + expect(screen.getByText(/15\/01\/2024|15 janvier 2024/)).toBeInTheDocument(); + }); + + it('should show claimed date when showClaimedDate is true', () => { + const ticketWithClaim: Ticket = { + ...mockTicket, + status: 'CLAIMED', + claimedAt: '2024-01-20T14:00:00Z', + }; + + render( + + + + +
+ ); + + // Should have an extra column for claimed date + const cells = screen.getAllByRole('cell'); + expect(cells.length).toBe(5); // code, prize, status, playedAt, claimedAt + }); + + it('should not show claimed date column when showClaimedDate is false', () => { + render( + + + + +
+ ); + + const cells = screen.getAllByRole('cell'); + expect(cells.length).toBe(4); // code, prize, status, playedAt + }); + + it('should show dash when playedAt is null', () => { + const ticketWithoutDate: Ticket = { + ...mockTicket, + playedAt: null, + }; + + render( + + + + +
+ ); + + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('should show dash when claimedAt is null with showClaimedDate', () => { + render( + + + + +
+ ); + + const dashes = screen.getAllByText('-'); + expect(dashes.length).toBeGreaterThan(0); + }); + + it('should render CLAIMED status correctly', () => { + const claimedTicket: Ticket = { + ...mockTicket, + status: 'CLAIMED', + }; + + render( + + + + +
+ ); + + expect(screen.getByText('Réclamé')).toBeInTheDocument(); + }); + + it('should handle ticket without prize', () => { + const ticketWithoutPrize: Ticket = { + ...mockTicket, + prize: undefined, + }; + + render( + + + + +
+ ); + + expect(screen.getByText('ABC123')).toBeInTheDocument(); + // Should not crash and prize cell should be empty + }); + + it('should have hover styling on row', () => { + render( + + + + +
+ ); + + const row = screen.getByRole('row'); + expect(row).toHaveClass('hover:bg-gradient-to-r'); + }); + + it('should render code with monospace font', () => { + render( + + + + +
+ ); + + const codeElement = screen.getByText('ABC123'); + expect(codeElement).toHaveClass('font-mono'); + }); +}); diff --git a/__tests__/hooks/useClickOutside.test.ts b/__tests__/hooks/useClickOutside.test.ts new file mode 100644 index 0000000..448a4cc --- /dev/null +++ b/__tests__/hooks/useClickOutside.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for the useClickOutside hook + * @jest-environment jsdom + */ + +import { renderHook } from '@testing-library/react'; +import { useClickOutside } from '@/hooks/useClickOutside'; +import { createRef } from 'react'; + +describe('useClickOutside', () => { + let callback: jest.Mock; + let ref: React.RefObject; + let element: HTMLDivElement; + + beforeEach(() => { + callback = jest.fn(); + element = document.createElement('div'); + document.body.appendChild(element); + ref = { current: element }; + }); + + afterEach(() => { + document.body.removeChild(element); + jest.clearAllMocks(); + }); + + it('should call callback when clicking outside the element', () => { + renderHook(() => useClickOutside(ref, callback, true)); + + const outsideElement = document.createElement('div'); + document.body.appendChild(outsideElement); + + const event = new MouseEvent('mousedown', { bubbles: true }); + outsideElement.dispatchEvent(event); + + expect(callback).toHaveBeenCalledTimes(1); + + document.body.removeChild(outsideElement); + }); + + it('should not call callback when clicking inside the element', () => { + renderHook(() => useClickOutside(ref, callback, true)); + + const event = new MouseEvent('mousedown', { bubbles: true }); + element.dispatchEvent(event); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not call callback when disabled', () => { + renderHook(() => useClickOutside(ref, callback, false)); + + const outsideElement = document.createElement('div'); + document.body.appendChild(outsideElement); + + const event = new MouseEvent('mousedown', { bubbles: true }); + outsideElement.dispatchEvent(event); + + expect(callback).not.toHaveBeenCalled(); + + document.body.removeChild(outsideElement); + }); + + it('should handle null ref gracefully', () => { + const nullRef = createRef(); + + // Should not throw when ref is null + expect(() => { + renderHook(() => useClickOutside(nullRef, callback, true)); + + const event = new MouseEvent('mousedown', { bubbles: true }); + document.body.dispatchEvent(event); + }).not.toThrow(); + + // Callback should NOT be called when ref is null (ref.current is falsy) + expect(callback).not.toHaveBeenCalled(); + }); + + it('should remove event listener on unmount', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + + const { unmount } = renderHook(() => useClickOutside(ref, callback, true)); + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)); + + removeEventListenerSpy.mockRestore(); + }); + + it('should use default enabled value of true', () => { + renderHook(() => useClickOutside(ref, callback)); + + const outsideElement = document.createElement('div'); + document.body.appendChild(outsideElement); + + const event = new MouseEvent('mousedown', { bubbles: true }); + outsideElement.dispatchEvent(event); + + expect(callback).toHaveBeenCalledTimes(1); + + document.body.removeChild(outsideElement); + }); +});