// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-010 import { describe, it, expect, beforeEach, vi } from 'vitest'; import React from 'react'; import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render'; import { Routes, Route } from 'react-router-dom'; import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../tests/helpers/store'; import { useSettingsStore } from '../store/settingsStore'; import userEvent from '@testing-library/user-event'; // ── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { ...actual, useParams: () => ({ token: 'test-share-token' }) }; }); 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('leaflet', () => { const L = { divIcon: vi.fn(() => ({})), latLngBounds: vi.fn(() => ({ extend: vi.fn(), isValid: vi.fn(() => true) })), icon: vi.fn(() => ({})), }; return { default: L, ...L }; }); vi.mock('react-dom/server', () => ({ renderToStaticMarkup: vi.fn(() => ''), })); // Mock JourneyMap since it uses vanilla Leaflet (L.map) which requires a real DOM vi.mock('../components/Journey/JourneyMap', () => ({ default: ({ entries }: any) =>
Map with {entries?.length || 0} entries
, })); vi.mock('../components/Journey/JournalBody', () => ({ default: ({ text }: { text: string }) =>
{text}
, })); vi.mock('../components/Journey/PhotoLightbox', () => ({ default: ({ photos, onClose }: any) => (
{photos.length} photos
), })); vi.mock('../components/Journey/MobileMapTimeline', () => ({ default: ({ onEntryClick }: any) => (
), })); const mockIsMobile = { value: false }; vi.mock('../hooks/useIsMobile', () => ({ useIsMobile: () => mockIsMobile.value, })); import JourneyPublicPage from './JourneyPublicPage'; // ── Fixtures ───────────────────────────────────────────────────────────────── const mockJourneyData = { journey: { id: 1, user_id: 1, title: 'Tokyo 2026', subtitle: 'Spring trip to Japan', status: 'active', cover_image: null, }, entries: [ { id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', location_lat: 35.6595, location_lng: 139.7004, mood: 'excited', weather: 'sunny', pros_cons: null, photos: [], }, { id: 11, title: 'Senso-ji Temple', story: 'Beautiful ancient temple.', entry_date: '2026-03-16', entry_time: '10:00', location_name: 'Asakusa, Tokyo', location_lat: 35.7148, location_lng: 139.7967, mood: 'peaceful', weather: 'cloudy', pros_cons: null, photos: [ { id: 100, entry_id: 11, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' }, ], }, ], permissions: { share_timeline: true, share_gallery: true, share_map: true, }, gallery: [ { id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 }, ], stats: { entries: 2, photos: 1, places: 2, }, }; // ── Helpers ────────────────────────────────────────────────────────────────── function setupSuccess() { server.use( http.get('/api/public/journey/test-share-token', () => HttpResponse.json(mockJourneyData), ), ); } function setup404() { server.use( http.get('/api/public/journey/test-share-token', () => new HttpResponse(null, { status: 404 }), ), ); } // ── Setup / teardown ───────────────────────────────────────────────────────── beforeEach(() => { resetAllStores(); vi.clearAllMocks(); mockIsMobile.value = false; }); // ── Tests ──────────────────────────────────────────────────────────────────── describe('JourneyPublicPage', () => { it('FE-PAGE-PUBLICJOURNEY-001: renders without crashing', () => { setupSuccess(); render(); expect(document.body).toBeInTheDocument(); }); it('FE-PAGE-PUBLICJOURNEY-002: shows journey title after loading', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); }); it('FE-PAGE-PUBLICJOURNEY-003: shows 404 for invalid/missing token', async () => { setup404(); render(); await waitFor(() => { // The component shows the notFound heading when fetch errors expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); }); }); it('FE-PAGE-PUBLICJOURNEY-004: timeline tab is the default view', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Entry titles from the timeline should be visible expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); expect(screen.getByText('Senso-ji Temple')).toBeInTheDocument(); }); it('FE-PAGE-PUBLICJOURNEY-005: shows entry cards with titles', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); }); expect(screen.getByText('Senso-ji Temple')).toBeInTheDocument(); // Entry story text should render expect(screen.getByText('The most famous crossing in the world.')).toBeInTheDocument(); }); it('FE-PAGE-PUBLICJOURNEY-006: shows read-only badge text', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // The page renders a t('journey.public.readOnly') div with inline style textTransform: 'uppercase' // The translation key resolves to the English text in the real TranslationProvider const readOnlyEl = document.querySelector('[style*="uppercase"]'); expect(readOnlyEl).toBeInTheDocument(); }); it('FE-PAGE-PUBLICJOURNEY-007: shows footer with shared-via branding', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Footer shows "TREK" brand and "Made with" text expect(screen.getByText('TREK')).toBeInTheDocument(); expect(screen.getByText(/Made with/)).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument(); }); it('FE-PAGE-PUBLICJOURNEY-008: gallery tab switches view', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Find the gallery tab button — the view tabs contain icons and labels const buttons = screen.getAllByRole('button'); const galleryBtn = buttons.find( btn => btn.textContent && /gallery/i.test(btn.textContent), ); expect(galleryBtn).toBeDefined(); if (galleryBtn) { fireEvent.click(galleryBtn); // After switching to gallery, timeline entry titles should no longer be visible // Gallery shows a grid of photos instead await waitFor(() => { const grid = document.querySelector('.grid'); expect(grid).toBeInTheDocument(); }); } }); it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Desktop two-column: map sidebar is always rendered alongside the timeline; // there is no standalone "Map" tab button on desktop. await waitFor(() => { expect(screen.getByTestId('journey-map')).toBeInTheDocument(); }); // Timeline entries remain visible (two-column shows both simultaneously) expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); }); it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Stats pill: "2 Entries", "1 Photos", "2 Places" // The numbers appear alongside translation keys inside a pill with blur(4px) backdrop // Use querySelectorAll to find the right one (not the language picker which also has backdrop-filter) const allBackdrop = document.querySelectorAll('[style*="backdrop-filter"]'); // The stats pill contains the entry/photo/city counts const statsContainer = Array.from(allBackdrop).find( el => el.textContent && el.textContent.includes('1') && el.children.length > 3, ); expect(statsContainer).toBeDefined(); expect(statsContainer!.textContent).toContain('2'); expect(statsContainer!.textContent).toContain('1'); }); // FE-PAGE-PUBLICJOURNEY-011 it('FE-PAGE-PUBLICJOURNEY-011: tab switching from timeline to gallery hides entry titles', async () => { const user = userEvent.setup(); setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Timeline entries visible expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); // Switch to gallery const galleryBtn = screen.getAllByRole('button').find( btn => btn.textContent && /gallery/i.test(btn.textContent), ); expect(galleryBtn).toBeDefined(); await user.click(galleryBtn!); // Timeline entries should be gone await waitFor(() => { expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument(); }); }); // FE-PAGE-PUBLICJOURNEY-012 it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Desktop two-column: map sidebar is always rendered; no tab click required. await waitFor(() => { expect(screen.getByTestId('journey-map')).toBeInTheDocument(); }); // Both fixture entries have coordinates → map receives 2 located entries expect(screen.getByTestId('journey-map').textContent).toContain('2'); }); // FE-PAGE-PUBLICJOURNEY-013 it('FE-PAGE-PUBLICJOURNEY-013: entry card renders location name', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); expect(screen.getByText('Shibuya, Tokyo')).toBeInTheDocument(); expect(screen.getByText('Asakusa, Tokyo')).toBeInTheDocument(); }); // FE-PAGE-PUBLICJOURNEY-014 it('FE-PAGE-PUBLICJOURNEY-014: photo grid renders in gallery view', async () => { const user = userEvent.setup(); const richData = { ...mockJourneyData, entries: [ { id: 20, title: 'Photo Entry', story: null, entry_date: '2026-03-15', entry_time: null, location_name: null, location_lat: null, location_lng: null, mood: null, weather: null, pros_cons: null, photos: [ { id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' }, { id: 201, entry_id: 20, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' }, { id: 202, entry_id: 20, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' }, ], }, ], gallery: [ { id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 }, { id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 }, { id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 }, ], stats: { entries: 1, photos: 3, places: 0 }, }; server.use( http.get('/api/public/journey/test-share-token', () => HttpResponse.json(richData)), ); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Switch to gallery const galleryBtn = screen.getAllByRole('button').find( btn => btn.textContent && /gallery/i.test(btn.textContent), ); await user.click(galleryBtn!); await waitFor(() => { // Gallery grid: 3 images rendered const images = document.querySelectorAll('.grid img'); expect(images.length).toBe(3); }); }); // FE-PAGE-PUBLICJOURNEY-015 it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => { const customData = { ...mockJourneyData, stats: { entries: 14, photos: 83, places: 7 }, }; server.use( http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)), ); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Stats pill shows "14 Entries", "83 Photos", "7 Places" const allBackdrop = document.querySelectorAll('[style*="backdrop-filter"]'); const statsContainer = Array.from(allBackdrop).find( el => el.textContent && el.textContent.includes('14') && el.textContent.includes('83'), ); expect(statsContainer).toBeDefined(); expect(statsContainer!.textContent).toContain('14'); expect(statsContainer!.textContent).toContain('83'); expect(statsContainer!.textContent).toContain('7'); }); // FE-PAGE-PUBLICJOURNEY-019 — bug #828 it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => { mockIsMobile.value = true; setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); const buttons = screen.getAllByRole('button'); const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim())); expect(mapBtn).toBeUndefined(); }); // FE-PAGE-PUBLICJOURNEY-020 — bug #826 it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => { const user = userEvent.setup(); mockIsMobile.value = true; setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked const openBtn = screen.getByText('Open Entry'); await user.click(openBtn); // MobileEntryView should slide in with the entry title await waitFor(() => { expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); }); }); // FE-PAGE-PUBLICJOURNEY-016 it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { const user = userEvent.setup(); setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // The language picker button shows "English" by default const langButton = screen.getByText('English'); expect(langButton).toBeInTheDocument(); // Open the language picker await user.click(langButton); // Language options should appear await waitFor(() => { expect(screen.getByText('Deutsch')).toBeInTheDocument(); expect(screen.getByText('Español')).toBeInTheDocument(); expect(screen.getByText('Français')).toBeInTheDocument(); }); // Click Deutsch to switch language await user.click(screen.getByText('Deutsch')); // The picker should close and settings store should be updated const settings = useSettingsStore.getState().settings; expect(settings.language).toBe('de'); }); // FE-PAGE-PUBLICJOURNEY-017 it('FE-PAGE-PUBLICJOURNEY-017: restricted tabs — only allowed views appear', async () => { const restrictedData = { ...mockJourneyData, permissions: { share_timeline: false, share_gallery: true, share_map: true, }, }; server.use( http.get('/api/public/journey/test-share-token', () => HttpResponse.json(restrictedData)), ); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Timeline tab should not exist const buttons = screen.getAllByRole('button'); const timelineBtn = buttons.find(btn => btn.textContent && /timeline/i.test(btn.textContent)); expect(timelineBtn).toBeUndefined(); // Gallery and Map tabs should exist const galleryBtn = buttons.find(btn => btn.textContent && /gallery/i.test(btn.textContent)); const mapBtn = buttons.find(btn => btn.textContent && /map/i.test(btn.textContent)); expect(galleryBtn).toBeDefined(); expect(mapBtn).toBeDefined(); }); // FE-PAGE-PUBLICJOURNEY-018 it('FE-PAGE-PUBLICJOURNEY-018: default view set to gallery when timeline not shared', async () => { const restrictedData = { ...mockJourneyData, permissions: { share_timeline: false, share_gallery: true, share_map: true, }, }; server.use( http.get('/api/public/journey/test-share-token', () => HttpResponse.json(restrictedData)), ); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); // Timeline entries should NOT be visible since timeline is disabled // The default view should have switched to gallery expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument(); // Gallery grid should be visible (photos from entries) await waitFor(() => { const images = document.querySelectorAll('.grid img'); expect(images.length).toBeGreaterThan(0); }); }); });