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('