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 */} -
-
- TREK - - {t('journey.public.sharedVia')} TREK - -
-
- Made with ♥ by Maurice ·{' '} - - GitHub - -
-
- {/* 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",