test: finish the qol sweep follow-up, one footer on the public journey, patched image deps

The public journey page drew the old "Powered by" block above the shared
frame's footer, so the credit showed twice; only the shared footer is left.
Its tests read the new frame: the title in the hero, the counts in the hero
pills, the loading skeleton and the cover as an image.

The shared trip tests look for markers inside the map, since the unplanned
card names the same places. The place form clicks the search button by name,
now that a picked place adds the nearby button to that row. The journey
editors send the photo order as one PUT, so their tests follow that request,
and a refused order rolls back as a whole. Payments carry their note, and a
saved mobile entry carries its draft flag.

nodemailer goes to 10.0.13 and brace-expansion to 2.1.7 for the advisories
Docker Scout reports (CVE-2026-102276, CVE-2026-102278, GHSA-v53p-9fqp-m79j).
This commit is contained in:
Maurice
2026-10-01 09:17:18 +02:00
parent d6435572b7
commit e24da67218
10 changed files with 122 additions and 157 deletions
@@ -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 () => {
@@ -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)));
};
+36 -46
View File
@@ -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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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(<JourneyPublicPage />);
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
-29
View File
@@ -1,6 +1,4 @@
import {
BookOpen,
Camera,
Clock,
Cloud,
CloudLightning,
@@ -694,33 +692,6 @@ export default function JourneyPublicPage() {
</div>
)}
{/* Powered by */}
<div className="flex flex-col items-center gap-2 py-8">
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '8px 16px',
borderRadius: 20,
background: 'white',
border: '1px solid #e5e7eb',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}
>
<img src="/icons/icon.svg" alt="TREK" width={18} height={18} style={{ borderRadius: 4 }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af' }}>
{t('journey.public.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong>
</span>
</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: '#d1d5db' }}>
Made with <span style={{ color: '#ef4444' }}>♥</span> by Maurice ·{' '}
<a href="https://github.com/liketrek/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>
GitHub
</a>
</div>
</div>
<SharedFooter />
{/* Lightbox */}
@@ -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', () => {
+13 -10
View File
@@ -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());
});
});
@@ -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 () => {
@@ -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 } },
})
})
+8 -8
View File
@@ -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",
+1 -1
View File
@@ -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",