From de157cb87b55a21e4c841c03be220d3565b1ce13 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 12 Apr 2026 01:19:53 +0200 Subject: [PATCH] =?UTF-8?q?test:=20comprehensive=20Journey=20test=20suite?= =?UTF-8?q?=20=E2=80=94=2089.5%=20new=20code=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server (172 tests): - journeyService unit tests (87 tests): CRUD, access control, sync, photos, contributors - journeyShareService unit tests (20 tests): share links, token validation, public access - journey integration tests (45 tests): all API routes, auth, permissions, edge cases - Test helpers: journey factories, RESET_TABLES updated Client (340+ tests): - journeyStore tests (15 tests): all store actions and state management - JourneyPage tests (20 tests): frontpage, create flow, suggestions, navigation - JourneyDetailPage tests (94 tests): all sub-components, entry editor, settings, share links, contributors, gallery, map, trip linking - JourneyPublicPage tests (18 tests): public view, tabs, restricted access - JourneyBookPDF tests (6 tests): PDF generation - BottomNav tests (9 tests): profile sheet, navigation - PhotoLightbox tests (8 tests): keyboard nav, counter - JourneyMap tests (12 tests): markers, polylines, zoom - Component tests: moodConfig, stripMarkdown, MarkdownToolbar, JournalBody, MobileTopHeader - DashboardPage tests (32 tests): spotlight card, quick actions, widget settings SonarQube: exclude unused MemoriesPanel from coverage (dead code, moved to Journey) --- .../components/Journey/JournalBody.test.tsx | 39 + .../components/Journey/JourneyMap.test.tsx | 229 + .../Journey/MarkdownToolbar.test.tsx | 72 + .../components/Journey/PhotoLightbox.test.tsx | 97 + .../src/components/Journey/moodConfig.test.ts | 69 + .../components/Journey/stripMarkdown.test.ts | 38 + .../src/components/Layout/BottomNav.test.tsx | 101 + .../Layout/MobileTopHeader.test.tsx | 32 + .../components/PDF/JourneyBookPDF.test.tsx | 147 + client/src/pages/DashboardPage.test.tsx | 361 +- client/src/pages/JourneyDetailPage.test.tsx | 3712 +++++++++++++++++ client/src/pages/JourneyPage.test.tsx | 457 ++ client/src/pages/JourneyPublicPage.test.tsx | 499 +++ client/src/store/journeyStore.test.ts | 329 ++ client/tests/helpers/store.ts | 2 +- server/tests/helpers/factories.ts | 90 + server/tests/helpers/test-db.ts | 7 + server/tests/integration/journey.test.ts | 955 +++++ .../unit/services/journeyService.test.ts | 1352 ++++++ .../unit/services/journeyShareService.test.ts | 368 ++ sonar-project.properties | 3 +- 21 files changed, 8943 insertions(+), 16 deletions(-) create mode 100644 client/src/components/Journey/JournalBody.test.tsx create mode 100644 client/src/components/Journey/JourneyMap.test.tsx create mode 100644 client/src/components/Journey/MarkdownToolbar.test.tsx create mode 100644 client/src/components/Journey/PhotoLightbox.test.tsx create mode 100644 client/src/components/Journey/moodConfig.test.ts create mode 100644 client/src/components/Journey/stripMarkdown.test.ts create mode 100644 client/src/components/Layout/BottomNav.test.tsx create mode 100644 client/src/components/Layout/MobileTopHeader.test.tsx create mode 100644 client/src/components/PDF/JourneyBookPDF.test.tsx create mode 100644 client/src/pages/JourneyDetailPage.test.tsx create mode 100644 client/src/pages/JourneyPage.test.tsx create mode 100644 client/src/pages/JourneyPublicPage.test.tsx create mode 100644 client/src/store/journeyStore.test.ts create mode 100644 server/tests/integration/journey.test.ts create mode 100644 server/tests/unit/services/journeyService.test.ts create mode 100644 server/tests/unit/services/journeyShareService.test.ts diff --git a/client/src/components/Journey/JournalBody.test.tsx b/client/src/components/Journey/JournalBody.test.tsx new file mode 100644 index 00000000..39da6246 --- /dev/null +++ b/client/src/components/Journey/JournalBody.test.tsx @@ -0,0 +1,39 @@ +// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005 + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import JournalBody from './JournalBody'; + +describe('JournalBody', () => { + it('FE-COMP-JOURNALBODY-001: renders plain text content', () => { + render(); + expect(screen.getByText('Hello traveller')).toBeInTheDocument(); + }); + + it('FE-COMP-JOURNALBODY-002: renders bold markdown as ', () => { + const { container } = render(); + const strong = container.querySelector('strong'); + expect(strong).toBeInTheDocument(); + expect(strong!.textContent).toBe('bold'); + }); + + it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => { + render(); + const link = screen.getByRole('link', { name: 'Visit' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => { + const { container } = render(); + const h2 = container.querySelector('h2'); + expect(h2).toBeInTheDocument(); + expect(h2!.textContent).toBe('Section Title'); + }); + + it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => { + const { container } = render(); + expect(container.querySelector('.journal-body')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx new file mode 100644 index 00000000..a44e9dbc --- /dev/null +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -0,0 +1,229 @@ +// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-006 + +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +// Leaflet does not work in jsdom — mock the entire library +vi.mock('leaflet', () => { + const mockMarker = { + addTo: vi.fn().mockReturnThis(), + bindTooltip: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + setIcon: vi.fn(), + setZIndexOffset: vi.fn(), + getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })), + }; + const mockMap = { + remove: vi.fn(), + invalidateSize: vi.fn(), + fitBounds: vi.fn(), + setView: vi.fn(), + flyTo: vi.fn(), + getZoom: vi.fn(() => 10), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; + return { + default: { + map: vi.fn(() => mockMap), + tileLayer: vi.fn(() => ({ addTo: vi.fn() })), + marker: vi.fn(() => mockMarker), + polyline: vi.fn(() => ({ addTo: vi.fn() })), + divIcon: vi.fn(() => ({})), + latLngBounds: vi.fn(() => ({})), + }, + map: vi.fn(() => mockMap), + tileLayer: vi.fn(() => ({ addTo: vi.fn() })), + marker: vi.fn(() => mockMarker), + polyline: vi.fn(() => ({ addTo: vi.fn() })), + divIcon: vi.fn(() => ({})), + latLngBounds: vi.fn(() => ({})), + }; +}); + +import React from 'react'; +import { render } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useSettingsStore } from '../../store/settingsStore'; +import { buildSettings } from '../../../tests/helpers/factories'; +import L from 'leaflet'; +import JourneyMap from './JourneyMap'; +import type { JourneyMapHandle } from './JourneyMap'; + +const entriesWithCoords = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, + { id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' }, +]; + +const entriesWithoutCoords = [ + { id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' }, +]; + +const mixedEntries = [ + ...entriesWithCoords, + ...entriesWithoutCoords, +]; + +beforeEach(() => { + resetAllStores(); + seedStore(useSettingsStore, { settings: buildSettings() }); + vi.clearAllMocks(); +}); + +describe('JourneyMap', () => { + it('FE-COMP-JOURNEYMAP-001: renders map container', () => { + const { container } = render( + + ); + // The component renders a div with a child div ref for the Leaflet map + expect(container.firstChild).toBeInTheDocument(); + expect(L.map).toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => { + render( + + ); + // Two entries with valid lat/lng should produce two markers + expect(L.marker).toHaveBeenCalledTimes(2); + }); + + it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => { + render( + + ); + // Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng)) + expect(L.marker).not.toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => { + render( + + ); + // With 2+ marker items, a route polyline is drawn + expect(L.polyline).toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => { + render( + + ); + // Each marker calls bindTooltip with the entry label + const mockMarkerInstance = (L.marker as any).mock.results[0].value; + expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith( + 'Paris', + expect.objectContaining({ direction: 'top' }), + ); + }); + + it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => { + const ref = React.createRef(); + render( + + ); + expect(ref.current).not.toBeNull(); + expect(typeof ref.current!.focusMarker).toBe('function'); + expect(typeof ref.current!.highlightMarker).toBe('function'); + }); + + it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => { + render( + + ); + // Each marker is created with L.divIcon containing SVG html + expect(L.divIcon).toHaveBeenCalledTimes(2); + const firstCall = (L.divIcon as any).mock.calls[0][0]; + expect(firstCall.html).toContain(''); + // Marker index label "1" for first entry + expect(firstCall.html).toContain('>1<'); + }); + + it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => { + const entriesWithMood = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' }, + { id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' }, + ]; + render( + + ); + // Markers are still created (mood does not prevent rendering) + expect(L.marker).toHaveBeenCalledTimes(2); + // Tooltips use the entry titles + const mockMarker1 = (L.marker as any).mock.results[0].value; + expect(mockMarker1.bindTooltip).toHaveBeenCalledWith( + 'Happy Paris', + expect.objectContaining({ direction: 'top' }), + ); + const mockMarker2 = (L.marker as any).mock.results[1].value; + expect(mockMarker2.bindTooltip).toHaveBeenCalledWith( + 'Sad Berlin', + expect.objectContaining({ direction: 'top' }), + ); + }); + + it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => { + const threeEntries = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, + { id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' }, + { id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' }, + ]; + render( + + ); + // Route polyline is drawn for items.length > 1 + expect(L.polyline).toHaveBeenCalled(); + const polylineCall = (L.polyline as any).mock.calls[0]; + // Should contain coordinates for all three entries + expect(polylineCall[0].length).toBe(3); + // Verify dashed style + expect(polylineCall[1]).toMatchObject({ dashArray: '4 6' }); + }); + + it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => { + // Trigger requestAnimationFrame synchronously + const origRAF = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; + + render( + + ); + + const mockMap = (L.map as any).mock.results[0].value; + // fitBounds is called inside requestAnimationFrame with the collected coordinates + expect(mockMap.fitBounds).toHaveBeenCalled(); + expect(L.latLngBounds).toHaveBeenCalled(); + + globalThis.requestAnimationFrame = origRAF; + }); + + it('FE-COMP-JOURNEYMAP-011: single entry creates marker but no polyline', () => { + const singleEntry = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' }, + ]; + render( + + ); + // One marker created + expect(L.marker).toHaveBeenCalledTimes(1); + // No route polyline — polyline is only drawn when items.length > 1 + expect(L.polyline).not.toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => { + const { container } = render( + + ); + // The component renders zoom in (+) and zoom out (−) buttons + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(2); + expect(buttons[0].textContent).toBe('+'); + expect(buttons[1].textContent).toBe('−'); + }); +}); diff --git a/client/src/components/Journey/MarkdownToolbar.test.tsx b/client/src/components/Journey/MarkdownToolbar.test.tsx new file mode 100644 index 00000000..6910dbc8 --- /dev/null +++ b/client/src/components/Journey/MarkdownToolbar.test.tsx @@ -0,0 +1,72 @@ +// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import MarkdownToolbar from './MarkdownToolbar'; +import React from 'react'; + +function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.selectionStart = selectionStart; + textarea.selectionEnd = selectionEnd; + textarea.focus = vi.fn(); + textarea.setSelectionRange = vi.fn(); + return { current: textarea } as React.RefObject; +} + +describe('MarkdownToolbar', () => { + let onUpdate: ReturnType; + + beforeEach(() => { + onUpdate = vi.fn(); + }); + + it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => { + const ref = createTextareaRef(); + render(); + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(8); + }); + + it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => { + const ref = createTextareaRef(); + render(); + expect(screen.getByTitle('Bold')).toBeInTheDocument(); + expect(screen.getByTitle('Italic')).toBeInTheDocument(); + expect(screen.getByTitle('Link')).toBeInTheDocument(); + expect(screen.getByTitle('Heading')).toBeInTheDocument(); + expect(screen.getByTitle('Quote')).toBeInTheDocument(); + expect(screen.getByTitle('List')).toBeInTheDocument(); + expect(screen.getByTitle('Ordered')).toBeInTheDocument(); + expect(screen.getByTitle('Divider')).toBeInTheDocument(); + }); + + it('FE-COMP-MDTOOLBAR-003: bold button wraps selected text with **', () => { + const ref = createTextareaRef('hello world', 6, 11); + render(); + fireEvent.click(screen.getByTitle('Bold')); + expect(onUpdate).toHaveBeenCalledWith('hello **world**'); + }); + + it('FE-COMP-MDTOOLBAR-004: italic button wraps selected text with _', () => { + const ref = createTextareaRef('hello world', 6, 11); + render(); + fireEvent.click(screen.getByTitle('Italic')); + expect(onUpdate).toHaveBeenCalledWith('hello _world_'); + }); + + it('FE-COMP-MDTOOLBAR-005: link button wraps selected text as markdown link', () => { + const ref = createTextareaRef('click me', 0, 8); + render(); + fireEvent.click(screen.getByTitle('Link')); + expect(onUpdate).toHaveBeenCalledWith('[click me](url)'); + }); + + it('FE-COMP-MDTOOLBAR-006: heading button inserts line prefix', () => { + const ref = createTextareaRef('my title', 0, 0); + render(); + fireEvent.click(screen.getByTitle('Heading')); + expect(onUpdate).toHaveBeenCalledWith('## my title'); + }); +}); diff --git a/client/src/components/Journey/PhotoLightbox.test.tsx b/client/src/components/Journey/PhotoLightbox.test.tsx new file mode 100644 index 00000000..048801e8 --- /dev/null +++ b/client/src/components/Journey/PhotoLightbox.test.tsx @@ -0,0 +1,97 @@ +// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-008 + +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import { resetAllStores } from '../../../tests/helpers/store'; +import PhotoLightbox from './PhotoLightbox'; + +const samplePhotos = [ + { id: 'p1', src: '/photos/1.jpg', caption: 'Sunset at the beach' }, + { id: 'p2', src: '/photos/2.jpg', caption: 'Mountain trail' }, + { id: 'p3', src: '/photos/3.jpg', caption: null }, +]; + +beforeEach(() => { + resetAllStores(); +}); + +describe('PhotoLightbox', () => { + it('FE-COMP-LIGHTBOX-001: renders without crashing when open', () => { + const onClose = vi.fn(); + render(); + expect(document.body).toBeInTheDocument(); + }); + + it('FE-COMP-LIGHTBOX-002: shows photo image', () => { + const onClose = vi.fn(); + render(); + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', '/photos/1.jpg'); + }); + + it('FE-COMP-LIGHTBOX-003: shows close button', () => { + const onClose = vi.fn(); + render(); + const buttons = screen.getAllByRole('button'); + // Close button exists (the X button in the top bar) + expect(buttons.length).toBeGreaterThan(0); + }); + + it('FE-COMP-LIGHTBOX-004: previous/next navigation works', () => { + const onClose = vi.fn(); + render(); + // Initially shows photo 1 + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', '/photos/1.jpg'); + + // Navigate to next photo via ArrowRight key + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(screen.getByText('2 / 3')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/2.jpg'); + + // Navigate back via ArrowLeft key + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/1.jpg'); + }); + + it('FE-COMP-LIGHTBOX-005: keyboard Escape closes lightbox', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => { + const onClose = vi.fn(); + render(); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + }); + + it('FE-COMP-LIGHTBOX-007: does not render when photos array is empty', () => { + const onClose = vi.fn(); + const { container } = render(); + // Component returns null when photo is undefined (empty array, index 0 is undefined) + expect(container.querySelector('img')).not.toBeInTheDocument(); + }); + + it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => { + const onClose = vi.fn(); + render(); + // The close button is in the top bar — find the button and click it + const buttons = screen.getAllByRole('button'); + // The first button in the top bar is the close (X) button + buttons[0].click(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Journey/moodConfig.test.ts b/client/src/components/Journey/moodConfig.test.ts new file mode 100644 index 00000000..62151a57 --- /dev/null +++ b/client/src/components/Journey/moodConfig.test.ts @@ -0,0 +1,69 @@ +// FE-COMP-MOOD-001 to FE-COMP-MOOD-005 + +import { describe, it, expect } from 'vitest'; +import { MOODS, WEATHERS, getMood, moodColor, tagColors, TAG_STYLES, MOOD_DEFAULT_COLOR } from './moodConfig'; + +describe('moodConfig', () => { + it('FE-COMP-MOOD-001: MOODS contains all five mood definitions', () => { + const ids = MOODS.map(m => m.id); + expect(ids).toEqual(['amazing', 'good', 'neutral', 'tired', 'rough']); + expect(MOODS).toHaveLength(5); + }); + + it('FE-COMP-MOOD-002: every mood has valid hex color and css var', () => { + for (const mood of MOODS) { + expect(mood.color).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(mood.cssVar).toMatch(/^var\(--mood-.+\)$/); + expect(mood.icon).toBeDefined(); + expect(mood.label).toBeTruthy(); + } + }); + + it('FE-COMP-MOOD-003: getMood returns correct mood or undefined', () => { + expect(getMood('amazing')?.id).toBe('amazing'); + expect(getMood('rough')?.color).toBe('#9B8EC4'); + expect(getMood('nonexistent')).toBeUndefined(); + expect(getMood(null)).toBeUndefined(); + expect(getMood(undefined)).toBeUndefined(); + }); + + it('FE-COMP-MOOD-004: moodColor returns css var or fallback', () => { + expect(moodColor('good')).toBe('var(--mood-good)'); + expect(moodColor(null)).toBe('var(--journal-faint)'); + expect(moodColor('unknown')).toBe('var(--journal-faint)'); + }); + + it('FE-COMP-MOOD-005: WEATHERS contains all eight entries with icons', () => { + expect(WEATHERS).toHaveLength(8); + const ids = WEATHERS.map(w => w.id); + expect(ids).toContain('sunny'); + expect(ids).toContain('snowy'); + expect(ids).toContain('stormy'); + for (const w of WEATHERS) { + expect(w.icon).toBeDefined(); + expect(w.label).toBeTruthy(); + } + }); +}); + +describe('tagColors', () => { + it('FE-COMP-MOOD-006: returns known tag colors for light and dark mode', () => { + const light = tagColors('hidden gem', false); + expect(light.bg).toBe('#dcfce7'); + expect(light.fg).toBe('#166534'); + + const dark = tagColors('hidden gem', true); + expect(dark.bg).toBe('rgba(22,101,52,0.2)'); + expect(dark.fg).toBe('#86efac'); + }); + + it('FE-COMP-MOOD-007: returns fallback colors for unknown tags', () => { + const light = tagColors('random tag', false); + expect(light.bg).toBe('rgba(0,0,0,0.05)'); + expect(light.fg).toBe('#374151'); + + const dark = tagColors('random tag', true); + expect(dark.bg).toBe('rgba(255,255,255,0.07)'); + expect(dark.fg).toBe('#a1a1aa'); + }); +}); diff --git a/client/src/components/Journey/stripMarkdown.test.ts b/client/src/components/Journey/stripMarkdown.test.ts new file mode 100644 index 00000000..d932c06d --- /dev/null +++ b/client/src/components/Journey/stripMarkdown.test.ts @@ -0,0 +1,38 @@ +// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006 + +import { describe, it, expect } from 'vitest'; +import { stripMarkdown } from './stripMarkdown'; + +describe('stripMarkdown', () => { + it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => { + expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic'); + expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic'); + }); + + it('FE-UTIL-STRIPMD-002: strips headings', () => { + expect(stripMarkdown('# Heading 1')).toBe('Heading 1'); + expect(stripMarkdown('## Heading 2')).toBe('Heading 2'); + expect(stripMarkdown('### Heading 3')).toBe('Heading 3'); + }); + + it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => { + expect(stripMarkdown('[click here](https://example.com)')).toBe('click here'); + expect(stripMarkdown('![alt text](image.jpg)')).toBe(''); + }); + + it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => { + expect(stripMarkdown('use `console.log`')).toBe('use console.log'); + expect(stripMarkdown('```\ncode block\n```')).toBe(''); + }); + + it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => { + expect(stripMarkdown('> quoted text')).toBe('quoted text'); + expect(stripMarkdown('- item one')).toBe('item one'); + expect(stripMarkdown('1. first item')).toBe('first item'); + }); + + it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => { + expect(stripMarkdown('~~deleted~~')).toBe('deleted'); + expect(stripMarkdown('---')).toBe(''); + }); +}); diff --git a/client/src/components/Layout/BottomNav.test.tsx b/client/src/components/Layout/BottomNav.test.tsx new file mode 100644 index 00000000..9be7ce16 --- /dev/null +++ b/client/src/components/Layout/BottomNav.test.tsx @@ -0,0 +1,101 @@ +// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009 + +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { useAuthStore } from '../../store/authStore'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildUser } from '../../../tests/helpers/factories'; +import BottomNav from './BottomNav'; + +const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' }); + +beforeEach(() => { + resetAllStores(); + mockNavigate.mockClear(); + seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); +}); + +describe('BottomNav', () => { + it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => { + render(); + expect(document.body).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => { + render(); + expect(screen.getByText('Trips')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => { + render(); + expect(screen.getByText('Profile')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + // Profile sheet shows username + expect(screen.getByText('testuser')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText('test@example.com')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => { + const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' }); + seedStore(useAuthStore, { user: adminUser, isAuthenticated: true }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + // Sheet is open — username visible + expect(screen.getByText('testuser')).toBeInTheDocument(); + // The outermost fixed div is the backdrop wrapper, clicking it triggers onClose + const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop); + // Sheet should be closed — username no longer visible (only the nav Profile text remains) + expect(screen.queryByText('testuser')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Layout/MobileTopHeader.test.tsx b/client/src/components/Layout/MobileTopHeader.test.tsx new file mode 100644 index 00000000..b8adca13 --- /dev/null +++ b/client/src/components/Layout/MobileTopHeader.test.tsx @@ -0,0 +1,32 @@ +// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004 + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import MobileTopHeader from './MobileTopHeader'; + +describe('MobileTopHeader', () => { + it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toBeInTheDocument(); + expect(heading.textContent).toBe('Journeys'); + }); + + it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => { + render(); + expect(screen.getByText('3 trips')).toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => { + const { container } = render(); + const subtitleEl = container.querySelector('.text-xs.text-zinc-500'); + expect(subtitleEl).not.toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => { + render( + Add} />, + ); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/PDF/JourneyBookPDF.test.tsx b/client/src/components/PDF/JourneyBookPDF.test.tsx new file mode 100644 index 00000000..bb43e711 --- /dev/null +++ b/client/src/components/PDF/JourneyBookPDF.test.tsx @@ -0,0 +1,147 @@ +// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006 +// +// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)` +// that opens a new browser window and writes a full HTML document into it. +// It does NOT render a React component. Tests verify window.open behaviour. + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// Mock `marked` so we don't need the real markdown parser +vi.mock('marked', () => ({ + marked: { + parse: (str: string) => `

