test: follow the qol sweep in the e2e schemas and the assertions it changed

The hand-written e2e schemas gain the new columns and place_regions, and the
unit assertions take the new arguments and fields. The Public API page names
GET /api/auth/app-config for the running version.
This commit is contained in:
Maurice
2026-09-30 23:28:14 +02:00
parent 8fd46c4c18
commit f5103d04e0
19 changed files with 56 additions and 223 deletions
@@ -1917,8 +1917,9 @@ describe('DayDetailPanel time format', () => {
);
render(<DayDetailPanel {...defaultProps} />);
// The arrival day shows only the check-in; the check-out belongs to the last day (#2393).
expect(await screen.findByText('15:00')).toBeInTheDocument();
expect(screen.getByText('11:00')).toBeInTheDocument();
expect(screen.queryByText('11:00')).not.toBeInTheDocument();
});
});
@@ -99,7 +99,8 @@ describe('LocationSelect', () => {
await screen.findByText('Gare du Nord');
expect(body.query).toBe('Gare du Nord');
expect(lang).toBe('en-US');
// The place name language, which follows the app's language when none is picked (#1799).
expect(lang).toBe('en');
});
it('FE-PLANNER-LOCSEL-008: a hit whose address equals its name shows no duplicate subtitle', async () => {
@@ -369,7 +369,7 @@ describe('VacayCalendar', () => {
// Now click a month card cell
await user.click(screen.getByText('click-0'))
expect(toggleCompanyHoliday).toHaveBeenCalledWith('2025-01-01')
expect(toggleCompanyHoliday.mock.calls[0][0]).toBe('2025-01-01')
expect(toggleEntry).not.toHaveBeenCalled()
})
+23 -203
View File
@@ -28,7 +28,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView';
import MobileMapTimeline from '../components/Journey/MobileMapTimeline';
import PhotoLightbox from '../components/Journey/PhotoLightbox';
import EmptyState from '../components/shared/EmptyState';
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
import { PAGE_WIDTH, SharedFooter, SharedHero, SharedLoading, SharedTabBar, SharedTopBar } from './sharedTrip/SharedChrome';
import { useTranslation } from '../i18n';
import { formatLocationName } from '../utils/formatters';
import { posterlessVideo } from './journeyDetail/JourneyDetailPage.helpers';
@@ -129,13 +129,7 @@ export default function JourneyPublicPage() {
desktopTwoColumn,
} = useJourneyPublic();
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
</div>
);
}
if (loading) return <SharedLoading />;
if (error || !data) {
return (
@@ -578,202 +572,29 @@ export default function JourneyPublicPage() {
</div>
);
// Shared view tab bar
const renderTabs = (views: typeof availableViews) =>
views.length > 1 && (
<div className="mb-6 flex w-fit overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
{views.map((v) => (
<button type="button"
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${view === v.id
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
);
// The same frame as a shared trip (#2541): the planner's top bar, the journey as
// a postcard, and the views as tabs that stay in reach. The mobile map view keeps
// its own floating switch above the full screen map.
const firstDate = sortedDates[0];
const lastDate = sortedDates[sortedDates.length - 1];
const heroStats = [
{ key: 'entries', value: stats.entries, label: t('journey.stats.entries') },
{ key: 'photos', value: stats.photos, label: t('journey.stats.photos') },
{ key: 'places', value: stats.places, label: t('journey.stats.places') },
].filter(st => st.value > 0);
const floatingSwitch = isMobile && view === 'timeline' && perms.share_timeline && perms.share_map;
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */}
<div
className="relative text-center text-white"
style={{
background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)',
padding: '32px 20px 28px',
overflow: 'hidden',
}}
>
{journey.cover_image && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(/uploads/${journey.cover_image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: 0.15,
}}
/>
)}
<div
style={{
position: 'absolute',
top: -60,
right: -60,
width: 200,
height: 200,
borderRadius: '50%',
background: 'rgba(255,255,255,0.03)',
}}
<div className="flex min-h-screen flex-col bg-surface-secondary" style={{ fontFamily: 'var(--font-system)' }}>
<SharedTopBar title={journey.title} locale={locale} langOpen={showLangPicker} onLangOpenChange={setShowLangPicker} />
<div className={`${PAGE_WIDTH} pt-5`}>
<SharedHero
trip={{ title: journey.title, description: journey.subtitle, cover_image: journey.cover_image, start_date: firstDate, end_date: lastDate }}
stats={heroStats}
/>
<div
style={{
position: 'absolute',
bottom: -40,
left: -40,
width: 150,
height: 150,
borderRadius: '50%',
background: 'rgba(255,255,255,0.02)',
}}
/>
<PublicLanguagePicker locale={locale} open={showLangPicker} onOpenChange={setShowLangPicker} />
{/* Logo */}
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
borderRadius: 12,
background: 'rgba(255,255,255,0.08)',
backdropFilter: 'blur(8px)',
marginBottom: 12,
border: '1px solid rgba(255,255,255,0.1)',
position: 'relative',
}}
>
<img src="/icons/icon-white.svg" alt="TREK" width={26} height={26} />
</div>
<div
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 600,
letterSpacing: 3,
textTransform: 'uppercase',
opacity: 0.35,
marginBottom: 12,
position: 'relative',
}}
>
{t('journey.public.tagline')}
</div>
<h1
className="relative"
style={{
margin: '0 0 4px',
fontSize: 'calc(26px * var(--fs-scale-title, 1))',
fontWeight: 700,
letterSpacing: -0.5,
}}
>
{journey.title}
</h1>
{journey.subtitle && (
<div
className="relative"
style={{
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
opacity: 0.5,
maxWidth: 400,
margin: '0 auto',
lineHeight: 1.5,
}}
>
{journey.subtitle}
</div>
{!floatingSwitch && (
<SharedTabBar tabs={availableViews.map(v => ({ id: v.id, label: v.label, icon: v.icon }))} active={view} onChange={id => setView(id as typeof view)} />
)}
{/* Stats pill */}
<div
className="relative"
style={{
marginTop: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 12,
padding: '8px 18px',
borderRadius: 20,
background: 'rgba(255,255,255,0.08)',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<span
style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 500,
opacity: 0.8,
display: 'flex',
alignItems: 'center',
gap: 5,
}}
>
<BookOpen size={12} /> {stats.entries} {t('journey.stats.entries')}
</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.4 }}>·</span>
<span
style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 500,
opacity: 0.8,
display: 'flex',
alignItems: 'center',
gap: 5,
}}
>
<Camera size={12} /> {stats.photos} {t('journey.stats.photos')}
</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.4 }}>·</span>
<span
style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 500,
opacity: 0.8,
display: 'flex',
alignItems: 'center',
gap: 5,
}}
>
<MapPin size={12} /> {stats.places} {t('journey.stats.places')}
</span>
</div>
<div
className="relative"
style={{
marginTop: 12,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
fontWeight: 500,
letterSpacing: 1.5,
textTransform: 'uppercase',
opacity: 0.25,
}}
>
{t('journey.public.readOnly')}
</div>
</div>
{/* Content */}
@@ -782,7 +603,6 @@ export default function JourneyPublicPage() {
<div className="mx-auto flex max-w-[1440px]" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */}
<div className="min-w-0 flex-1 px-8 py-6 xl:max-w-[50%]">
{renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()}
</div>
@@ -843,8 +663,6 @@ export default function JourneyPublicPage() {
</div>
)}
{renderTabs(availableViews)}
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
@@ -903,6 +721,8 @@ export default function JourneyPublicPage() {
</div>
</div>
<SharedFooter />
{/* Lightbox */}
{lightbox && (
<PhotoLightbox photos={lightbox.photos} startIndex={lightbox.index} onClose={() => setLightbox(null)} />
+1 -1
View File
@@ -153,7 +153,7 @@ describe('vacayStore optimistic updates', () => {
await useVacayStore.getState().toggleCompanyHoliday('2025-12-25');
expect(seen[0]).toEqual([]);
expect(seen[1]).toEqual([{ date: '2025-12-25' }]);
expect(seen[1]).toEqual([{ date: '2025-12-25', fraction: 1 }]);
});
it('FE-STORE-VCY-008: a rejected company-holiday toggle rolls the day back', async () => {
@@ -667,6 +667,7 @@ describe('useMPlanTimeline', () => {
expect(result.current.routeModeOptions).toEqual([
{ key: 'driving', label: 'mobileTrip.profileDriving' },
{ key: 'walking', label: 'mobileTrip.profileWalking' },
{ key: 'cycling', label: 'mobileTrip.profileCycling' },
{ key: 'plugin:ev/eco', label: 'Eco' },
{ key: 'plugin:ev/fast', label: 'Fast' },
])
@@ -332,7 +332,7 @@ describe('useMVacay', () => {
mocks.vacay = buildVacay({ plan: buildPlan({ company_holidays_enabled: true }) });
act(() => { rerender(); });
await act(async () => { await result.current.handleDayTap('2026-12-24'); });
expect(toggleCompanyHoliday).toHaveBeenCalledWith('2026-12-24');
expect(toggleCompanyHoliday.mock.calls[0][0]).toBe('2026-12-24');
});
it('FE-MOB-MVAC-019: the entitlement stepper caps at 365 and never drops below used', async () => {
+1 -1
View File
@@ -34,7 +34,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT,
assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT,
accommodation_id INTEGER,
accommodation_id INTEGER, route_excluded INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')));`);
// The auto-sort reads a booked night's hour off its booking, so the table has to be
// here even though nothing in this file books one.
+1
View File
@@ -37,6 +37,7 @@ const { db } = vi.hoisted(() => {
is_private INTEGER NOT NULL DEFAULT 0,
owner_id INTEGER,
updated_at DATETIME,
packed_quantity INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
tmp.exec(`CREATE TABLE packing_bags (
+4 -1
View File
@@ -41,8 +41,11 @@ const { db } = vi.hoisted(() => {
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT, title TEXT);');
// The GPX export reads the trip title for <metadata> and the filename.
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
// The places list reads each place's country and region (#2537).
tmp.exec(`CREATE TABLE place_regions (place_id INTEGER PRIMARY KEY, country_code TEXT NOT NULL,
region_code TEXT NOT NULL, region_name TEXT NOT NULL);`);
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0);`);
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, route_excluded INTEGER NOT NULL DEFAULT 0);`);
// reclaimPlaceImage ref-counts an uploaded thumbnail across both tables.
// Deleting a place cancels the nights booked at it (#2354), so the delete path
// reads this table even in a file that never books one.
+5 -2
View File
@@ -34,7 +34,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT, reservation_status TEXT,
reservation_notes TEXT, reservation_datetime TEXT, assignment_time TEXT, assignment_end_time TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
route_excluded INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
trip_id INTEGER NOT NULL, text TEXT, time TEXT, icon TEXT, sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
@@ -51,7 +51,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE packing_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
name TEXT NOT NULL, checked INTEGER DEFAULT 0, category TEXT, sort_order INTEGER DEFAULT 0,
weight_grams INTEGER, bag_id INTEGER, quantity INTEGER NOT NULL DEFAULT 1,
is_private INTEGER NOT NULL DEFAULT 0, owner_id INTEGER, updated_at DATETIME,
is_private INTEGER NOT NULL DEFAULT 0, owner_id INTEGER, updated_at DATETIME, packed_quantity INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE packing_item_recipients (item_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
PRIMARY KEY (item_id, user_id));`);
@@ -75,6 +75,9 @@ const { db } = vi.hoisted(() => {
needs_review INTEGER DEFAULT 0, day_plan_position REAL, external_source TEXT, sync_enabled INTEGER,
ingest_state TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);');
// The places list reads each place's country and region (#2537).
tmp.exec(`CREATE TABLE place_regions (place_id INTEGER PRIMARY KEY, country_code TEXT NOT NULL,
region_code TEXT NOT NULL, region_name TEXT NOT NULL);`);
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
+1 -1
View File
@@ -51,7 +51,7 @@ const { db } = vi.hoisted(() => {
UNIQUE(user_id, plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_company_holidays (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
date TEXT NOT NULL, note TEXT DEFAULT '', UNIQUE(plan_id, date));`);
date TEXT NOT NULL, note TEXT DEFAULT '', fraction REAL NOT NULL DEFAULT 1, UNIQUE(plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_holiday_calendars (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'public_holiday', region TEXT NOT NULL, label TEXT,
@@ -298,7 +298,7 @@ describe('Tool: clear_day_assignments (#2470)', () => {
expect(data.success).toBe(true);
expect([...data.removedIds].sort()).toEqual([a1.id, a2.id].sort());
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', { assignmentId: a1.id, dayId: day.id });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', expect.objectContaining({ assignmentId: a1.id, dayId: day.id }));
});
});
@@ -1223,9 +1223,9 @@ describe('applySettlementUpdate', () => {
const res = budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
// from, to, rounded amount, currency-flag(0)/value(null), rate-flag(null)/value(1),
// settled_at-flag(0)/value(null), id.
// No currency/exchange_rate/settled_at passed → all three CASE guards keep the existing columns.
expect(run).toHaveBeenCalledWith(2, 1, 10.13, 0, null, null, 1, 0, null, 7);
// settled_at-flag(0)/value(null), note-flag(0)/value(null) (#2340), id.
// No currency/exchange_rate/settled_at/note passed → every CASE guard keeps the existing column.
expect(run).toHaveBeenCalledWith(2, 1, 10.13, 0, null, null, 1, 0, null, 0, null, 7);
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
});
});
+4 -3
View File
@@ -932,14 +932,15 @@ describe('MapsService: Amap first inside China when picked outright (#1636)', ()
mockProviderGet.mockReturnValue({ value: 'amap' });
keys({ amap: 'akey' });
osmAnswers([]);
const fetchSpy = vi.fn().mockResolvedValue(ok({ count: '0', pois: [] }));
vi.stubGlobal('fetch', fetchSpy);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ count: '0', pois: [] })));
const searchText = vi.spyOn(AmapPlacesProvider.prototype, 'searchText');
spies.push(searchText);
const result = await svc.searchPlaces(1, 'nothing here', 'zh', BEIJING);
expect(result).toEqual({ places: [], source: 'amap' });
expect(trekPlacesSearch).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(searchText).toHaveBeenCalledTimes(1);
});
it('AMAP-097: a search centred outside China keeps the usual order, so the Eiffel Tower does not land in Macau', async () => {
+1 -1
View File
@@ -1514,7 +1514,7 @@ describe('getSharedCalendars', () => {
const calendars = svc.getSharedCalendars(viewer.id, '2025');
expect(calendars[0].companyHolidays).toEqual([{ date: '2025-12-24' }]);
expect(calendars[0].companyHolidays).toEqual([{ date: '2025-12-24', fraction: 1 }]);
});
it('VACAY-SVC-065: an owner without any plan yields empty arrays (no lazy creation)', () => {
@@ -236,7 +236,7 @@ describe('addon-gated domains validate their input', () => {
const toggleCompanyHoliday = vi.fn(() => ({}));
const host = build({ vacay: { toggleCompanyHoliday } });
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01' }), 42);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined, undefined);
});
it('ADDONERR-010e a collab message without a replyTo passes null', async () => {
@@ -285,7 +285,7 @@ describe('every schema-validated method rejects a payload its schema refuses', (
createTestPluginRegistry([new VacayRpc(vacay, guards), new TagsRpc(tags)]),
);
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01', note: 42 }), 42);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined, undefined);
await host.dispatch(req('tags.update', { tagId: 1, input: { name: 'kept' } }), 42);
expect(update).toHaveBeenCalledWith(1, 'kept', undefined);
});
+2
View File
@@ -242,6 +242,8 @@ A Homepage widget then needs no scripting:
| `404` | no such trip, or not one of yours |
| `429` | rate limit exceeded |
**Which TREK is running.** To check the running version, for example against the latest GitHub release, call `GET /api/auth/app-config`. It needs no key and no login, and the `version` field holds the running version (such as `4.3.3`). It is not part of `/api/v1`, so it follows the app's own release rather than the promise below.
**Versioning.** `/api/v1` may gain fields; it will not lose them or change their types. A breaking change ships as `/api/v2` and both run side by side for a transition period. Write your client to ignore fields it does not know.
## What is not here yet