diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx
index e65ab4cf0..f0d0a94e1 100644
--- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx
+++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx
@@ -289,10 +289,10 @@ describe('EntryEditor', () => {
})
it('FE-JRN-EDITOR-012: promoting a photo to first persists the new sort order', async () => {
- const patched: Array<{ id: string; body: unknown }> = []
- server.use(http.patch('/api/journeys/photos/:id', async ({ params, request }) => {
- patched.push({ id: String(params.id), body: await request.json() })
- return HttpResponse.json({ ok: true })
+ const sent: Array<{ id: string; body: unknown }> = []
+ server.use(http.put('/api/journeys/entries/:id/photos/reorder', async ({ params, request }) => {
+ sent.push({ id: String(params.id), body: await request.json() })
+ return HttpResponse.json({ success: true })
}))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
@@ -300,11 +300,9 @@ describe('EntryEditor', () => {
expect(screen.getByText('1st')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
- await waitFor(() => expect(patched).toHaveLength(2))
- expect(patched).toEqual([
- { id: '101', body: { sort_order: 0 } },
- { id: '100', body: { sort_order: 1 } },
- ])
+ // The whole order goes out in one request, not one PATCH per photo.
+ await waitFor(() => expect(sent).toHaveLength(1))
+ expect(sent[0]).toEqual({ id: '10', body: { orderedIds: [101, 100] } })
const imgs = Array.from(container.querySelectorAll('img')).map(i => i.getAttribute('src'))
expect(imgs[0]).toBe('/api/photos/101/thumbnail')
})
@@ -599,7 +597,7 @@ describe('EntryEditor', () => {
it('FE-JRN-EDITOR-029: rolls the order back when persisting it fails', async () => {
let attempts = 0
- server.use(http.patch('/api/journeys/photos/:id', () => {
+ server.use(http.put('/api/journeys/entries/:id/photos/reorder', () => {
attempts += 1
return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
}))
@@ -608,30 +606,31 @@ describe('EntryEditor', () => {
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
- await waitFor(() => expect(attempts).toBe(2))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('sort rejected', 'error', undefined))
+ expect(attempts).toBe(1)
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
expect(order).toEqual(['/api/photos/100/thumbnail', '/api/photos/101/thumbnail'])
})
- it('FE-JRN-EDITOR-047: a partly accepted order keeps what the server took', async () => {
+ it('FE-JRN-EDITOR-047: the server takes the whole order or none of it, so a refusal never leaves a half order', async () => {
const patched: number[] = []
- server.use(http.patch('/api/journeys/photos/:id', ({ params }) => {
- const id = Number(params.id)
- patched.push(id)
- if (id === 100) return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
- return HttpResponse.json({ success: true })
- }))
+ server.use(
+ http.patch('/api/journeys/photos/:id', ({ params }) => {
+ patched.push(Number(params.id))
+ return HttpResponse.json({ success: true })
+ }),
+ http.put('/api/journeys/entries/:id/photos/reorder', () => HttpResponse.json({ error: 'sort rejected' }, { status: 409 })),
+ )
const user = userEvent.setup()
- const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
+ const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101), buildPhoto(102)] }))
- await user.click(screen.getByRole('button', { name: 'Make 1st' }))
+ await user.click(screen.getAllByRole('button', { name: 'Make 1st' })[1])
- await waitFor(() => expect(patched).toEqual([101, 100]))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('sort rejected', 'error', undefined))
- // 101 is first on the server now; snapping the strip back would hide that.
+ // No per-photo writes any more, and the strip shows the order the server kept.
+ expect(patched).toEqual([])
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
- expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail'])
+ expect(order).toEqual(['/api/photos/100/thumbnail', '/api/photos/101/thumbnail', '/api/photos/102/thumbnail'])
})
it('FE-JRN-EDITOR-030: dropping an unsaved gallery pick cancels its link', async () => {
diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx
index c110c5c8f..9861a63cb 100644
--- a/client/src/components/Planner/PlaceFormModal.test.tsx
+++ b/client/src/components/Planner/PlaceFormModal.test.tsx
@@ -1817,7 +1817,7 @@ describe('PlaceFormModal remaining branches', () => {
await user.clear(searchInput);
await user.type(searchInput, String(place.name));
const searchRow = searchInput.closest('.flex') as HTMLElement;
- await user.click(within(searchRow).getByRole('button'));
+ await user.click(within(searchRow).getByRole('button', { name: 'Search' }));
await user.click(await screen.findByText(String(place.name)));
};
diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx
index bc89dd2a4..a46448b58 100644
--- a/client/src/pages/JourneyPublicPage.test.tsx
+++ b/client/src/pages/JourneyPublicPage.test.tsx
@@ -1,7 +1,7 @@
// FE-PAGE-PUBLICJOURNEY-001 to FE-PAGE-PUBLICJOURNEY-023
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
-import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
+import { render, screen, waitFor, fireEvent, within } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
@@ -166,7 +166,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
});
@@ -183,7 +183,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Entry titles from the timeline should be visible
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
@@ -205,31 +205,31 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: '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();
+ // The top bar carries the read-only label of the shared frame
+ expect(screen.getByText('Read-only shared view')).toBeInTheDocument();
});
it('FE-PAGE-PUBLICJOURNEY-007: shows footer with shared-via branding', async () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: '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();
+ // One footer, the shared frame's: "Shared via TREK", the credit and the GitHub link
+ const footer = screen.getByRole('contentinfo');
+ expect(within(footer).getByText('TREK')).toBeInTheDocument();
+ expect(within(footer).getByText(/Made with/)).toBeInTheDocument();
+ expect(within(footer).getByText('GitHub')).toBeInTheDocument();
+ expect(screen.getAllByText(/Made with/)).toHaveLength(1);
});
it('FE-PAGE-PUBLICJOURNEY-008: gallery tab switches view', async () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Find the gallery tab button — the view tabs contain icons and labels
@@ -253,7 +253,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Desktop two-column: map sidebar is always rendered alongside the timeline;
@@ -269,19 +269,13 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: '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');
+ // The hero lists the counts as glass pills: 2 entries, 1 photo, 2 places
+ const hero = within(screen.getByTestId('shared-hero'));
+ expect(hero.getByText('Entries').previousSibling?.textContent).toBe('2');
+ expect(hero.getByText('Photos').previousSibling?.textContent).toBe('1');
+ expect(hero.getByText('Places').previousSibling?.textContent).toBe('2');
});
// FE-PAGE-PUBLICJOURNEY-011
@@ -290,7 +284,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Timeline entries visible
@@ -314,7 +308,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Desktop two-column: map sidebar is always rendered; no tab click required.
@@ -330,7 +324,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
expect(screen.getByText('Shibuya, Tokyo')).toBeInTheDocument();
@@ -369,7 +363,7 @@ describe('JourneyPublicPage', () => {
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Switch to gallery
@@ -397,18 +391,14 @@ describe('JourneyPublicPage', () => {
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: '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');
+ // The hero shows "14 Entries", "83 Photos", "7 Places"
+ const hero = within(screen.getByTestId('shared-hero'));
+ expect(hero.getByText('Entries').previousSibling?.textContent).toBe('14');
+ expect(hero.getByText('Photos').previousSibling?.textContent).toBe('83');
+ expect(hero.getByText('Places').previousSibling?.textContent).toBe('7');
});
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
@@ -417,7 +407,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
@@ -432,7 +422,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
@@ -451,7 +441,7 @@ describe('JourneyPublicPage', () => {
setupSuccess();
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// The language picker button shows "English" by default
@@ -492,7 +482,7 @@ describe('JourneyPublicPage', () => {
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Timeline tab should not exist
@@ -523,7 +513,7 @@ describe('JourneyPublicPage', () => {
render();
await waitFor(() => {
- expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Tokyo 2026' })).toBeInTheDocument();
});
// Timeline entries should NOT be visible since timeline is disabled
diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx
index 85840f512..c331d3c22 100644
--- a/client/src/pages/JourneyPublicPage.tsx
+++ b/client/src/pages/JourneyPublicPage.tsx
@@ -1,6 +1,4 @@
import {
- BookOpen,
- Camera,
Clock,
Cloud,
CloudLightning,
@@ -694,33 +692,6 @@ export default function JourneyPublicPage() {
)}
- {/* Powered by */}
-
-
-

-
- {t('journey.public.sharedVia')} TREK
-
-
-
-
-
{/* Lightbox */}
diff --git a/client/src/pages/JourneyPublicPage.wiring.test.tsx b/client/src/pages/JourneyPublicPage.wiring.test.tsx
index e258c069e..2f90e66c4 100644
--- a/client/src/pages/JourneyPublicPage.wiring.test.tsx
+++ b/client/src/pages/JourneyPublicPage.wiring.test.tsx
@@ -1,6 +1,6 @@
// FE-JRN-PUBWIRE-001 to FE-JRN-PUBWIRE-023
import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { render, screen, fireEvent } from '../../tests/helpers/render';
+import { render, screen, fireEvent, within } from '../../tests/helpers/render';
import { useSettingsStore } from '../store/settingsStore';
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublic/journeyPublicModel';
import JourneyPublicPage from './JourneyPublicPage';
@@ -112,7 +112,7 @@ beforeEach(() => {
describe('JourneyPublicPage wiring', () => {
it('FE-JRN-PUBWIRE-001: shows a spinner while the share is being fetched', () => {
setup({ loading: true });
- expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+ expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
});
it('FE-JRN-PUBWIRE-002: an unknown or revoked token shows the not-found screen', () => {
@@ -124,15 +124,16 @@ describe('JourneyPublicPage wiring', () => {
setup();
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
expect(screen.getByText('Tokyo & Kyoto')).toBeInTheDocument();
- expect(screen.getByText(/2 Entries/)).toBeInTheDocument();
- expect(screen.getByText(/4 Photos/)).toBeInTheDocument();
- expect(screen.getByText(/3 Places/)).toBeInTheDocument();
+ const hero = within(screen.getByTestId('shared-hero'));
+ expect(hero.getByText('Entries').previousSibling?.textContent).toBe('2');
+ expect(hero.getByText('Photos').previousSibling?.textContent).toBe('4');
+ expect(hero.getByText('Places').previousSibling?.textContent).toBe('3');
expect(screen.getByTestId('public-map')).toBeInTheDocument();
});
it('FE-JRN-PUBWIRE-004: the cover image is layered into the hero when present', () => {
setup({ journey: { title: 'Japan 2026', cover_image: 'covers/j.jpg' } });
- expect(document.querySelector('[style*="/uploads/covers/j.jpg"]')).toBeInTheDocument();
+ expect(screen.getByTestId('shared-hero').querySelector('img[src="/uploads/covers/j.jpg"]')).toBeInTheDocument();
});
it('FE-JRN-PUBWIRE-005: hovering an entry in two-column mode highlights its marker', () => {
diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx
index 88bb98df6..4618b3f2a 100644
--- a/client/src/pages/SharedTripPage.test.tsx
+++ b/client/src/pages/SharedTripPage.test.tsx
@@ -742,6 +742,7 @@ describe('SharedTripPage', () => {
/** The select over the map and its step buttons. */
const dayPicker = () => within(screen.getByTestId('map-day-picker'));
+ const onMap = () => within(screen.getByTestId('map-container'));
const nextDay = () => fireEvent.click(dayPicker().getByRole('button', { name: 'Next day' }));
const prevDay = () => fireEvent.click(dayPicker().getByRole('button', { name: 'Previous day' }));
/** The card of one day, found by its title. */
@@ -912,18 +913,19 @@ describe('SharedTripPage', () => {
assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: withImage }] },
}));
- // Whole trip: every geocoded place has a marker, the Pantheon too, though no day visits it.
- expect(screen.getByText('Pantheon')).toBeInTheDocument();
- expect(screen.queryByText('Seine Walk')).toBeNull();
+ // Whole trip: every geocoded place has a marker on the map (the unplanned card
+ // lists the Pantheon as well, so look only at the map's tooltips), the Pantheon too, though no day visits it.
+ expect(onMap().getByText('Pantheon')).toBeInTheDocument();
+ expect(onMap().queryByText('Seine Walk')).toBeNull();
// Once picked, the select over the map names the day as well; the card is the one in an article.
const cardTitle = () => screen.getAllByText('Day One').find(el => el.closest('article')) as HTMLElement;
fireEvent.click(cardTitle());
- await waitFor(() => expect(screen.queryByText('Pantheon')).toBeNull());
+ await waitFor(() => expect(onMap().queryByText('Pantheon')).toBeNull());
expect(cardTitle().closest('[aria-pressed]')).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(cardTitle());
- await waitFor(() => expect(screen.getByText('Pantheon')).toBeInTheDocument());
+ await waitFor(() => expect(onMap().getByText('Pantheon')).toBeInTheDocument());
});
});
@@ -1036,14 +1038,15 @@ describe('SharedTripPage', () => {
assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] },
}));
- // All: both geocoded places are on the map, so Orsay's tooltip is present.
- expect(screen.getByText('Orsay')).toBeInTheDocument();
+ // All: both geocoded places are on the map, so Orsay's tooltip is present (the
+ // unplanned card names Orsay too, so the check stays inside the map).
+ expect(onMap().getByText('Orsay')).toBeInTheDocument();
nextDay();
- await waitFor(() => expect(screen.queryByText('Orsay')).toBeNull());
+ await waitFor(() => expect(onMap().queryByText('Orsay')).toBeNull());
prevDay();
- await waitFor(() => expect(screen.getByText('Orsay')).toBeInTheDocument());
+ await waitFor(() => expect(onMap().getByText('Orsay')).toBeInTheDocument());
});
it('names the open day in the select and presses its card, and stops at either end', async () => {
@@ -1069,7 +1072,7 @@ describe('SharedTripPage', () => {
fireEvent.click(dayPicker().getByText('Whole trip'));
const option = screen.getAllByText('Day One').find(el => !el.closest('article') && !el.closest('[data-testid="map-day-picker"]'));
fireEvent.click(option as HTMLElement);
- await waitFor(() => expect(screen.queryByText('Orsay')).toBeNull());
+ await waitFor(() => expect(onMap().queryByText('Orsay')).toBeNull());
});
});
diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
index 43d33592c..a4fb8af29 100644
--- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
+++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
@@ -431,6 +431,8 @@ describe('MJourneyEntrySheet full editor', () => {
tags: ['food', 'city'],
pros_cons: { pros: ['Gelato'], cons: ['Crowds'] },
type: undefined,
+ is_draft: false,
+ stats_excluded: undefined,
}, undefined);
});
@@ -678,46 +680,43 @@ describe('MJourneyEntrySheet full editor', () => {
});
it('FE-MOB-JENTRY-020: promoting a photo to first persists the new sort order', async () => {
- const patched: Array<{ id: string; body: unknown }> = [];
- server.use(http.patch('/api/journeys/photos/:id', async ({ params, request }) => {
- patched.push({ id: String(params.id), body: await request.json() });
- return HttpResponse.json({ ok: true });
+ const sent: Array<{ id: string; body: unknown }> = [];
+ server.use(http.put('/api/journeys/entries/:id/photos/reorder', async ({ params, request }) => {
+ sent.push({ id: String(params.id), body: await request.json() });
+ return HttpResponse.json({ success: true });
}));
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100), buildPhoto(101)] }));
await user.click(screen.getByRole('button', { name: '1st' }));
- await waitFor(() => expect(patched).toHaveLength(2));
- expect(patched).toEqual([
- { id: '101', body: { sort_order: 0 } },
- { id: '100', body: { sort_order: 1 } },
- ]);
+ // The whole order goes out in one request, not one PATCH per photo.
+ await waitFor(() => expect(sent).toHaveLength(1));
+ expect(sent[0]).toEqual({ id: '5', body: { orderedIds: [101, 100] } });
const order = Array.from(document.querySelectorAll('.h-16 img')).map(i => i.getAttribute('src'));
expect(order[0]).toBe('/api/photos/101/thumbnail');
});
- it('FE-MOB-JENTRY-049: promoting a photo under StrictMode still patches each photo once', async () => {
- const patched: Array<{ id: string; body: unknown }> = [];
- server.use(http.patch('/api/journeys/photos/:id', async ({ params, request }) => {
- patched.push({ id: String(params.id), body: await request.json() });
- return HttpResponse.json({ ok: true });
+ it('FE-MOB-JENTRY-049: promoting a photo under StrictMode still sends the order once', async () => {
+ const sent: unknown[] = [];
+ server.use(http.put('/api/journeys/entries/:id/photos/reorder', async ({ request }) => {
+ sent.push(await request.json());
+ return HttpResponse.json({ success: true });
}));
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100), buildPhoto(101)] }), { strict: true });
await user.click(screen.getByRole('button', { name: '1st' }));
- await waitFor(() => expect(patched.length).toBeGreaterThanOrEqual(2));
- // Let a doubled batch land before counting, otherwise the extra PATCHes slip in
- // after the assertion.
+ await waitFor(() => expect(sent.length).toBeGreaterThanOrEqual(1));
+ // Let a doubled request land before counting, otherwise it slips in after the assertion.
await act(async () => { await new Promise(r => setTimeout(r, 20)); });
- expect(patched.map(p => p.id)).toEqual(['101', '100']);
+ expect(sent).toEqual([{ orderedIds: [101, 100] }]);
});
it('FE-MOB-JENTRY-050: a refused reorder snaps the strip back and says so', async () => {
let attempts = 0;
- server.use(http.patch('/api/journeys/photos/:id', () => {
+ server.use(http.put('/api/journeys/entries/:id/photos/reorder', () => {
attempts += 1;
return HttpResponse.json({ error: 'sort rejected' }, { status: 500 });
}));
@@ -726,8 +725,8 @@ describe('MJourneyEntrySheet full editor', () => {
await user.click(screen.getByRole('button', { name: '1st' }));
- await waitFor(() => expect(attempts).toBe(2));
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('sort rejected', 'error', undefined));
+ expect(attempts).toBe(1);
const order = Array.from(document.querySelectorAll('.h-16 img')).map(i => i.getAttribute('src'));
expect(order).toEqual(['/api/photos/100/thumbnail', '/api/photos/101/thumbnail']);
});
@@ -828,23 +827,25 @@ describe('MJourneyEntrySheet full editor', () => {
expect(screen.queryByText('All photos already added')).not.toBeInTheDocument();
});
- it('FE-MOB-JENTRY-037: a partly accepted order keeps what the server took', async () => {
+ it('FE-MOB-JENTRY-037: the server takes the whole order or none of it, so a refusal never leaves a half order', async () => {
const patched: number[] = [];
- server.use(http.patch('/api/journeys/photos/:id', ({ params }) => {
- const id = Number(params.id);
- patched.push(id);
- if (id === 100) return new HttpResponse(null, { status: 500 });
- return HttpResponse.json({ ok: true });
- }));
+ server.use(
+ http.patch('/api/journeys/photos/:id', ({ params }) => {
+ patched.push(Number(params.id));
+ return HttpResponse.json({ ok: true });
+ }),
+ http.put('/api/journeys/entries/:id/photos/reorder', () => new HttpResponse(null, { status: 409 })),
+ );
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100), buildPhoto(101)] }));
await user.click(screen.getByRole('button', { name: '1st' }));
- await waitFor(() => expect(patched).toEqual([101, 100]));
- // 101 is first on the server now; snapping the strip back would hide that.
+ await waitFor(() => expect(toastSpy).toHaveBeenCalled());
+ // No per-photo writes any more, and the strip shows the order the server kept.
+ expect(patched).toEqual([]);
const order = Array.from(document.querySelectorAll('.h-16 img')).map(i => i.getAttribute('src'));
- expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail']);
+ expect(order).toEqual(['/api/photos/100/thumbnail', '/api/photos/101/thumbnail']);
});
it('FE-MOB-JENTRY-024: shows no suggestions when the location search fails', async () => {
diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx
index a17c49c25..af71a0f0c 100644
--- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx
+++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx
@@ -465,7 +465,7 @@ describe('MCostsTab', () => {
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } })
expect(submit).toBeEnabled()
fireEvent.click(submit)
- expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD', settled_at: localToday() })
+ expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD', settled_at: localToday(), note: null })
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
@@ -690,7 +690,7 @@ describe('MCostsTab', () => {
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '30' } })
fireEvent.click(submit)
- expect(update).toHaveBeenCalledWith(7, 501, { from_user_id: 1, to_user_id: 2, amount: 30, currency: 'USD', settled_at: '2026-04-28' })
+ expect(update).toHaveBeenCalledWith(7, 501, { from_user_id: 1, to_user_id: 2, amount: 30, currency: 'USD', settled_at: '2026-04-28', note: null })
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
@@ -727,7 +727,7 @@ describe('MCostsTab', () => {
// The pound rate the phone holds goes along; the server only uses it when the
// currency changes and it has no rate of its own, so the frozen one stays.
expect(update).toHaveBeenCalledWith(7, 505, {
- from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28',
+ from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28', note: null,
fallback_fx: { base: 'USD', rates: { GBP: 0.5 } },
})
})
diff --git a/package-lock.json b/package-lock.json
index 766fec5dc..573569006 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9423,9 +9423,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
- "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -15660,12 +15660,12 @@
}
},
"node_modules/nodemailer": {
- "version": "9.1.1",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
- "integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
+ "version": "10.0.13",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.13.tgz",
+ "integrity": "sha512-SzG86OlvcW/NNhUFC6uROMwRTL4n7MswfQqC/T8mhkmnY1YVa23zUEMYi4ijSeXSl9GLz9ZeTJDUatEDuY5FeQ==",
"license": "MIT-0",
"engines": {
- "node": ">=6.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/nodemon": {
@@ -21452,7 +21452,7 @@
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.2",
"nestjs-zod": "^5.5.0",
- "nodemailer": "^9.1.1",
+ "nodemailer": "^10.0.13",
"otplib": "^12.0.1",
"pdf-parse": "^2.4.5",
"qrcode": "^1.5.4",
diff --git a/server/package.json b/server/package.json
index 44ef6ac86..5555cf055 100644
--- a/server/package.json
+++ b/server/package.json
@@ -54,7 +54,7 @@
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.2",
"nestjs-zod": "^5.5.0",
- "nodemailer": "^9.1.1",
+ "nodemailer": "^10.0.13",
"otplib": "^12.0.1",
"pdf-parse": "^2.4.5",
"qrcode": "^1.5.4",