${str}

`, + }, +})); + +import { downloadJourneyBookPDF } from './JourneyBookPDF'; +import type { JourneyDetail } from '../../store/journeyStore'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function buildJourney(overrides: Partial = {}): JourneyDetail { + return { + id: 1, + user_id: 1, + title: 'Iceland Ring Road', + subtitle: 'Two weeks around the island', + status: 'active', + cover_image: null, + cover_gradient: null, + created_at: Date.now(), + updated_at: Date.now(), + entries: [ + { + id: 10, + journey_id: 1, + author_id: 1, + type: 'entry', + title: 'Golden Circle', + story: 'An incredible day of geysers and waterfalls.', + entry_date: '2026-07-01', + entry_time: '09:00', + location_name: 'Thingvellir', + location_lat: 64.255, + location_lng: -21.13, + mood: 'excited', + weather: 'sunny', + tags: [], + pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] }, + visibility: 'private', + sort_order: 0, + created_at: Date.now(), + updated_at: Date.now(), + source_trip_id: null, + source_place_id: null, + source_trip_name: null, + photos: [ + { + id: 100, + entry_id: 10, + provider: 'local', + file_path: 'journey/geyser.jpg', + thumbnail_path: null, + asset_id: null, + owner_id: null, + shared: 0, + caption: 'Strokkur erupting', + sort_order: 0, + created_at: Date.now(), + }, + ], + }, + ], + trips: [], + contributors: [], + stats: { entries: 1, photos: 1, cities: 1 }, + ...overrides, + } as unknown as JourneyDetail; +} + +// ── Mock window.open ───────────────────────────────────────────────────────── + +let mockWindow: { + document: { write: ReturnType; close: ReturnType }; + focus: ReturnType; +}; + +beforeEach(() => { + mockWindow = { + document: { write: vi.fn(), close: vi.fn() }, + focus: vi.fn(), + }; + vi.spyOn(window, 'open').mockReturnValue(mockWindow as any); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('downloadJourneyBookPDF', () => { + it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => { + await downloadJourneyBookPDF(buildJourney()); + expect(window.open).toHaveBeenCalledWith('', '_blank'); + }); + + it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => { + await downloadJourneyBookPDF(buildJourney()); + expect(mockWindow.document.write).toHaveBeenCalledTimes(1); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => { + await downloadJourneyBookPDF(buildJourney()); + expect(mockWindow.document.close).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => { + await downloadJourneyBookPDF(buildJourney()); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain('Iceland Ring Road'); + }); + + it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => { + await downloadJourneyBookPDF(buildJourney()); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain('Golden Circle'); + // Story text is rendered via markdown + expect(html).toContain('An incredible day of geysers and waterfalls.'); + // Pros/cons verdict cards are included + expect(html).toContain('Amazing views'); + expect(html).toContain('Crowded'); + }); + + it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => { + const journey = buildJourney({ entries: [] }); + await downloadJourneyBookPDF(journey); + expect(window.open).toHaveBeenCalled(); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain('Iceland Ring Road'); + // No entry pages, but cover and closing page are still present + expect(html).toContain('Journey Book'); + expect(html).toContain('The End'); + }); +}); diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index ecc24488..070a6e1d 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -46,7 +46,7 @@ describe('DashboardPage', () => { // After data loads, trip cards should appear await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); }); }); @@ -56,11 +56,11 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // At least the first trip name should be visible - expect(screen.getByText('Paris Adventure')).toBeVisible(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeVisible(); }); }); @@ -135,7 +135,7 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // Find delete button — CardAction with label t('common.delete') @@ -155,7 +155,7 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // Open confirm dialog @@ -188,7 +188,7 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // Open confirm dialog @@ -202,7 +202,7 @@ describe('DashboardPage', () => { await user.click(screen.getByRole('button', { name: /cancel/i })); // Trip still visible - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); }); @@ -223,7 +223,7 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // Click archive button @@ -239,7 +239,7 @@ describe('DashboardPage', () => { await user.click(screen.getByRole('button', { name: /archived/i })); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); }); }); @@ -250,7 +250,7 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); const editButtons = screen.getAllByRole('button', { name: /edit/i }); @@ -269,7 +269,7 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // Find the view mode toggle button (shows List icon when in grid mode, title "List view") @@ -299,7 +299,7 @@ describe('DashboardPage', () => { // Wait for active trips to load await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // Archived section toggle should be present @@ -394,7 +394,7 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Paris Adventure')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); // Find copy buttons @@ -402,7 +402,7 @@ describe('DashboardPage', () => { await user.click(copyButtons[0]); await waitFor(() => { - expect(screen.getByText('Paris Adventure (Copy)')).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument(); }); }); }); @@ -543,4 +543,337 @@ describe('DashboardPage', () => { }); }); }); + + describe('FE-PAGE-DASH-023: SpotlightCard shows progress bar for ongoing trip', () => { + it('renders progress bar and live badge when trip is currently ongoing', async () => { + const today = new Date().toISOString().split('T')[0]; + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]; + + const ongoingTrip = buildTrip({ + title: 'Current Voyage', + start_date: yesterday, + end_date: nextWeek, + day_count: 9, + place_count: 3, + shared_count: 1, + }); + + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [] }); + return HttpResponse.json({ trips: [ongoingTrip] }); + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Current Voyage').length).toBeGreaterThan(0); + }); + + // Live badge text appears (mobile + desktop spotlight) + await waitFor(() => { + expect(screen.getAllByText(/live now/i).length).toBeGreaterThan(0); + }); + + // Progress bar label "Trip progress" appears + expect(screen.getAllByText(/trip progress/i).length).toBeGreaterThan(0); + + // "days left" label appears inside the progress section + expect(screen.getAllByText(/days left/i).length).toBeGreaterThan(0); + }); + }); + + describe('FE-PAGE-DASH-024: SpotlightCard shows countdown for upcoming trip', () => { + it('renders countdown badge for a future trip', async () => { + const inFiveDays = new Date(Date.now() + 5 * 86400000).toISOString().split('T')[0]; + const inTenDays = new Date(Date.now() + 10 * 86400000).toISOString().split('T')[0]; + + const upcomingTrip = buildTrip({ + title: 'Upcoming Safari', + start_date: inFiveDays, + end_date: inTenDays, + place_count: 2, + shared_count: 0, + }); + + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [] }); + return HttpResponse.json({ trips: [upcomingTrip] }); + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Upcoming Safari').length).toBeGreaterThan(0); + }); + + // Badge should show "X days left" countdown (not "Live now") + expect(screen.queryByText(/live now/i)).not.toBeInTheDocument(); + // The SpotlightCard renders a badge with the countdown text containing "days" + await waitFor(() => { + expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0); + }); + }); + }); + + describe('FE-PAGE-DASH-025: Mobile Quick Actions section renders', () => { + it('shows New Trip quick action button on mobile', async () => { + render(); + + await waitFor(() => { + expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0); + }); + + // Mobile Quick Actions: "New Trip" button rendered in the quick-actions grid + // getAllByText because it appears in both mobile quick-actions and desktop header + const newTripButtons = screen.getAllByText(/new trip/i); + expect(newTripButtons.length).toBeGreaterThan(0); + }); + }); + + describe('FE-PAGE-DASH-026: Widget settings toggles currency and timezone', () => { + it('toggling currency widget off hides it from settings', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0); + }); + + // Open widget settings + const allBtns = screen.getAllByRole('button'); + const settingsButton = allBtns.find( + btn => { + const title = btn.getAttribute('title'); + const text = btn.textContent?.trim() || ''; + return !title && !text && btn.querySelector('.lucide-settings'); + } + ); + + expect(settingsButton).toBeDefined(); + if (settingsButton) { + await user.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Widgets:')).toBeInTheDocument(); + }); + + // Both currency and timezone toggle labels should be visible + // Use getAllByText because labels may appear in both widget settings and quick actions + expect(screen.getAllByText(/currency/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/timezone/i).length).toBeGreaterThan(0); + } + }); + }); + + describe('FE-PAGE-DASH-027: Archived section expand and collapse', () => { + it('expands and then collapses the archived trips section', async () => { + const activeTrip = buildTrip({ title: 'Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' }); + const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: true }); + + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('archived')) { + return HttpResponse.json({ trips: [archivedTrip] }); + } + return HttpResponse.json({ trips: [activeTrip] }); + }), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument(); + }); + + // Expand + await user.click(screen.getByRole('button', { name: /archived/i })); + await waitFor(() => { + expect(screen.getByText('Old Archived Trip')).toBeInTheDocument(); + }); + + // Collapse + await user.click(screen.getByRole('button', { name: /archived/i })); + await waitFor(() => { + expect(screen.queryByText('Old Archived Trip')).not.toBeInTheDocument(); + }); + }); + }); + + describe('FE-PAGE-DASH-028: Unarchive action restores trip to active list', () => { + it('clicking restore on an archived trip removes it from archived section', async () => { + const activeTrip = buildTrip({ title: 'My Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' }); + const archivedTrip = buildTrip({ title: 'Restored Trip', start_date: '2024-06-01', end_date: '2024-06-07', is_archived: true }); + const restoredTrip = { ...archivedTrip, is_archived: false }; + + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('archived')) { + return HttpResponse.json({ trips: [archivedTrip] }); + } + return HttpResponse.json({ trips: [activeTrip] }); + }), + http.put('/api/trips/:id', async ({ request }) => { + const body = await request.json() as Record; + if (body.is_archived === false) { + return HttpResponse.json({ trip: restoredTrip }); + } + return HttpResponse.json({ trip: archivedTrip }); + }), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /archived/i })); + + await waitFor(() => { + expect(screen.getByText('Restored Trip')).toBeInTheDocument(); + }); + + const restoreBtn = screen.getByRole('button', { name: /restore/i }); + await user.click(restoreBtn); + + // After restore, the archived section should disappear (no archived trips left) + await waitFor(() => { + expect(screen.queryByRole('button', { name: /archived/i })).not.toBeInTheDocument(); + }); + }); + }); + + describe('FE-PAGE-DASH-029: Copy trip action creates a duplicate', () => { + it('clicking copy on a spotlight card duplicates the trip', async () => { + server.use( + http.post('/api/trips/:id/copy', async () => { + const trip = buildTrip({ title: 'Paris Adventure (Copy)', start_date: '2026-07-01', end_date: '2026-07-10' }); + return HttpResponse.json({ trip }); + }), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); + }); + + // Find copy buttons (may appear in mobile + desktop) + const copyButtons = screen.getAllByRole('button', { name: /copy/i }); + expect(copyButtons.length).toBeGreaterThan(0); + await user.click(copyButtons[0]); + + await waitFor(() => { + expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0); + }); + }); + }); + + describe('FE-PAGE-DASH-030: Empty state renders create button', () => { + it('shows empty state with create button when no trips exist', async () => { + server.use( + http.get('/api/trips', () => { + return HttpResponse.json({ trips: [] }); + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/no trips yet/i)).toBeInTheDocument(); + }); + + // Empty state should show a descriptive text and a create button + const createButtons = screen.getAllByRole('button'); + const createBtn = createButtons.find(btn => btn.textContent?.toLowerCase().includes('trip')); + expect(createBtn).toBeDefined(); + }); + }); + + describe('FE-PAGE-DASH-031: SpotlightCard shows stats for ongoing trip', () => { + it('renders duration stat and places/buddies stats for a live trip', async () => { + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + const inFiveDays = new Date(Date.now() + 5 * 86400000).toISOString().split('T')[0]; + + const ongoingTrip = buildTrip({ + title: 'Live Adventure', + start_date: yesterday, + end_date: inFiveDays, + place_count: 5, + shared_count: 2, + }); + + server.use( + http.get('/api/trips', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [] }); + return HttpResponse.json({ trips: [ongoingTrip] }); + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Live Adventure').length).toBeGreaterThan(0); + }); + + // Stats section: places count "5" and buddies count "2" appear + await waitFor(() => { + expect(screen.getAllByText('5').length).toBeGreaterThan(0); + expect(screen.getAllByText('2').length).toBeGreaterThan(0); + }); + + // Duration stat label + expect(screen.getAllByText(/duration/i).length).toBeGreaterThan(0); + // Places stat label + expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0); + }); + }); + + describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => { + it('renders without error when dark_mode is set to auto', async () => { + // Seed settings with dark_mode = 'auto' to exercise the matchMedia branch + const { useSettingsStore } = await import('../store/settingsStore'); + seedStore(useSettingsStore, { + settings: { + map_tile_url: '', + default_lat: 48.8566, + default_lng: 2.3522, + default_zoom: 10, + dark_mode: 'auto', + default_currency: 'USD', + language: 'en', + temperature_unit: 'fahrenheit', + time_format: '12h', + show_place_description: false, + route_calculation: false, + blur_booking_codes: false, + dashboard_currency: 'on', + dashboard_timezone: 'on', + }, + updateSetting: vi.fn(), + } as any); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0); + }); + + // Page renders successfully with dark_mode = 'auto' + expect(screen.getByText(/my trips/i)).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx new file mode 100644 index 00000000..d27d0ad8 --- /dev/null +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -0,0 +1,3712 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup } from '../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../tests/helpers/msw/server'; +import { resetAllStores, seedStore } from '../../tests/helpers/store'; +import { buildUser } from '../../tests/helpers/factories'; +import { useAuthStore } from '../store/authStore'; +import { usePermissionsStore } from '../store/permissionsStore'; +import { useJourneyStore } from '../store/journeyStore'; +import JourneyDetailPage from './JourneyDetailPage'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: any) =>
{children}
, + TileLayer: () => null, + Marker: ({ children }: any) =>
{children}
, + Popup: ({ children }: any) =>
{children}
, + Polyline: () => null, + useMap: () => ({ fitBounds: vi.fn(), setView: vi.fn() }), +})); + +vi.mock('../components/Layout/Navbar', () => ({ + default: () =>