mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the file did not parse and vitest reported it as "no tests". Both guards in it were dead - including the byte-identical egress-policy check the package's own notes call the worst possible bug in that module to lose. No CI job runs the SDK suite, so it landed quietly; the next npm publish would have failed on it, since prepublishOnly runs the tests. Also in the SDK: the dev preview answered trek:geolocation without checking the geolocation:read grant, so a plugin whose manifest is missing the permission works perfectly in dev and is dead once installed. That is the lenient direction, which is the one that ships broken plugins. Client side: the background-task poll never dismissed a 404'd import job and spun forever, "Update All" collapsed its busy flag after the first plugin settled, the lightbox had no cancellation so a slow signed URL rendered the wrong image, the plugin geolocation watch was torn down by any unrelated settings write, manual date entry accepted 31 February, cover previews were never revoked, and check-all issued one request per packing item.
This commit is contained in:
@@ -1862,3 +1862,43 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
|
||||
await waitFor(() => expect(uploaded).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
// FE-W5PLG-030 — Update all drives the same single-slot busy flag as a per-row update,
|
||||
// so the requests have to go out one after another.
|
||||
describe('AdminPluginsPanel — update all', () => {
|
||||
const updatableRow = (over: Record<string, unknown> = {}) => plugin({
|
||||
id: 'a-widget', name: 'Alpha Widget', type: 'widget', version: '1.0.0', operatorEgress: false, ...over,
|
||||
})
|
||||
|
||||
it('FE-W5PLG-030: Update all sends the second update only after the first came back', async () => {
|
||||
const started: string[] = []
|
||||
let release = () => {}
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({
|
||||
enabled: true, devLink: false,
|
||||
plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
|
||||
})),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
|
||||
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
|
||||
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
|
||||
])),
|
||||
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
|
||||
started.push(String(params.id))
|
||||
if (started.length === 1) await gate
|
||||
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /update all/i }))
|
||||
|
||||
await waitFor(() => expect(started).toHaveLength(1))
|
||||
// Give a parallel dispatch every chance to show up before releasing the first.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(started).toEqual(['a-widget'])
|
||||
|
||||
release()
|
||||
await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -641,7 +641,7 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
const runUpdate = (p: PluginRow) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
adminApi.pluginUpdate(p.id)
|
||||
return adminApi.pluginUpdate(p.id)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
@@ -676,6 +676,12 @@ export default function AdminPluginsPanel() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
|
||||
|
||||
// One after another: busy is a single slot, so parallel updates would leave the
|
||||
// other rows clickable mid-install and refresh once per plugin.
|
||||
const updateAll = async () => {
|
||||
for (const p of updatable) await runUpdate(p)
|
||||
}
|
||||
|
||||
// Installed list after search / type / status filters + sort.
|
||||
const shownInstalled = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
@@ -865,7 +871,7 @@ export default function AdminPluginsPanel() {
|
||||
<div className="mx-1.5 sm:mx-3 mb-2 mt-1 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl bg-warning-soft border border-warning/30">
|
||||
<ArrowUpCircle size={16} className="text-warning shrink-0" />
|
||||
<span className="text-xs text-content-secondary">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
|
||||
<button onClick={() => updatable.forEach(runUpdate)}
|
||||
<button onClick={() => void updateAll()}
|
||||
className="ml-auto text-xs font-semibold px-3 py-1.5 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.updateAll')}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-W4BGT-001 to FE-W4BGT-020
|
||||
// FE-W4BGT-001 to FE-W4BGT-022
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { screen, act, waitFor } from '@testing-library/react'
|
||||
import { render, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -185,6 +185,34 @@ describe('BackgroundTasksWidget — rehydrate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — poll backstop', () => {
|
||||
it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => {
|
||||
vi.useFakeTimers()
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus)
|
||||
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
|
||||
.mockRejectedValue({ response: { status: 404 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error')
|
||||
})
|
||||
|
||||
it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => {
|
||||
vi.useFakeTimers()
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus)
|
||||
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
|
||||
.mockRejectedValue({ response: { status: 500 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — AI retry', () => {
|
||||
const withFiles = () => task({
|
||||
items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })],
|
||||
|
||||
@@ -111,11 +111,15 @@ export default function BackgroundTasksWidget() {
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch((err: { response?: { status?: number } }) => {
|
||||
// The server 404s a job once it has expired (or after a restart). Nothing is
|
||||
// ever coming, so end the card instead of spinning and re-polling forever.
|
||||
if (err?.response?.status === 404) setError(task.id, task.tripId, t('reservations.import.error'))
|
||||
})
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tasks, setProgress, setDone, setError])
|
||||
}, [tasks, setProgress, setDone, setError, t])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-W4LBX-001 to FE-W4LBX-019
|
||||
// FE-W4LBX-001 to FE-W4LBX-020
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { TripFile } from '../../types'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'
|
||||
|
||||
const getAuthUrl = vi.fn(async (url: string, _kind: string) => `${url}?token=abc`)
|
||||
const openFile = vi.fn(async (_url: string, _name: string) => {})
|
||||
@@ -222,6 +222,23 @@ describe('ImageLightbox', () => {
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-020: a mint that lands after the gallery moved on does not paint', async () => {
|
||||
const pending: Record<string, (url: string) => void> = {}
|
||||
getAuthUrl.mockImplementation((url: string) => new Promise<string>(resolve => { pending[url] = resolve }))
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/b.jpg', 'download'))
|
||||
// The token for the image we already left resolves last.
|
||||
await act(async () => {
|
||||
pending['/f/b.jpg']('/f/b.jpg?token=b')
|
||||
pending['/f/a.jpg']('/f/a.jpg?token=a')
|
||||
})
|
||||
|
||||
expect(container.querySelector('img[alt="b.jpg"]')).toHaveAttribute('src', '/f/b.jpg?token=b')
|
||||
})
|
||||
|
||||
it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => {
|
||||
// Every thumbnail costs its own one-shot token, so an off-screen thumb must
|
||||
// stay quiet. The default observer stub never intersects.
|
||||
|
||||
@@ -27,7 +27,12 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
setImgSrc('')
|
||||
// Images use a one-shot signed URL; a video must use the plain same-origin
|
||||
// URL (cookie auth) so its many Range requests all authenticate (#823).
|
||||
if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
if (!file || isVideo(file.mime_type)) return
|
||||
// Arrowing through the gallery leaves several mints in flight; only the one for
|
||||
// the file still on screen may paint.
|
||||
let current = true
|
||||
getAuthUrl(file.url, 'download').then(u => { if (current) setImgSrc(u) }).catch(() => {})
|
||||
return () => { current = false }
|
||||
}, [file?.url, file?.mime_type])
|
||||
|
||||
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||
|
||||
@@ -300,6 +300,18 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('cover.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-023: a cover url cannot close url() and add a second declaration', async () => {
|
||||
// cover_image is a free string on the write path, and the style attribute is decoded
|
||||
// before the CSS is parsed, so the quote and the paren have to be encoded.
|
||||
const hostile = "http://host/a.jpg');background-image:url('http://elsewhere/leak.jpg"
|
||||
await downloadTripPDF({ ...richArgs, trip: { ...richArgs.trip, cover_image: hostile } as any })
|
||||
const iframe = getIframe()
|
||||
const style = /<div class="cover-bg" style="([^"]*)"/.exec(iframe!.srcdoc)![1]
|
||||
|
||||
expect(style.split('url(')).toHaveLength(2)
|
||||
expect(style.match(/'/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-015: renders accommodation section when accommodations exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/accommodations', () =>
|
||||
|
||||
@@ -81,6 +81,13 @@ function escHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// The style attribute is decoded back into raw quotes before the CSS is parsed, so
|
||||
// escHtml alone cannot stop a cover_image from closing the url() and appending its own
|
||||
// declaration. Percent-encoding is transparent to the fetch.
|
||||
function cssUrl(url) {
|
||||
return String(url).replace(/["'()\\\s]/g, c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||
}
|
||||
|
||||
function absUrl(url) {
|
||||
if (!url) return null
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
|
||||
@@ -703,7 +710,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="cover">
|
||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(cssUrl(coverImg))}')"></div>` : ''}
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
|
||||
<div class="cover-body">
|
||||
|
||||
@@ -229,6 +229,33 @@ describe('KategorieGruppe — bulk actions', () => {
|
||||
await waitFor(() => expect(puts).toEqual([1]))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-011a: Check All sends the items together, not one round trip after another', async () => {
|
||||
const started: number[] = []
|
||||
let release = () => {}
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/:itemId', async ({ params }) => {
|
||||
started.push(Number(params.itemId))
|
||||
// The first item only answers once the second one has been sent, so a
|
||||
// serialised loop would never get its second request out.
|
||||
if (started.length === 1) await gate
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: Number(params.itemId) }) })
|
||||
}),
|
||||
)
|
||||
const { container } = setup({
|
||||
items: [
|
||||
buildPackingItem({ id: 1, name: 'Tent', checked: 0 }),
|
||||
buildPackingItem({ id: 2, name: 'Rope', checked: 0 }),
|
||||
],
|
||||
})
|
||||
openCategoryMenu(container)
|
||||
|
||||
fireEvent.click(screen.getByText('Check All'))
|
||||
|
||||
await waitFor(() => expect(started).toHaveLength(2))
|
||||
release()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-012: a failing PUT during Check All is reported by the store, once', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/:itemId', () => new HttpResponse(null, { status: 500 })))
|
||||
seedStore(useTripStore, { packingItems: [buildPackingItem({ id: 1, name: 'Tent', checked: 0 })] })
|
||||
|
||||
@@ -99,16 +99,13 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
}
|
||||
|
||||
// togglePackingItem rolls its own optimistic update back and reports the
|
||||
// failure itself, so the bulk actions just drive it item by item.
|
||||
// failure itself, so the bulk actions just drive it item by item. They go out
|
||||
// together: serialised, a long list costs one round trip per item.
|
||||
const handleCheckAll = async () => {
|
||||
for (const item of Array.from(items)) {
|
||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
||||
}
|
||||
await Promise.all(items.filter(i => !i.checked).map(i => togglePackingItem(tripId, i.id, true)))
|
||||
}
|
||||
const handleUncheckAll = async () => {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
}
|
||||
await Promise.all(items.filter(i => i.checked).map(i => togglePackingItem(tripId, i.id, false)))
|
||||
}
|
||||
const handleDeleteAll = async () => {
|
||||
await onDeleteAll(items)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLUGINS-FRAME-001 to 046
|
||||
// FE-PLUGINS-FRAME-001 to 061
|
||||
import { render, cleanup, waitFor, fireEvent, screen, act } from '@testing-library/react';
|
||||
import PluginFrame from './PluginFrame';
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
@@ -340,12 +340,12 @@ describe('PluginFrame', () => {
|
||||
|
||||
function mount(granted: boolean) {
|
||||
grant(granted);
|
||||
const { container, unmount } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const view = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = view.container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) =>
|
||||
posted.push(m as Record<string, unknown>);
|
||||
return { iframe, posted, unmount };
|
||||
return { ...view, iframe, posted };
|
||||
}
|
||||
|
||||
it('FE-PLUGINS-FRAME-015: refuses an ungranted plugin without touching the browser API', () => {
|
||||
@@ -381,6 +381,33 @@ describe('PluginFrame', () => {
|
||||
expect(geo.clearWatch).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-060: a host settings change re-bridges without dropping the watch', () => {
|
||||
let tick: ((p: unknown) => void) | null = null;
|
||||
geo.watchPosition.mockImplementation((ok: (p: unknown) => void) => { tick = ok; return 7; });
|
||||
const { iframe, posted, rerender } = mount(true);
|
||||
act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g20', action: 'watch' }); });
|
||||
|
||||
// Any settings write re-runs the bridge effect; the watch must survive it.
|
||||
host.settings = { ...DEFAULT_SETTINGS, distance_unit: 'imperial' };
|
||||
act(() => { rerender(<PluginFrame pluginId="demo" />); });
|
||||
|
||||
expect(geo.clearWatch).not.toHaveBeenCalled();
|
||||
const before = posted.length;
|
||||
act(() => tick!({ coords: { latitude: 5, longitude: 6, accuracy: 5, heading: null, speed: null }, timestamp: 3 }));
|
||||
expect(posted.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-061: a frame that navigates itself has its watch released', () => {
|
||||
const { iframe } = mount(true);
|
||||
act(() => { fireEvent.load(iframe); });
|
||||
act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g21', action: 'watch' }); });
|
||||
|
||||
// Second load = the frame navigated away, so nothing receives the stream any more.
|
||||
act(() => { fireEvent.load(iframe); });
|
||||
|
||||
expect(geo.clearWatch).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-018: clear stops the watch on request', () => {
|
||||
const { iframe, posted } = mount(true);
|
||||
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g4', action: 'watch' });
|
||||
|
||||
@@ -650,8 +650,6 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
window.removeEventListener('message', onMessage)
|
||||
themeObserver.disconnect()
|
||||
if (wsForward) removeListener(wsForward)
|
||||
// The frame is going away (or re-bridging) — never leave a live GPS watch behind.
|
||||
if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null }
|
||||
}
|
||||
}, [pluginId, tripId, fill, navigate, postFrame, buildContext, userId])
|
||||
|
||||
@@ -664,6 +662,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
setHeight(null)
|
||||
confirmReqRef.current = null
|
||||
setConfirmReq(null)
|
||||
// Only a real teardown may kill the watch: the bridge above re-runs on any
|
||||
// settings, locale or breakpoint change, and nothing would re-arm it.
|
||||
return () => {
|
||||
if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null }
|
||||
}
|
||||
}, [pluginId])
|
||||
|
||||
const answerConfirm = (confirmed: boolean) => {
|
||||
@@ -689,7 +692,14 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
// Deliver the context as soon as the document is parsed (the plugin sets up its
|
||||
// message listener during parse), closing the trek:ready race so the theme is
|
||||
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
|
||||
onLoad={() => { setLoadFailed(false); loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
|
||||
onLoad={() => {
|
||||
setLoadFailed(false)
|
||||
loadsRef.current += 1
|
||||
if (loadsRef.current === 1) postFrame(buildContext())
|
||||
// A self-navigated frame is no longer bridged, so its watch is only
|
||||
// burning battery for positions nobody receives.
|
||||
else if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null }
|
||||
}}
|
||||
onError={() => setLoadFailed(true)}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
|
||||
@@ -298,6 +298,18 @@ describe('ModalRenderer', () => {
|
||||
expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-057: a body param is inserted literally, not as a replacement pattern', async () => {
|
||||
const notice = makeNotice({
|
||||
bodyKey: 'Version {version} is here',
|
||||
bodyParams: { version: '4.0.0 ($&)' },
|
||||
});
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Version 4.0.0 ($&) is here')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
|
||||
const { container } = render(<ModalRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
@@ -617,13 +617,17 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
|
||||
type NoticeState = ReturnType<typeof useSystemNoticeModal>;
|
||||
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Build the NoticeContent props for a given notice + pager slot index.
|
||||
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||
const { t, isDark, canPage, notices, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
|
||||
const rawBody = t(n.bodyKey);
|
||||
// The key is escaped into the pattern and the value goes in through a function, so a
|
||||
// value carrying $&, $1 or $` is inserted as typed instead of being expanded.
|
||||
const body = n.bodyParams
|
||||
? Object.entries(n.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${escapeRe(k)}\\}`, 'g'), () => v),
|
||||
rawBody
|
||||
)
|
||||
: rawBody;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-TODO-001 to FE-COMP-TODO-052
|
||||
// FE-COMP-TODO-001 to FE-COMP-TODO-079
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -24,6 +24,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(window, 'innerWidth', { value: 0, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
@@ -202,6 +203,25 @@ describe('TodoListPanel', () => {
|
||||
expect(screen.queryByText('Future Task')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-079: Overdue uses the local calendar day, not the UTC one', () => {
|
||||
// Freeze on an instant whose UTC date differs from the local one, so a UTC-derived
|
||||
// "today" would move the cut-off a day in whichever direction this runner sits.
|
||||
const offset = new Date(2026, 4, 15, 12).getTimezoneOffset();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 4, 15, offset > 0 ? 23 : 0, 30));
|
||||
const items = [
|
||||
buildTodoItem({ id: 1, name: 'Due Today', checked: 0, due_date: '2026-05-15' }),
|
||||
buildTodoItem({ id: 2, name: 'Due Yesterday', checked: 0, due_date: '2026-05-14' }),
|
||||
];
|
||||
render(<TodoListPanel tripId={1} items={items} />);
|
||||
const overdueBtn = screen.getAllByRole('button').find(
|
||||
b => b.textContent?.includes('Overdue') || b.getAttribute('title') === 'Overdue'
|
||||
);
|
||||
fireEvent.click(overdueBtn!);
|
||||
expect(screen.getByText('Due Yesterday')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Due Today')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-017: My Tasks filter shows only items assigned to current user', async () => {
|
||||
// Use default current_user_id: 1 from beforeEach; assign one item to user 1
|
||||
const items = [
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
import { formatDate as fmtDate } from '../../utils/formatters'
|
||||
import type { TodoItem } from '../../types'
|
||||
import { localToday } from '../Planner/today'
|
||||
import type { FilterType, Member } from './todoListModel'
|
||||
|
||||
/**
|
||||
@@ -65,7 +66,9 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
||||
return Array.from(cats).sort()
|
||||
}, [items])
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
// due_date is a bare calendar date, so "today" has to be the user's calendar
|
||||
// day: toISOString() hands over the UTC one and shifts the overdue cut-off.
|
||||
const today = localToday()
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result: TodoItem[]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-082
|
||||
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-084
|
||||
import type { Mock } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -1214,4 +1214,37 @@ describe('TripFormModal', () => {
|
||||
await waitFor(() => expect(staleResolved).toBe(true));
|
||||
expect(screen.queryByText('Stale failure')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-083: a closed modal fetches nothing until it is opened', async () => {
|
||||
// The trip planner keeps the modal mounted behind the page.
|
||||
const seen: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/auth/users', () => { seen.push('users'); return HttpResponse.json({ users: [] }); }),
|
||||
http.get('/api/trips/:id/members', () => { seen.push('members'); return HttpResponse.json({ members: [] }); }),
|
||||
);
|
||||
const trip = buildTrip({ id: 5 });
|
||||
const { rerender } = render(<TripFormModal {...defaultProps} isOpen={false} trip={trip} />);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
rerender(<TripFormModal {...defaultProps} isOpen trip={trip} />);
|
||||
await waitFor(() => expect(seen).toContain('users'));
|
||||
expect(seen).toContain('members');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-084: staging another cover revokes the preview it replaces', async () => {
|
||||
const revokeObjectURL = vi.fn();
|
||||
const original = URL.revokeObjectURL;
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL });
|
||||
createObjectURL.mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:second');
|
||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||
|
||||
fireEvent.change(fileInput(), { target: { files: [pngFile()] } });
|
||||
await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1));
|
||||
fireEvent.change(fileInput(), { target: { files: [pngFile()] } });
|
||||
|
||||
await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first'));
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const isEditing = !!trip
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const coverSearchSeq = useRef(0)
|
||||
// The staged cover lives on as an object URL until it is replaced or the modal goes.
|
||||
const previewUrlRef = useRef<string | null>(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
@@ -104,6 +106,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
setCoverPreview(null)
|
||||
setCoverSearchQuery('')
|
||||
}
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current)
|
||||
previewUrlRef.current = null
|
||||
}
|
||||
setPendingCoverFile(null)
|
||||
setPendingUnsplashUrl(null)
|
||||
setCoverSearchResults([])
|
||||
@@ -112,16 +118,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
setPendingDateShift(null)
|
||||
setDateShiftMode('keep_bookings')
|
||||
setError('')
|
||||
setExistingMembers([])
|
||||
// The planner keeps this modal mounted while it is closed, so nothing may be
|
||||
// fetched until it is actually open.
|
||||
if (isOpen) {
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
}
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
if (trip) {
|
||||
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
|
||||
} else {
|
||||
setExistingMembers([])
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
if (trip) {
|
||||
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [trip, isOpen])
|
||||
|
||||
@@ -131,6 +138,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
}
|
||||
}, [tripRemindersEnabled])
|
||||
|
||||
// A staged cover that never got uploaded would otherwise pin the full image for
|
||||
// as long as the tab lives.
|
||||
useEffect(() => () => {
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -219,7 +232,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
} else {
|
||||
// New trip: stage for upload after creation
|
||||
setPendingCoverFile(normalized)
|
||||
setCoverPreview(URL.createObjectURL(normalized))
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
|
||||
previewUrlRef.current = URL.createObjectURL(normalized)
|
||||
setCoverPreview(previewUrlRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,26 @@ describe('CustomDatePicker', () => {
|
||||
expect(onChange).toHaveBeenCalledWith('2026-07-17');
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-031: text input rejects a day the month does not have', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
|
||||
const input = screen.getByPlaceholderText('DD.MM.YYYY');
|
||||
fireEvent.change(input, { target: { value: '31.02.2026' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-032: text input still accepts 29 February in a leap year', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
|
||||
const input = screen.getByPlaceholderText('DD.MM.YYYY');
|
||||
fireEvent.change(input, { target: { value: '29.02.2024' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith('2024-02-29');
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-014: Escape in text input cancels text mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
|
||||
@@ -240,6 +240,10 @@ export function CustomDatePicker({
|
||||
}
|
||||
const year = y < 100 ? 2000 + y : y;
|
||||
if (m < 1 || m > 12 || d < 1 || d > 31) return;
|
||||
// 31.02. parses fine as numbers but is no day at all; without the round-trip it would
|
||||
// travel on as '2026-02-31' and come back out of new Date() as 3 March.
|
||||
const probe = new Date(Date.UTC(year, m - 1, d));
|
||||
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) return;
|
||||
const iso = `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
if (isOutOfRange(iso)) return;
|
||||
onChange(iso);
|
||||
|
||||
Reference in New Issue
Block a user