mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
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:
@@ -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(<JournalBody text="Hello traveller" />);
|
||||
expect(screen.getByText('Hello traveller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-002: renders bold markdown as <strong>', () => {
|
||||
const { container } = render(<JournalBody text="This is **bold** text" />);
|
||||
const strong = container.querySelector('strong');
|
||||
expect(strong).toBeInTheDocument();
|
||||
expect(strong!.textContent).toBe('bold');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => {
|
||||
render(<JournalBody text="[Visit](https://example.com)" />);
|
||||
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(<JournalBody text="## Section Title" />);
|
||||
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(<JournalBody text="" />);
|
||||
expect(container.querySelector('.journal-body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
|
||||
);
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// 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<JourneyMapHandle>();
|
||||
render(
|
||||
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// 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('<svg');
|
||||
expect(firstCall.html).toContain('</svg>');
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithMood} />
|
||||
);
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={threeEntries} />
|
||||
);
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
|
||||
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(
|
||||
<JourneyMap checkins={[]} entries={singleEntry} />
|
||||
);
|
||||
// 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(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// 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('−');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
describe('MarkdownToolbar', () => {
|
||||
let onUpdate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdate = vi.fn();
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||
const ref = createTextareaRef();
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => {
|
||||
const ref = createTextareaRef();
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Heading'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('## my title');
|
||||
});
|
||||
});
|
||||
@@ -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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-002: shows photo image', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
// 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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
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(<PhotoLightbox photos={[]} onClose={onClose} />);
|
||||
// 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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('')).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('');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('react-router-dom')>('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(<BottomNav />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
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(<BottomNav />);
|
||||
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(<BottomNav />);
|
||||
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(<BottomNav />);
|
||||
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(<BottomNav />);
|
||||
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(<BottomNav />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(<MobileTopHeader title="Journeys" />);
|
||||
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(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
|
||||
expect(screen.getByText('3 trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
|
||||
const { container } = render(<MobileTopHeader title="Journeys" />);
|
||||
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
|
||||
expect(subtitleEl).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
|
||||
render(
|
||||
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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) => `<p>${str}</p>`,
|
||||
},
|
||||
}));
|
||||
|
||||
import { downloadJourneyBookPDF } from './JourneyBookPDF';
|
||||
import type { JourneyDetail } from '../../store/journeyStore';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildJourney(overrides: Partial<JourneyDetail> = {}): 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<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { useJourneyStore } from './journeyStore';
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||
|
||||
const initialState = useJourneyStore.getState();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _seq = 100;
|
||||
function nextId(): number {
|
||||
return ++_seq;
|
||||
}
|
||||
|
||||
function buildJourney(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,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJourneyDetail(overrides: Record<string, unknown> = {}): JourneyDetail {
|
||||
const base = buildJourney(overrides);
|
||||
return {
|
||||
...base,
|
||||
entries: [],
|
||||
trips: [],
|
||||
contributors: [],
|
||||
stats: { entries: 0, photos: 0, cities: 0 },
|
||||
...(overrides as any),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEntry(overrides: Record<string, unknown> = {}): JourneyEntry {
|
||||
const id = (overrides.id as number) ?? nextId();
|
||||
return {
|
||||
id,
|
||||
journey_id: 1,
|
||||
source_trip_id: null,
|
||||
source_place_id: null,
|
||||
source_trip_name: null,
|
||||
author_id: 1,
|
||||
type: 'entry',
|
||||
title: `Entry ${id}`,
|
||||
story: null,
|
||||
entry_date: '2026-04-01',
|
||||
entry_time: null,
|
||||
location_name: null,
|
||||
location_lat: null,
|
||||
location_lng: null,
|
||||
mood: null,
|
||||
weather: null,
|
||||
tags: [],
|
||||
pros_cons: null,
|
||||
visibility: 'private',
|
||||
sort_order: 0,
|
||||
photos: [],
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
} as JourneyEntry;
|
||||
}
|
||||
|
||||
function buildPhoto(overrides: Record<string, unknown> = {}): JourneyPhoto {
|
||||
const id = (overrides.id as number) ?? nextId();
|
||||
return {
|
||||
id,
|
||||
entry_id: 1,
|
||||
provider: 'local',
|
||||
asset_id: null,
|
||||
owner_id: null,
|
||||
file_path: `/uploads/photo_${id}.jpg`,
|
||||
thumbnail_path: null,
|
||||
caption: null,
|
||||
sort_order: 0,
|
||||
width: null,
|
||||
height: null,
|
||||
shared: 0,
|
||||
created_at: Date.now(),
|
||||
...overrides,
|
||||
} as JourneyPhoto;
|
||||
}
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
useJourneyStore.setState(initialState, true);
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('journeyStore', () => {
|
||||
// ── loadJourneys ─────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-001: loadJourneys populates store', async () => {
|
||||
const j1 = buildJourney({ id: 1 });
|
||||
const j2 = buildJourney({ id: 2 });
|
||||
server.use(
|
||||
http.get('/api/journeys', () =>
|
||||
HttpResponse.json({ journeys: [j1, j2] })
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().loadJourneys();
|
||||
expect(useJourneyStore.getState().journeys).toHaveLength(2);
|
||||
expect(useJourneyStore.getState().journeys[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-002: loadJourneys sets loading false on error', async () => {
|
||||
server.use(
|
||||
http.get('/api/journeys', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
await expect(useJourneyStore.getState().loadJourneys()).rejects.toThrow();
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── loadJourney ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-003: loadJourney sets current journey', async () => {
|
||||
const detail = buildJourneyDetail({ id: 5 });
|
||||
server.use(
|
||||
http.get('/api/journeys/5', () =>
|
||||
HttpResponse.json(detail)
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().loadJourney(5);
|
||||
expect(useJourneyStore.getState().current?.id).toBe(5);
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-004: loadJourney sets loading false on error', async () => {
|
||||
server.use(
|
||||
http.get('/api/journeys/999', () =>
|
||||
HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||
)
|
||||
);
|
||||
await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow();
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── createJourney ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-005: createJourney adds to store and returns journey', async () => {
|
||||
const created = buildJourney({ id: 10, title: 'My Trip' });
|
||||
server.use(
|
||||
http.post('/api/journeys', () =>
|
||||
HttpResponse.json(created)
|
||||
)
|
||||
);
|
||||
const result = await useJourneyStore.getState().createJourney({ title: 'My Trip' });
|
||||
expect(result.id).toBe(10);
|
||||
expect(useJourneyStore.getState().journeys).toContainEqual(created);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-006: createJourney throws on API error', async () => {
|
||||
server.use(
|
||||
http.post('/api/journeys', () =>
|
||||
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
|
||||
)
|
||||
);
|
||||
await expect(useJourneyStore.getState().createJourney({ title: '' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── updateJourney ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-007: updateJourney updates in list and current', async () => {
|
||||
const existing = buildJourney({ id: 20, title: 'Old' });
|
||||
const detail = buildJourneyDetail({ id: 20, title: 'Old' });
|
||||
useJourneyStore.setState({ journeys: [existing], current: detail });
|
||||
|
||||
server.use(
|
||||
http.patch('/api/journeys/20', () =>
|
||||
HttpResponse.json({ title: 'New' })
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().updateJourney(20, { title: 'New' });
|
||||
expect(useJourneyStore.getState().journeys[0].title).toBe('New');
|
||||
expect(useJourneyStore.getState().current?.title).toBe('New');
|
||||
});
|
||||
|
||||
// ── deleteJourney ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-008: deleteJourney removes from list', async () => {
|
||||
const j1 = buildJourney({ id: 30 });
|
||||
const j2 = buildJourney({ id: 31 });
|
||||
useJourneyStore.setState({ journeys: [j1, j2] });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/journeys/30', () =>
|
||||
HttpResponse.json({})
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().deleteJourney(30);
|
||||
expect(useJourneyStore.getState().journeys).toHaveLength(1);
|
||||
expect(useJourneyStore.getState().journeys[0].id).toBe(31);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-009: deleteJourney clears current if matching', async () => {
|
||||
const detail = buildJourneyDetail({ id: 40 });
|
||||
useJourneyStore.setState({ journeys: [buildJourney({ id: 40 })], current: detail });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/journeys/40', () =>
|
||||
HttpResponse.json({})
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().deleteJourney(40);
|
||||
expect(useJourneyStore.getState().current).toBeNull();
|
||||
});
|
||||
|
||||
// ── createEntry ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-010: createEntry adds entry to current', async () => {
|
||||
const detail = buildJourneyDetail({ id: 50 });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const newEntry = buildEntry({ id: 60, journey_id: 50 });
|
||||
server.use(
|
||||
http.post('/api/journeys/50/entries', () =>
|
||||
HttpResponse.json(newEntry)
|
||||
)
|
||||
);
|
||||
const result = await useJourneyStore.getState().createEntry(50, { title: 'Day 1' });
|
||||
expect(result.id).toBe(60);
|
||||
expect(useJourneyStore.getState().current?.entries).toHaveLength(1);
|
||||
expect(useJourneyStore.getState().current?.entries[0].id).toBe(60);
|
||||
});
|
||||
|
||||
// ── updateEntry ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-011: updateEntry updates entry in current', async () => {
|
||||
const entry = buildEntry({ id: 70, title: 'Old Title' });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.patch('/api/journeys/entries/70', () =>
|
||||
HttpResponse.json({ title: 'New Title' })
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().updateEntry(70, { title: 'New Title' });
|
||||
expect(useJourneyStore.getState().current?.entries[0].title).toBe('New Title');
|
||||
});
|
||||
|
||||
// ── deleteEntry ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-012: deleteEntry removes entry from current', async () => {
|
||||
const entry1 = buildEntry({ id: 80 });
|
||||
const entry2 = buildEntry({ id: 81 });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry1, entry2] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/journeys/entries/80', () =>
|
||||
HttpResponse.json({})
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().deleteEntry(80);
|
||||
expect(useJourneyStore.getState().current?.entries).toHaveLength(1);
|
||||
expect(useJourneyStore.getState().current?.entries[0].id).toBe(81);
|
||||
});
|
||||
|
||||
// ── uploadPhotos ─────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-013: uploadPhotos appends photos to entry', async () => {
|
||||
const existingPhoto = buildPhoto({ id: 90, entry_id: 100 });
|
||||
const entry = buildEntry({ id: 100, photos: [existingPhoto] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () =>
|
||||
HttpResponse.json({ photos: [newPhoto] })
|
||||
)
|
||||
);
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(91);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-014: deletePhoto removes photo from entry', async () => {
|
||||
const photo1 = buildPhoto({ id: 200, entry_id: 100 });
|
||||
const photo2 = buildPhoto({ id: 201, entry_id: 100 });
|
||||
const entry = buildEntry({ id: 100, photos: [photo1, photo2] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.delete('/api/journeys/photos/200', () =>
|
||||
HttpResponse.json({})
|
||||
)
|
||||
);
|
||||
await useJourneyStore.getState().deletePhoto(200);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(1);
|
||||
expect(storedEntry?.photos[0].id).toBe(201);
|
||||
});
|
||||
|
||||
// ── clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||
useJourneyStore.setState({
|
||||
journeys: [buildJourney()],
|
||||
current: buildJourneyDetail(),
|
||||
loading: true,
|
||||
});
|
||||
useJourneyStore.getState().clear();
|
||||
expect(useJourneyStore.getState().journeys).toEqual([]);
|
||||
expect(useJourneyStore.getState().current).toBeNull();
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useVacayStore } from '../../src/store/vacayStore';
|
||||
import { useAddonStore } from '../../src/store/addonStore';
|
||||
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
|
||||
import { usePermissionsStore } from '../../src/store/permissionsStore';
|
||||
// Journey store is reset individually in journey tests to avoid circular import issues
|
||||
|
||||
// Capture initial states at import time (before any test modifies them)
|
||||
const initialAuthState = useAuthStore.getState();
|
||||
@@ -14,7 +15,6 @@ const initialVacayState = useVacayStore.getState();
|
||||
const initialAddonState = useAddonStore.getState();
|
||||
const initialNotifState = useInAppNotificationStore.getState();
|
||||
const initialPermsState = usePermissionsStore.getState();
|
||||
|
||||
export function resetAllStores(): void {
|
||||
useAuthStore.setState(initialAuthState, true);
|
||||
useTripStore.setState(initialTripState, true);
|
||||
|
||||
@@ -638,3 +638,93 @@ export function createTag(
|
||||
const result = db.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, name, color);
|
||||
return db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid) as { id: number; user_id: number; name: string; color: string };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Journeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _journeySeq = 0;
|
||||
|
||||
export interface TestJourney {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
status: string;
|
||||
cover_image: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export function createJourney(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
overrides: Partial<{ title: string; subtitle: string; status: string }> = {}
|
||||
): TestJourney {
|
||||
_journeySeq++;
|
||||
const title = overrides.title ?? `Test Journey ${_journeySeq}`;
|
||||
const now = Date.now();
|
||||
const result = db.prepare(
|
||||
'INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(userId, title, overrides.subtitle ?? null, overrides.status ?? 'active', now, now);
|
||||
|
||||
const journeyId = result.lastInsertRowid as number;
|
||||
|
||||
// Auto-add owner as contributor
|
||||
db.prepare(
|
||||
"INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'owner', ?)"
|
||||
).run(journeyId, userId, now);
|
||||
|
||||
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as TestJourney;
|
||||
}
|
||||
|
||||
export interface TestJourneyEntry {
|
||||
id: number;
|
||||
journey_id: number;
|
||||
author_id: number;
|
||||
type: string;
|
||||
entry_date: string;
|
||||
title: string | null;
|
||||
story: string | null;
|
||||
}
|
||||
|
||||
export function createJourneyEntry(
|
||||
db: Database.Database,
|
||||
journeyId: number,
|
||||
authorId: number,
|
||||
overrides: Partial<{ type: string; entry_date: string; title: string; story: string; location_name: string; mood: string; weather: string }> = {}
|
||||
): TestJourneyEntry {
|
||||
const now = Date.now();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, title, story, location_name, mood, weather, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'private', 0, ?, ?)
|
||||
`).run(
|
||||
journeyId, authorId,
|
||||
overrides.type ?? 'entry',
|
||||
overrides.entry_date ?? '2026-01-15',
|
||||
overrides.title ?? null,
|
||||
overrides.story ?? null,
|
||||
overrides.location_name ?? null,
|
||||
overrides.mood ?? null,
|
||||
overrides.weather ?? null,
|
||||
now, now
|
||||
);
|
||||
return db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(result.lastInsertRowid) as TestJourneyEntry;
|
||||
}
|
||||
|
||||
export function addJourneyContributor(
|
||||
db: Database.Database,
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
role: 'editor' | 'viewer' = 'editor'
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
|
||||
).run(journeyId, userId, role, Date.now());
|
||||
}
|
||||
|
||||
export function linkTripToJourney(db: Database.Database, journeyId: number, tripId: number): void {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, linked_at) VALUES (?, ?, ?)'
|
||||
).run(journeyId, tripId, Date.now());
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ const RESET_TABLES = [
|
||||
'share_tokens',
|
||||
'trip_members',
|
||||
'trips',
|
||||
// Journey
|
||||
'journey_share_tokens',
|
||||
'journey_photos',
|
||||
'journey_entries',
|
||||
'journey_contributors',
|
||||
'journey_trips',
|
||||
'journeys',
|
||||
// Vacay
|
||||
'vacay_entries',
|
||||
'vacay_company_holidays',
|
||||
|
||||
@@ -0,0 +1,955 @@
|
||||
/**
|
||||
* Journey API integration tests.
|
||||
* Covers JOURNEY-INT-001 through JOURNEY-INT-020.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({
|
||||
broadcast: vi.fn(),
|
||||
broadcastToUser: vi.fn(),
|
||||
setupWebSocket: vi.fn(),
|
||||
getOnlineUserIds: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock('../../src/services/memories/immichService', () => ({
|
||||
uploadToImmich: vi.fn(async () => null),
|
||||
getImmichCredentials: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
createTrip,
|
||||
createJourney,
|
||||
createJourneyEntry,
|
||||
addJourneyContributor,
|
||||
} from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
// Enable the journey addon
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)"
|
||||
).run();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List journeys (JOURNEY-INT-001, 002)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('List journeys', () => {
|
||||
it('JOURNEY-INT-001 — GET /api/journeys returns 200 with empty list initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.journeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-002 — GET /api/journeys returns 401 without auth', async () => {
|
||||
const res = await request(app).get('/api/journeys');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create journey (JOURNEY-INT-003)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create journey', () => {
|
||||
it('JOURNEY-INT-003 — POST /api/journeys creates a journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Japan 2026', subtitle: 'Cherry blossom season' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.title).toBe('Japan 2026');
|
||||
expect(res.body.subtitle).toBe('Cherry blossom season');
|
||||
expect(res.body.id).toBeDefined();
|
||||
|
||||
// Should appear in listing now
|
||||
const list = await request(app)
|
||||
.get('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.journeys).toHaveLength(1);
|
||||
expect(list.body.journeys[0].title).toBe('Japan 2026');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Get journey detail (JOURNEY-INT-004, 005)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Get journey detail', () => {
|
||||
it('JOURNEY-INT-004 — GET /api/journeys/:id returns full detail', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, { title: 'Iceland' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Iceland');
|
||||
expect(res.body.entries).toBeDefined();
|
||||
expect(res.body.contributors).toBeDefined();
|
||||
expect(res.body.stats).toBeDefined();
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-005 — GET /api/journeys/:id returns 404 for non-existent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update journey (JOURNEY-INT-006)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update journey', () => {
|
||||
it('JOURNEY-INT-006 — PATCH /api/journeys/:id updates journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, { title: 'Draft' });
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Updated Title', subtitle: 'New subtitle' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Updated Title');
|
||||
expect(res.body.subtitle).toBe('New subtitle');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete journey (JOURNEY-INT-007)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete journey', () => {
|
||||
it('JOURNEY-INT-007 — DELETE /api/journeys/:id deletes journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Journey trips (JOURNEY-INT-008, 009)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Journey trips', () => {
|
||||
it('JOURNEY-INT-008 — POST /api/journeys/:id/trips links a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris', start_date: '2026-06-01', end_date: '2026-06-05' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/trips`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_id: trip.id });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify trip appears in journey detail
|
||||
const detail = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(detail.body.trips).toHaveLength(1);
|
||||
expect(detail.body.trips[0].trip_id).toBe(trip.id);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-009 — DELETE /api/journeys/:id/trips/:tripId unlinks trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: '2026-07-01', end_date: '2026-07-03' });
|
||||
|
||||
// Link via API first (avoids factory column mismatch)
|
||||
await request(app)
|
||||
.post(`/api/journeys/${journey.id}/trips`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_id: trip.id });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Journey entries (JOURNEY-INT-010, 011, 012)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Journey entries', () => {
|
||||
it('JOURNEY-INT-010 — POST /api/journeys/:id/entries creates an entry', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'First day in Tokyo',
|
||||
story: 'Arrived at Narita airport.',
|
||||
entry_date: '2026-04-01',
|
||||
entry_time: '14:00',
|
||||
location_name: 'Narita Airport',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.title).toBe('First day in Tokyo');
|
||||
expect(res.body.entry_date).toBe('2026-04-01');
|
||||
expect(res.body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-011 — PATCH /api/journeys/entries/:id updates entry', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
||||
title: 'Original',
|
||||
entry_date: '2026-04-01',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/journeys/entries/${entry.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Updated entry title', story: 'Now with a story' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Updated entry title');
|
||||
expect(res.body.story).toBe('Now with a story');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-012 — DELETE /api/journeys/entries/:id deletes entry', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
||||
title: 'To delete',
|
||||
entry_date: '2026-04-02',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/entries/${entry.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Contributors (JOURNEY-INT-013, 014)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Journey contributors', () => {
|
||||
it('JOURNEY-INT-013 — POST /api/journeys/:id/contributors adds a contributor', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: contributor } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/contributors`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: contributor.id, role: 'editor' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Contributor should now be able to access the journey
|
||||
const detail = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(contributor.id));
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.title).toBeDefined();
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-014 — DELETE /api/journeys/:id/contributors/:userId removes contributor', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: contributor } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, contributor.id, 'editor');
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}/contributors/${contributor.id}`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Contributor should no longer access the journey
|
||||
const detail = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(contributor.id));
|
||||
expect(detail.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Share link (JOURNEY-INT-015, 016, 017)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Journey share link', () => {
|
||||
it('JOURNEY-INT-015 — GET /api/journeys/:id/share-link returns null initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.link).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-016 — POST /api/journeys/:id/share-link creates a share link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_timeline: true, share_gallery: true, share_map: false });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeDefined();
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.created).toBe(true);
|
||||
|
||||
// GET should now return the link
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.body.link).not.toBeNull();
|
||||
expect(get.body.link.token).toBe(res.body.token);
|
||||
expect(get.body.link.share_timeline).toBe(true);
|
||||
expect(get.body.link.share_gallery).toBe(true);
|
||||
expect(get.body.link.share_map).toBe(false);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-017 — DELETE /api/journeys/:id/share-link deletes the share link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
// Create first
|
||||
await request(app)
|
||||
.post(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_timeline: true, share_gallery: true, share_map: true });
|
||||
|
||||
// Delete
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.body.link).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Permission checks (JOURNEY-INT-018, 019)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Journey permissions', () => {
|
||||
it('JOURNEY-INT-018 — contributor (viewer) can read but non-member cannot', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: viewer } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id, { title: 'Private Journey' });
|
||||
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
||||
|
||||
// Viewer can read
|
||||
const viewerRes = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(viewer.id));
|
||||
expect(viewerRes.status).toBe(200);
|
||||
expect(viewerRes.body.title).toBe('Private Journey');
|
||||
|
||||
// Outsider cannot
|
||||
const outsiderRes = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
expect(outsiderRes.status).toBe(404);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-019 — non-owner cannot delete a journey', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: editor } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
||||
|
||||
// Editor can read
|
||||
const readRes = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(editor.id));
|
||||
expect(readRes.status).toBe(200);
|
||||
|
||||
// Editor cannot delete — only owner can
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(editor.id));
|
||||
expect(delRes.status).toBe(404);
|
||||
|
||||
// Journey still exists
|
||||
const verify = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
expect(verify.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suggestions (JOURNEY-INT-020)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Journey suggestions', () => {
|
||||
it('JOURNEY-INT-020 — GET /api/journeys/suggestions returns trip suggestions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// Create a recent trip so it shows up in suggestions
|
||||
createTrip(testDb, user.id, {
|
||||
title: 'Recent Trip',
|
||||
start_date: '2026-03-01',
|
||||
end_date: '2026-03-05',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys/suggestions')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trips).toBeDefined();
|
||||
expect(Array.isArray(res.body.trips)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Available trips (JOURNEY-INT-021)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Available trips', () => {
|
||||
it('JOURNEY-INT-021 — GET /api/journeys/available-trips returns user trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'My Trip', start_date: '2026-05-01', end_date: '2026-05-03' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys/available-trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trips).toBeDefined();
|
||||
expect(Array.isArray(res.body.trips)).toBe(true);
|
||||
expect(res.body.trips.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.trips[0].title).toBe('My Trip');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create journey validation (JOURNEY-INT-022)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Create journey validation', () => {
|
||||
it('JOURNEY-INT-022 — POST /api/journeys returns 400 without title', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ subtitle: 'No title provided' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Title is required');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-023 — POST /api/journeys returns 400 for blank title', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: ' ' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Title is required');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider photos (JOURNEY-INT-024, 025, 026)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Provider photos', () => {
|
||||
it('JOURNEY-INT-024 — POST /api/journeys/entries/:id/provider-photos creates provider photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'abc-123', caption: 'Nice view' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.provider).toBe('immich');
|
||||
expect(res.body.asset_id).toBe('abc-123');
|
||||
expect(res.body.caption).toBe('Nice view');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-025 — POST /api/journeys/entries/:id/provider-photos returns 400 without required fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ caption: 'Missing provider and asset_id' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('provider and asset_id required');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-026 — POST /api/journeys/entries/:id/provider-photos returns 403 for viewer', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: viewer } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
||||
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(viewer.id))
|
||||
.send({ provider: 'immich', asset_id: 'xyz-456' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Link photo to entry (JOURNEY-INT-027, 028)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Link photo to entry', () => {
|
||||
it('JOURNEY-INT-027 — POST /api/journeys/entries/:id/link-photo moves photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
const entry2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-02' });
|
||||
|
||||
// Add a provider photo to entry1
|
||||
const photoRes = await request(app)
|
||||
.post(`/api/journeys/entries/${entry1.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'link-test-asset' });
|
||||
|
||||
// Link it to entry2
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry2.id}/link-photo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ photo_id: photoRes.body.id });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.entry_id).toBe(entry2.id);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-028 — POST /api/journeys/entries/:id/link-photo returns 400 without photo_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/link-photo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('photo_id required');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update photo (JOURNEY-INT-029, 030)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update photo', () => {
|
||||
it('JOURNEY-INT-029 — PATCH /api/journeys/photos/:id updates caption and sort_order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
// Add a provider photo first
|
||||
const photoRes = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'update-test-asset' });
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/journeys/photos/${photoRes.body.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ caption: 'Updated caption', sort_order: 5 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caption).toBe('Updated caption');
|
||||
expect(res.body.sort_order).toBe(5);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-030 — PATCH /api/journeys/photos/:id returns 404 for non-existent photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/journeys/photos/99999')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ caption: 'No photo here' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete photo via route (JOURNEY-INT-031, 032)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete photo (route)', () => {
|
||||
it('JOURNEY-INT-031 — DELETE /api/journeys/photos/:id deletes photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const photoRes = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'del-test-asset' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/photos/${photoRes.body.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-032 — DELETE /api/journeys/photos/:id returns 404 for non-existent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/journeys/photos/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Journey entries sub-routes (JOURNEY-INT-033, 034)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Journey entries sub-routes', () => {
|
||||
it('JOURNEY-INT-033 — GET /api/journeys/:id/entries returns entries list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
createJourneyEntry(testDb, journey.id, user.id, { title: 'Day 1', entry_date: '2026-04-01' });
|
||||
createJourneyEntry(testDb, journey.id, user.id, { title: 'Day 2', entry_date: '2026-04-02' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-034 — GET /api/journeys/:id/entries returns 404 for inaccessible journey', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-035 — POST /api/journeys/:id/entries returns 400 without entry_date', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Missing date' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('entry_date is required');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update entry edge cases (JOURNEY-INT-036, 037)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Update entry edge cases', () => {
|
||||
it('JOURNEY-INT-036 — PATCH /api/journeys/entries/:id returns 404 for non-existent entry', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/journeys/entries/99999')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Does not exist' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-037 — DELETE /api/journeys/entries/:id returns 404 for non-existent entry', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/journeys/entries/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Trip link validation (JOURNEY-INT-038, 039)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Trip link validation', () => {
|
||||
it('JOURNEY-INT-038 — POST /api/journeys/:id/trips returns 400 without trip_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/trips`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('trip_id required');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-039 — DELETE /api/journeys/:id/trips/:tripId returns 403 for non-owner', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: editor } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Link Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
|
||||
|
||||
await request(app)
|
||||
.post(`/api/journeys/${journey.id}/trips`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ trip_id: trip.id });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(editor.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Contributor routes (JOURNEY-INT-040, 041, 042)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Contributor route validation', () => {
|
||||
it('JOURNEY-INT-040 — POST /api/journeys/:id/contributors returns 400 without user_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/contributors`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ role: 'editor' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('user_id required');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-041 — PATCH /api/journeys/:id/contributors/:userId updates role', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: contrib } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, contrib.id, 'viewer');
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/journeys/${journey.id}/contributors/${contrib.id}`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ role: 'editor' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-042 — PATCH /api/journeys/:id/contributors/:userId returns 403 for non-owner', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: editor } = createUser(testDb);
|
||||
const { user: target } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
||||
addJourneyContributor(testDb, journey.id, target.id, 'viewer');
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/journeys/${journey.id}/contributors/${target.id}`)
|
||||
.set('Cookie', authCookie(editor.id))
|
||||
.send({ role: 'editor' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Share link with update (JOURNEY-INT-043, 044)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Share link update', () => {
|
||||
it('JOURNEY-INT-043 — POST /api/journeys/:id/share-link updates existing share link permissions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
// Create initial share link
|
||||
const create = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_timeline: true, share_gallery: true, share_map: true });
|
||||
|
||||
expect(create.body.created).toBe(true);
|
||||
|
||||
// Update permissions (same endpoint creates or updates)
|
||||
const update = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_timeline: true, share_gallery: false, share_map: false });
|
||||
|
||||
expect(update.status).toBe(200);
|
||||
expect(update.body.token).toBe(create.body.token);
|
||||
expect(update.body.created).toBe(false);
|
||||
|
||||
// Verify updated permissions
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(get.body.link.share_timeline).toBe(true);
|
||||
expect(get.body.link.share_gallery).toBe(false);
|
||||
expect(get.body.link.share_map).toBe(false);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-044 — journey PATCH /:id can update status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ status: 'archived' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('archived');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Photo upload without files (JOURNEY-INT-045)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Photo upload validation', () => {
|
||||
it('JOURNEY-INT-045 — POST /api/journeys/entries/:id/photos returns 400 without files', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/photos`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('No files uploaded');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Unit tests for journeyShareService — JOURNEY-SHARE-001 through JOURNEY-SHARE-018.
|
||||
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// -- DB setup -----------------------------------------------------------------
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createJourney, createJourneyEntry } from '../../helpers/factories';
|
||||
import {
|
||||
createOrUpdateJourneyShareLink,
|
||||
getJourneyShareLink,
|
||||
deleteJourneyShareLink,
|
||||
validateShareTokenForPhoto,
|
||||
validateShareTokenForAsset,
|
||||
getPublicJourney,
|
||||
} from '../../../src/services/journeyShareService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
/** Insert a journey_photos row and return its id. */
|
||||
function insertJourneyPhoto(
|
||||
entryId: number,
|
||||
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
||||
): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, file_path, caption, sort_order, created_at, asset_id, owner_id)
|
||||
VALUES (?, ?, NULL, 0, ?, ?, ?)
|
||||
`).run(entryId, opts.filePath ?? '/photos/test.jpg', Date.now(), opts.assetId ?? null, opts.ownerId ?? null);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
// -- Tests --------------------------------------------------------------------
|
||||
|
||||
describe('createOrUpdateJourneyShareLink', () => {
|
||||
it('JOURNEY-SHARE-001: creates a new share link with default permissions', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const result = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.token).toBeTruthy();
|
||||
expect(result.token.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-002: creates a share link with custom permissions', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
createOrUpdateJourneyShareLink(journey.id, user.id, {
|
||||
share_timeline: true,
|
||||
share_gallery: false,
|
||||
share_map: false,
|
||||
});
|
||||
|
||||
const link = getJourneyShareLink(journey.id);
|
||||
expect(link).not.toBeNull();
|
||||
expect(link!.share_timeline).toBe(true);
|
||||
expect(link!.share_gallery).toBe(false);
|
||||
expect(link!.share_map).toBe(false);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-003: updates permissions on existing link without regenerating token', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const first = createOrUpdateJourneyShareLink(journey.id, user.id, {
|
||||
share_timeline: true,
|
||||
share_gallery: true,
|
||||
share_map: true,
|
||||
});
|
||||
const second = createOrUpdateJourneyShareLink(journey.id, user.id, {
|
||||
share_timeline: true,
|
||||
share_gallery: false,
|
||||
share_map: false,
|
||||
});
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.token).toBe(first.token);
|
||||
|
||||
const link = getJourneyShareLink(journey.id);
|
||||
expect(link!.share_gallery).toBe(false);
|
||||
expect(link!.share_map).toBe(false);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-004: different journeys get different tokens', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const j1 = createJourney(testDb, user.id);
|
||||
const j2 = createJourney(testDb, user.id);
|
||||
|
||||
const r1 = createOrUpdateJourneyShareLink(j1.id, user.id, {});
|
||||
const r2 = createOrUpdateJourneyShareLink(j2.id, user.id, {});
|
||||
|
||||
expect(r1.token).not.toBe(r2.token);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJourneyShareLink', () => {
|
||||
it('JOURNEY-SHARE-005: returns null when no share link exists', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const result = getJourneyShareLink(journey.id);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-006: returns share link info when it exists', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
createOrUpdateJourneyShareLink(journey.id, user.id, {
|
||||
share_timeline: true,
|
||||
share_gallery: false,
|
||||
share_map: true,
|
||||
});
|
||||
|
||||
const result = getJourneyShareLink(journey.id);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.token).toBeTruthy();
|
||||
expect(result!.share_timeline).toBe(true);
|
||||
expect(result!.share_gallery).toBe(false);
|
||||
expect(result!.share_map).toBe(true);
|
||||
expect(result!.created_at).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteJourneyShareLink', () => {
|
||||
it('JOURNEY-SHARE-007: removes an existing share link', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
deleteJourneyShareLink(journey.id);
|
||||
|
||||
expect(getJourneyShareLink(journey.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
expect(() => deleteJourneyShareLink(journey.id)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateShareTokenForPhoto', () => {
|
||||
it('JOURNEY-SHARE-009: returns journeyId and ownerId for valid token + photo', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
||||
const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id });
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
const result = validateShareTokenForPhoto(token, photoId);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.journeyId).toBe(journey.id);
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-010: returns null for invalid token', () => {
|
||||
const result = validateShareTokenForPhoto('nonexistent-token', 1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-011: returns null when photo does not belong to shared journey', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey1 = createJourney(testDb, user.id);
|
||||
const journey2 = createJourney(testDb, user.id);
|
||||
const entry2 = createJourneyEntry(testDb, journey2.id, user.id);
|
||||
const photoId = insertJourneyPhoto(entry2.id);
|
||||
const { token } = createOrUpdateJourneyShareLink(journey1.id, user.id, {});
|
||||
|
||||
const result = validateShareTokenForPhoto(token, photoId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-012: falls back to journey owner_id when photo has no owner_id', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
||||
const photoId = insertJourneyPhoto(entry.id, { ownerId: undefined });
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
const result = validateShareTokenForPhoto(token, photoId);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateShareTokenForAsset', () => {
|
||||
it('JOURNEY-SHARE-013: returns ownerId when asset belongs to shared journey', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
||||
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id });
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
const result = validateShareTokenForAsset(token, 'immich-asset-123');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-014: returns null for invalid token', () => {
|
||||
const result = validateShareTokenForAsset('bad-token', 'some-asset');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-015: falls back to journey owner when asset not found in photos', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
const result = validateShareTokenForAsset(token, 'nonexistent-asset');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicJourney', () => {
|
||||
it('JOURNEY-SHARE-016: returns null for invalid token', () => {
|
||||
const result = getPublicJourney('invalid-token');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-017: returns journey data with entries, stats, and permissions', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, {
|
||||
title: 'Japan 2026',
|
||||
subtitle: 'Cherry blossom season',
|
||||
});
|
||||
const entry1 = createJourneyEntry(testDb, journey.id, user.id, {
|
||||
type: 'entry',
|
||||
title: 'Arrived in Tokyo',
|
||||
entry_date: '2026-03-20',
|
||||
location_name: 'Tokyo',
|
||||
});
|
||||
createJourneyEntry(testDb, journey.id, user.id, {
|
||||
type: 'entry',
|
||||
title: 'Kyoto Day Trip',
|
||||
entry_date: '2026-03-22',
|
||||
location_name: 'Kyoto',
|
||||
});
|
||||
insertJourneyPhoto(entry1.id);
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {
|
||||
share_timeline: true,
|
||||
share_gallery: true,
|
||||
share_map: false,
|
||||
});
|
||||
|
||||
const result = getPublicJourney(token);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.journey.title).toBe('Japan 2026');
|
||||
expect(result!.journey.subtitle).toBe('Cherry blossom season');
|
||||
expect(result!.entries).toHaveLength(2);
|
||||
expect(result!.stats.entries).toBe(2);
|
||||
expect(result!.stats.photos).toBe(1);
|
||||
expect(result!.stats.cities).toBe(2);
|
||||
expect(result!.permissions.share_timeline).toBe(true);
|
||||
expect(result!.permissions.share_gallery).toBe(true);
|
||||
expect(result!.permissions.share_map).toBe(false);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-018: excludes skeleton entries from public view', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
createJourneyEntry(testDb, journey.id, user.id, {
|
||||
type: 'entry',
|
||||
title: 'Visible Entry',
|
||||
entry_date: '2026-01-10',
|
||||
});
|
||||
createJourneyEntry(testDb, journey.id, user.id, {
|
||||
type: 'skeleton',
|
||||
title: 'Skeleton Entry',
|
||||
entry_date: '2026-01-11',
|
||||
});
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
const result = getPublicJourney(token);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.entries).toHaveLength(1);
|
||||
expect(result!.entries[0].title).toBe('Visible Entry');
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-019: enriches entries with parsed tags and photos', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
||||
type: 'entry',
|
||||
entry_date: '2026-04-01',
|
||||
});
|
||||
// Set tags on the entry directly
|
||||
testDb.prepare('UPDATE journey_entries SET tags = ? WHERE id = ?')
|
||||
.run(JSON.stringify(['food', 'culture']), entry.id);
|
||||
insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' });
|
||||
insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' });
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
const result = getPublicJourney(token);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const enriched = result!.entries[0];
|
||||
expect(enriched.tags).toEqual(['food', 'culture']);
|
||||
expect(enriched.photos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, { title: 'Empty Journey' });
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
const result = getPublicJourney(token);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.entries).toEqual([]);
|
||||
expect(result!.stats.entries).toBe(0);
|
||||
expect(result!.stats.photos).toBe(0);
|
||||
expect(result!.stats.cities).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -24,4 +24,5 @@ sonar.coverage.exclusions=\
|
||||
server/src/db/migrations.ts,\
|
||||
server/src/scheduler.ts,\
|
||||
client/src/main.tsx,\
|
||||
client/src/types.ts
|
||||
client/src/types.ts,\
|
||||
client/src/components/Memories/MemoriesPanel.tsx
|
||||
|
||||
Reference in New Issue
Block a user