test: comprehensive Journey test suite — 89.5% new code coverage

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)
This commit is contained in:
Maurice
2026-04-12 01:19:53 +02:00
parent 2d9f545c57
commit de157cb87b
21 changed files with 8943 additions and 16 deletions
+347 -14
View File
@@ -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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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<string, unknown>;
if (body.is_archived === false) {
return HttpResponse.json({ trip: restoredTrip });
}
return HttpResponse.json({ trip: archivedTrip });
}),
);
const user = userEvent.setup();
render(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
});
// Page renders successfully with dark_mode = 'auto'
expect(screen.getByText(/my trips/i)).toBeInTheDocument();
});
});
});
File diff suppressed because it is too large Load Diff
+457
View File
@@ -0,0 +1,457 @@
// FE-PAGE-JOURNEY-001 to FE-PAGE-JOURNEY-010
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor } 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 { useAddonStore } from '../store/addonStore';
import { usePermissionsStore } from '../store/permissionsStore';
import JourneyPage from './JourneyPage';
// ── Mocks ────────────────────────────────────────────────────────────────────
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../components/Layout/Navbar', () => ({
default: () => <nav data-testid="navbar" />,
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
let _seq = 500;
function nextId(): number {
return ++_seq;
}
function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
const id = (overrides.id as number) ?? nextId();
return {
id,
user_id: 1,
title: `Journey ${id}`,
subtitle: null,
cover_gradient: null,
cover_image: null,
status: 'draft' as const,
entry_count: 0,
photo_count: 0,
city_count: 0,
created_at: Date.now(),
updated_at: Date.now(),
...overrides,
};
}
function seedDefaults() {
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useAddonStore, {
addons: [{ id: 'journey', type: 'global', enabled: true }],
} as any);
seedStore(usePermissionsStore, { level: 'owner' } as any);
}
function setupDefaultHandlers(journeys: ReturnType<typeof buildJourneyListItem>[] = []) {
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({ journeys })
),
http.get('/api/journeys/suggestions', () =>
HttpResponse.json({ trips: [] })
),
http.get('/api/journeys/available-trips', () =>
HttpResponse.json({ trips: [] })
),
);
}
// ── Setup ────────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
mockNavigate.mockReset();
resetAllStores();
seedDefaults();
setupDefaultHandlers();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-001
it('FE-PAGE-JOURNEY-001: renders without crashing', async () => {
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByTestId('navbar')).toBeInTheDocument();
});
});
// FE-PAGE-JOURNEY-002
it('FE-PAGE-JOURNEY-002: shows loading state', async () => {
server.use(
http.get('/api/journeys', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
return HttpResponse.json({ journeys: [] });
}),
);
render(<JourneyPage />);
// The spinner has animate-spin class while loading with no journeys
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-003
it('FE-PAGE-JOURNEY-003: shows empty state when no journeys', async () => {
setupDefaultHandlers([]);
render(<JourneyPage />);
await waitFor(() => {
// Grid renders with only the create card (the dashed-border button)
// The "0 journeys" counter is shown
expect(screen.getByText(/0/)).toBeInTheDocument();
});
});
// FE-PAGE-JOURNEY-004
it('FE-PAGE-JOURNEY-004: shows journey cards when journeys exist', async () => {
const j1 = buildJourneyListItem({ id: 1, title: 'Summer in Italy' });
const j2 = buildJourneyListItem({ id: 2, title: 'Winter in Japan' });
setupDefaultHandlers([j1, j2]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Summer in Italy')).toBeInTheDocument();
expect(screen.getByText('Winter in Japan')).toBeInTheDocument();
});
});
// FE-PAGE-JOURNEY-005
it('FE-PAGE-JOURNEY-005: create journey button exists', async () => {
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getAllByText(/Create Journey|Create a new Journey/i).length).toBeGreaterThan(0);
});
});
// FE-PAGE-JOURNEY-006
it('FE-PAGE-JOURNEY-006: create journey dialog opens on click', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/journeys/available-trips', () =>
HttpResponse.json({ trips: [] })
),
);
render(<JourneyPage />);
await waitFor(() => {
// Wait for page to finish loading
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
});
// Find and click a create button (mobile or desktop)
const createButtons = screen.getAllByText(/Create Journey|Create a new Journey/i);
await user.click(createButtons[0]);
// Modal should now show the journey name input
await waitFor(() => {
expect(screen.getByPlaceholderText(/Southeast Asia 2026/i)).toBeInTheDocument();
});
});
// FE-PAGE-JOURNEY-007
it('FE-PAGE-JOURNEY-007: shows suggestion card for recently ended trips', async () => {
const suggestion = {
id: 99,
title: 'Paris Adventure',
start_date: '2026-03-01',
end_date: '2026-04-01',
place_count: 5,
};
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({ journeys: [] })
),
http.get('/api/journeys/suggestions', () =>
HttpResponse.json({ trips: [suggestion] })
),
);
render(<JourneyPage />);
await waitFor(() => {
// The suggestion banner shows the trip title embedded via dangerouslySetInnerHTML
// The translation key is journey.frontpage.suggestionText with {title}
// Look for the suggestion label
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
});
});
// FE-PAGE-JOURNEY-008
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
setupDefaultHandlers([active, other]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Active Trip')).toBeInTheDocument();
});
// Active journey section label
expect(screen.getByText(/Active Journey/i)).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-009
it('FE-PAGE-JOURNEY-009: dismiss suggestion removes the banner', async () => {
const user = userEvent.setup();
const suggestion = {
id: 77,
title: 'Tokyo Trip',
start_date: '2026-03-01',
end_date: '2026-04-01',
place_count: 3,
};
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({ journeys: [] })
),
http.get('/api/journeys/suggestions', () =>
HttpResponse.json({ trips: [suggestion] })
),
);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
});
// Click dismiss
await user.click(screen.getByText(/Dismiss/i));
await waitFor(() => {
expect(screen.queryByText(/Trip just ended/i)).not.toBeInTheDocument();
});
});
// FE-PAGE-JOURNEY-010
it('FE-PAGE-JOURNEY-010: shows journey count in header', async () => {
const j1 = buildJourneyListItem({ id: 1, title: 'Trip A' });
const j2 = buildJourneyListItem({ id: 2, title: 'Trip B' });
const j3 = buildJourneyListItem({ id: 3, title: 'Trip C' });
setupDefaultHandlers([j1, j2, j3]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Trip A')).toBeInTheDocument();
});
// The count "3 journeys" text is displayed
expect(screen.getByText(/3 journeys/i)).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-011
it('FE-PAGE-JOURNEY-011: clicking a journey card navigates to detail page', async () => {
const user = userEvent.setup();
const j1 = buildJourneyListItem({ id: 42, title: 'Morocco Road Trip' });
setupDefaultHandlers([j1]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Morocco Road Trip')).toBeInTheDocument();
});
await user.click(screen.getByText('Morocco Road Trip'));
expect(mockNavigate).toHaveBeenCalledWith('/journey/42');
});
// FE-PAGE-JOURNEY-012
it('FE-PAGE-JOURNEY-012: create journey form submission navigates to new journey', async () => {
const user = userEvent.setup();
const createdJourney = { id: 99, user_id: 1, title: 'My New Journey', subtitle: null, cover_gradient: null, cover_image: null, status: 'draft', created_at: Date.now(), updated_at: Date.now() };
server.use(
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: [] })),
http.get('/api/journeys/available-trips', () =>
HttpResponse.json({ trips: [
{ id: 5, title: 'Thailand 2026', start_date: '2026-05-01', end_date: '2026-05-14', place_count: 8 },
] })
),
http.post('/api/journeys', () => HttpResponse.json(createdJourney)),
);
render(<JourneyPage />);
await waitFor(() => {
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
});
// Open the create modal
const createButtons = screen.getAllByText(/Create Journey/i);
await user.click(createButtons[0]);
// Fill name
await waitFor(() => {
expect(screen.getByPlaceholderText(/Southeast Asia 2026/i)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(/Southeast Asia 2026/i), 'My New Journey');
// Select a trip
await waitFor(() => {
expect(screen.getByText('Thailand 2026')).toBeInTheDocument();
});
await user.click(screen.getByText('Thailand 2026'));
// The modal footer has a Create/Create Journey button — find it by its disabled-capable parent
// The footer buttons live inside the border-t div at the bottom of the modal
const footerDiv = document.querySelector('.border-t.border-zinc-200');
const footerButtons = footerDiv?.querySelectorAll('button');
// The last button in the footer is the submit button
const submitBtn = footerButtons ? footerButtons[footerButtons.length - 1] : null;
expect(submitBtn).toBeTruthy();
await user.click(submitBtn!);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/journey/99');
});
});
// FE-PAGE-JOURNEY-013
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
const j1 = buildJourneyListItem({
id: 20,
title: 'Stats Journey',
entry_count: 12,
photo_count: 47,
city_count: 5,
});
setupDefaultHandlers([j1]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
});
// The card renders entry_count, photo_count, city_count values
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('47')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-014
it('FE-PAGE-JOURNEY-014: journey card shows draft status badge', async () => {
const j1 = buildJourneyListItem({ id: 30, title: 'Draft Journey', status: 'draft' });
setupDefaultHandlers([j1]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Draft Journey')).toBeInTheDocument();
});
// Draft badge rendered
expect(screen.getByText('Draft')).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-015
it('FE-PAGE-JOURNEY-015: timeAgo renders "just now" for recent updates', async () => {
const active = buildJourneyListItem({
id: 40,
title: 'Recent Active',
status: 'active',
updated_at: Date.now() - 60000, // 1 minute ago
});
setupDefaultHandlers([active]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Recent Active')).toBeInTheDocument();
});
// timeAgo should show "just now" for < 1 hour
expect(screen.getByText(/Updated just now/i)).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-016
it('FE-PAGE-JOURNEY-016: timeAgo renders hours ago', async () => {
const active = buildJourneyListItem({
id: 41,
title: 'Hours Active',
status: 'active',
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
});
setupDefaultHandlers([active]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Hours Active')).toBeInTheDocument();
});
// timeAgo shows "{count}h ago"
expect(screen.getByText(/Updated 3h ago/i)).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-017
it('FE-PAGE-JOURNEY-017: timeAgo renders days ago', async () => {
const active = buildJourneyListItem({
id: 42,
title: 'Days Active',
status: 'active',
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
});
setupDefaultHandlers([active]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Days Active')).toBeInTheDocument();
});
// timeAgo shows "{count}d ago"
expect(screen.getByText(/Updated 5d ago/i)).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-018
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Writing Journey')).toBeInTheDocument();
});
expect(screen.getByText('Continue writing')).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-019
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Live Journey')).toBeInTheDocument();
});
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.getByText('Synced')).toBeInTheDocument();
});
// FE-PAGE-JOURNEY-020
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
const user = userEvent.setup();
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText('Clickable Hero')).toBeInTheDocument();
});
// Click the hero card title
await user.click(screen.getByText('Clickable Hero'));
expect(mockNavigate).toHaveBeenCalledWith('/journey/60');
});
});
+499
View File
@@ -0,0 +1,499 @@
// 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) => <div data-testid="map-container">{children}</div>,
TileLayer: () => null,
Marker: ({ children }: any) => <div>{children}</div>,
Popup: ({ children }: any) => <div>{children}</div>,
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(() => '<svg></svg>'),
}));
// Mock JourneyMap since it uses vanilla Leaflet (L.map) which requires a real DOM
vi.mock('../components/Journey/JourneyMap', () => ({
default: ({ entries }: any) => <div data-testid="journey-map">Map with {entries?.length || 0} entries</div>,
}));
vi.mock('../components/Journey/JournalBody', () => ({
default: ({ text }: { text: string }) => <div data-testid="journal-body">{text}</div>,
}));
vi.mock('../components/Journey/PhotoLightbox', () => ({
default: ({ photos, onClose }: any) => (
<div data-testid="photo-lightbox">
<span>{photos.length} photos</span>
<button onClick={onClose}>Close</button>
</div>
),
}));
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, 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,
},
stats: {
entries: 2,
photos: 1,
cities: 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();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('JourneyPublicPage', () => {
it('FE-PAGE-PUBLICJOURNEY-001: renders without crashing', () => {
setupSuccess();
render(<JourneyPublicPage />);
expect(document.body).toBeInTheDocument();
});
it('FE-PAGE-PUBLICJOURNEY-002: shows journey title after loading', async () => {
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
});
it('FE-PAGE-PUBLICJOURNEY-003: shows 404 for invalid/missing token', async () => {
setup404();
render(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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 tab switches view', async () => {
setupSuccess();
render(<JourneyPublicPage />);
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),
);
expect(mapBtn).toBeDefined();
if (mapBtn) {
fireEvent.click(mapBtn);
// After clicking map tab, the timeline entries should no longer be visible
// and the map view content should be rendered (even if JourneyMap errors internally
// due to jsdom limitations, the tab state switches)
await waitFor(() => {
// Shibuya Crossing (timeline-only) should not appear once map is active
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
});
}
});
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
setupSuccess();
render(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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: tab switching from timeline to map shows map component', async () => {
const user = userEvent.setup();
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
const mapBtn = screen.getAllByRole('button').find(
btn => btn.textContent && /map/i.test(btn.textContent),
);
expect(mapBtn).toBeDefined();
await user.click(mapBtn!);
await waitFor(() => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
});
// Map receives entries with lat/lng
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(<JourneyPublicPage />);
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, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' },
{ id: 201, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' },
{ id: 202, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
],
},
],
stats: { entries: 1, photos: 3, cities: 0 },
};
server.use(
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(richData)),
);
render(<JourneyPublicPage />);
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, cities: 7 },
};
server.use(
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
);
render(<JourneyPublicPage />);
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-016
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup();
setupSuccess();
render(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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);
});
});
});