mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
@@ -8,6 +8,9 @@
|
||||
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
|
||||
before first paint AND complies with the prod CSP (script-src 'self'). -->
|
||||
<script src="/theme-boot.js"></script>
|
||||
<!-- Recovers a shell whose entry module cannot load (#2524). Classic for the
|
||||
same reasons, and ahead of the entry, which it has to watch. -->
|
||||
<script src="/shell-guard.js"></script>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Boot guard for the app shell (#2524).
|
||||
*
|
||||
* Loaded as an external classic script in <head> (NOT a module), like
|
||||
* theme-boot.js, so it runs before the entry module and complies with the
|
||||
* production CSP (script-src 'self').
|
||||
*
|
||||
* The entry module and every chunk it imports statically have to load before
|
||||
* any of the app's own code runs. When one of them does not (a service worker
|
||||
* precache that keeps index.html under a chunk's name, a proxy that hands out a
|
||||
* stale file), the module script fails as a whole: nothing renders, and no error
|
||||
* screen or reload button of the app is there to help. A reload changes nothing
|
||||
* either, since the same precache answers it. This notices exactly that failure
|
||||
* and, while the server answers, loads the page again from the server under a
|
||||
* URL no cache has seen. When a service worker served the page, the app shell
|
||||
* it keeps (the Workbox precache and the worker itself) is thrown away first,
|
||||
* since that is what answered with the broken file. Once per session, so a build
|
||||
* that is broken on the server cannot put the page in a reload loop. Offline
|
||||
* nothing happens: the precache is then the only way the app can start at all.
|
||||
*
|
||||
* It also strips the query parameter such a reload carries, before the app
|
||||
* reads the URL. Keep the parameter and the cache filter in sync with
|
||||
* src/utils/chunkReload.ts and src/utils/serviceWorkerShell.ts.
|
||||
*
|
||||
* It must never throw.
|
||||
*/
|
||||
(function () {
|
||||
var PARAM = 'trek-reload';
|
||||
var MARKER = 'trek:shell-reload';
|
||||
var HEALTH_TIMEOUT_MS = 3000;
|
||||
|
||||
try {
|
||||
var here = new URL(window.location.href);
|
||||
if (here.searchParams.has(PARAM)) {
|
||||
here.searchParams.delete(PARAM);
|
||||
window.history.replaceState(window.history.state, '', here.pathname + here.search + here.hash);
|
||||
}
|
||||
} catch (e) {
|
||||
/* the parameter stays in the address bar, which is harmless */
|
||||
}
|
||||
|
||||
// The built entry lives under /assets/. The dev server's /src/main.tsx does
|
||||
// not, so a syntax error while developing is left to Vite's own overlay.
|
||||
function isEntry(el) {
|
||||
return !!el && el.tagName === 'SCRIPT' && el.type === 'module' && /\/assets\//.test(el.src || '');
|
||||
}
|
||||
|
||||
function alreadyTried() {
|
||||
try {
|
||||
return !!sessionStorage.getItem(MARKER);
|
||||
} catch (e) {
|
||||
// Without the marker there is no loop protection, so do nothing.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markTried() {
|
||||
try {
|
||||
sessionStorage.setItem(MARKER, String(Date.now()));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same test as probeNow() in src/sync/connectivity.ts: TREK's own health JSON,
|
||||
// not an edge proxy's login page and not a request that never came back.
|
||||
function serverAnswers() {
|
||||
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, HEALTH_TIMEOUT_MS) : null;
|
||||
return fetch('/api/health', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
redirect: 'manual',
|
||||
signal: ctrl ? ctrl.signal : undefined,
|
||||
}).then(function (res) {
|
||||
var type = res.headers.get('content-type') || '';
|
||||
return res.ok && type.indexOf('application/json') !== -1;
|
||||
}, function () {
|
||||
return false;
|
||||
}).then(function (ok) {
|
||||
if (timer) clearTimeout(timer);
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
|
||||
function servedByWorker() {
|
||||
try {
|
||||
return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dropAppShell() {
|
||||
var jobs = [];
|
||||
if (typeof caches !== 'undefined') {
|
||||
jobs.push(caches.keys().then(function (names) {
|
||||
return Promise.all(names.filter(function (name) {
|
||||
return name.indexOf('-precache-') !== -1;
|
||||
}).map(function (name) {
|
||||
return caches.delete(name);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
if (navigator.serviceWorker) {
|
||||
jobs.push(navigator.serviceWorker.getRegistration().then(function (reg) {
|
||||
return reg ? reg.unregister() : false;
|
||||
}));
|
||||
}
|
||||
return Promise.all(jobs);
|
||||
}
|
||||
|
||||
function reloadFresh() {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set(PARAM, Date.now().toString(36));
|
||||
window.location.replace(url.href);
|
||||
}
|
||||
|
||||
function recover() {
|
||||
if (alreadyTried()) return;
|
||||
// Read now: a page the worker did not serve failed on the network, and its
|
||||
// precache has nothing to do with it.
|
||||
var fromWorker = servedByWorker();
|
||||
serverAnswers().then(function (ok) {
|
||||
if (!ok || alreadyTried() || !markTried()) return;
|
||||
var dropped = fromWorker ? dropAppShell() : Promise.resolve();
|
||||
return dropped.then(null, function () {
|
||||
/* reload regardless, the server has the current build */
|
||||
}).then(reloadFresh);
|
||||
}).then(null, function () {
|
||||
/* never block boot */
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Error and load events of a script do not bubble, so both are caught on
|
||||
// their way down. On the document, since a load event never reaches window.
|
||||
document.addEventListener('error', function (event) {
|
||||
if (isEntry(event.target)) recover();
|
||||
}, true);
|
||||
// The shell started, so a later update in this session may recover too.
|
||||
document.addEventListener('load', function (event) {
|
||||
if (!isEntry(event.target)) return;
|
||||
try {
|
||||
sessionStorage.removeItem(MARKER);
|
||||
} catch (e) {
|
||||
/* nothing to clear */
|
||||
}
|
||||
}, true);
|
||||
} catch (e) {
|
||||
/* never block boot */
|
||||
}
|
||||
})();
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-APISURF-001 to FE-APISURF-058
|
||||
// FE-APISURF-001 to FE-APISURF-059
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -455,6 +455,7 @@ describe('client > endpoint wiring', () => {
|
||||
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
|
||||
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
|
||||
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
|
||||
{ n: 'freezeRates', r: () => budgetApi.freezeRates(1, {}), e: 'POST /api/trips/1/budget/freeze-rates' },
|
||||
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
|
||||
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
|
||||
])
|
||||
@@ -572,6 +573,14 @@ describe('client > request payloads', () => {
|
||||
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-059: the settlement carries the display rate only when the caller has one', async () => {
|
||||
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', 0.61))).url).toBe('/api/trips/1/budget/settlement?base=EUR&base_rate=0.61')
|
||||
expect((await traceOne(() => budgetApi.settlement(1, 'EUR', null))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
|
||||
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
|
||||
const lent = { fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }
|
||||
expect((await traceOne(() => budgetApi.freezeRates(1, lent))).body).toEqual(lent)
|
||||
})
|
||||
|
||||
it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => {
|
||||
const rec = await traceOne(() => daysApi.create(1, { dated: true }))
|
||||
expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } })
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
|
||||
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||
type BudgetCreateSettlementRequest, type BudgetUpdateSettlementRequest, type BudgetSettlementQuery,
|
||||
type BudgetFreezeRatesRequest, type BudgetFreezeRatesResponse,
|
||||
type TodoCategoryAssigneesRequest,
|
||||
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
||||
@@ -1352,9 +1354,13 @@ export const budgetApi = {
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
// `baseRate` is units of `base` per 1 trip currency from the client's own rates. The server
|
||||
// uses it only in place of a display quote it cannot fetch itself, which also reads legacy
|
||||
// transfers without a currency (in the display currency, as with a live quote).
|
||||
settlement: (tripId: number | string, base?: string, baseRate?: number | null) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base, ...(baseRate != null ? { base_rate: baseRate } : {}) } satisfies BudgetSettlementQuery } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: BudgetCreateSettlementRequest) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: BudgetUpdateSettlementRequest) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
freezeRates: (tripId: number | string, data: BudgetFreezeRatesRequest): Promise<BudgetFreezeRatesResponse> => apiClient.post(`/trips/${tripId}/budget/freeze-rates`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import CostsPanel, { ExpenseModal } from './CostsPanel'
|
||||
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
|
||||
import { resetFreezeAttempts } from './useFreezeMissingRates'
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
@@ -875,10 +876,11 @@ describe('CostsPanel — overview', () => {
|
||||
const alice = within(card).getByRole('button', { name: /You/ })
|
||||
await user.click(alice)
|
||||
expect(alice).toHaveAttribute('aria-expanded', 'true')
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row;
|
||||
// the raw 100 USD never shows up in the card.
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row; the
|
||||
// 100 USD is never converted again here. What was entered labels the row (#2525).
|
||||
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/100,00/)).toBeNull()
|
||||
expect(within(card).getByText('· $100.00')).toBeInTheDocument()
|
||||
// Received and still pending both lower her cost, each line with its one row.
|
||||
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
|
||||
@@ -2293,3 +2295,390 @@ describe('CostsPanel — expense modal in another language', () => {
|
||||
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2525: a bill entered in the display currency on a trip kept in another ───
|
||||
describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
// The reporter's setup: a euro trip, read in dollars, with the bill entered in
|
||||
// dollars. It was booked at 1.17 dollars to the euro, 685.26 EUR, and that is what
|
||||
// the balances and settle-up net. Today the euro buys 1.1551 dollars, so those
|
||||
// euros are $791.55. The dollar's own quote is rounded separately (0.865706 per
|
||||
// dollar is 1.155126 per euro), which is why the panel converts with the euro's.
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||||
clearExchangeRateCache()
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551, SEK: 10 }, ts: Date.now() }))
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
const hotel = (over: Partial<BudgetItem> = {}) => expense({
|
||||
id: 401, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||||
expense_date: '2026-08-06',
|
||||
payers: [{ user_id: 1, amount: 801.76 }],
|
||||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||||
...over,
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-079: a bill whose rate moved shows what was entered and what it was booked at', async () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
|
||||
// the 801.76 in the dialog. The pill beside the line is the last step.
|
||||
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
// Row total and payer chip stay the booked euros at today's rate, and so does what
|
||||
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
|
||||
// had Bob pay back more than he owed and reopened the trip.
|
||||
expect(screen.getAllByText('$791.55')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
|
||||
expect(screen.queryByText('you lent $400.88')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||||
mount([
|
||||
hotel({ id: 407, name: 'Villa', total_price: 12345.67, exchange_rate: 1.1551, payers: [{ user_id: 1, amount: 12345.67 }] }),
|
||||
hotel({ id: 408, name: 'Unpaid deposit', total_price: 250, exchange_rate: 1.1551, payers: [], members: [] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Villa')
|
||||
// Pill and payer chip; converting back with the dollar's own quote gave $12,346.05.
|
||||
expect(screen.getAllByText('$12,345.67')).toHaveLength(2)
|
||||
expect(screen.queryByText(/12,346\.0[56]/)).toBeNull()
|
||||
expect(screen.getByText('$12,595.67 spent')).toBeInTheDocument()
|
||||
const total = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(total.textContent).toContain('$12,595.67')
|
||||
const outstanding = screen.getByText('Outstanding amount').parentElement!.parentElement!.parentElement!
|
||||
expect(outstanding.textContent).toContain('$250.00')
|
||||
// Nothing to explain under either row.
|
||||
expect(screen.queryByText(/^\$12,345\.67 →/)).toBeNull()
|
||||
expect(screen.queryByText(/^\$250\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-088: the CSV export adds the trip-currency amount every sum is built from', async () => {
|
||||
let exported: Blob | null = null
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
try {
|
||||
mount([hotel()])
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
fireEvent.click(screen.getByTitle('Export CSV'))
|
||||
await waitFor(() => expect(exported).toBeTruthy())
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
|
||||
} finally {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-080: the totals add up the figures the rows show', async () => {
|
||||
mount([
|
||||
hotel(),
|
||||
hotel({ id: 402, name: 'Dinner', category: 'food', total_price: 120, payers: [{ user_id: 2, amount: 120 }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
|
||||
// $910.02, on the day header and on the total spend card.
|
||||
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
|
||||
// Label, its text block, the header row, then the card itself.
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(card.textContent).toContain('$910.02')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||||
// Nothing cached: the rates land after the expenses and the settlement, and the
|
||||
// totals used to keep the figure they were first added up with.
|
||||
clearExchangeRateCache()
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', async () => {
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
return HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
||||
}))
|
||||
mount([hotel({ id: 406, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||||
|
||||
// 100 euro at 0.8 euro to the dollar.
|
||||
expect(await screen.findByText(/100,00\s€ → \$125\.00/)).toBeInTheDocument()
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
await waitFor(() => expect(card.textContent).toContain('$125.00'))
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-081: a euro row converts live, a third currency shows the euros it was booked at', async () => {
|
||||
mount([
|
||||
hotel({ id: 403, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] }),
|
||||
hotel({ id: 404, name: 'Ferry', category: 'transport', total_price: 1000, currency: 'SEK', exchange_rate: 10, payers: [{ user_id: 1, amount: 1000 }] }),
|
||||
// Written before the freeze existed: nothing was booked, so dollars stay dollars.
|
||||
hotel({ id: 405, name: 'Old taxi', category: 'transport', total_price: 50, currency: 'USD', exchange_rate: 1, payers: [{ user_id: 1, amount: 50 }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Museum')
|
||||
// The trip currency has no frozen rate of its own and goes to dollars live.
|
||||
expect(screen.getByText(/^100,00\s€ → \$115\.51$/)).toBeInTheDocument()
|
||||
// 1000 SEK at 10 per euro is 100 euro, then live to dollars in the pill.
|
||||
expect(screen.getByText(/^1\s000,00\skr → 100,00\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('$50.00').length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
|
||||
mount([hotel()], {
|
||||
settlements: [
|
||||
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
|
||||
// Rows from before transfers had a currency were entered in the display
|
||||
// currency, which is how the server settles them.
|
||||
{ id: 22, from_user_id: 2, to_user_id: 1, amount: 30, currency: null, created_at: '2026-08-08 09:00:00' },
|
||||
],
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$395.77')).toBeInTheDocument()
|
||||
expect(screen.getByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 395.78 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -395.78 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 395.78 }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 791.55, reimbursed: 0, pending: 395.78, final: 395.77,
|
||||
sources: { fronted: [{ item_id: 401, cents: 79155 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 39578 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$791.55')).toHaveLength(2)
|
||||
expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument()
|
||||
expect(within(card).getByText('· $801.76')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel({ exchange_rate: 1.1551 })], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 400.88 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -400.88 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 400.88 }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 801.76, reimbursed: 0, pending: 400.88, final: 400.88,
|
||||
sources: { fronted: [{ item_id: 401, cents: 80176 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 40088 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$801.76')).toHaveLength(2)
|
||||
expect(within(card).queryByText('· $801.76')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => {
|
||||
const user = userEvent.setup()
|
||||
let put: Record<string, unknown> | null = null
|
||||
server.use(http.put('/api/trips/1/budget/405', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: hotel({ id: 405, currency: 'EUR' }) })
|
||||
}))
|
||||
mount([hotel({ id: 405, name: 'Tram pass', category: 'transport', total_price: 100, currency: null, exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||||
|
||||
await screen.findByText('Tram pass')
|
||||
// The list reads the missing currency as the trip's own.
|
||||
expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument()
|
||||
// Seeding the display currency here would label 100 euro as 100 dollars, and a
|
||||
// plain save would then store it that way.
|
||||
expect(screen.getByText(/^EUR/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^USD/)).toBeNull()
|
||||
|
||||
// Saving without touching anything keeps the 100 euro.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' }))
|
||||
})
|
||||
})
|
||||
|
||||
// ── A bill the server cannot convert: VND on an AUD trip ────────────────────────
|
||||
describe('CostsPanel: a bill the server cannot convert', () => {
|
||||
// The reporter's setup: an AUD trip whose server cannot fetch rates while the browser
|
||||
// can. An 8,920,000 VND dinner that never froze a rate was counted as 8,920,000 AUD.
|
||||
// The settlement now leaves it out and lists it, and an editor's browser lends its AUD
|
||||
// table to freeze a rate on it.
|
||||
const pho = () => expense({
|
||||
id: 501, name: 'Pho', category: 'food', total_price: 8920000, currency: 'VND', exchange_rate: 1,
|
||||
expense_date: '2026-09-20',
|
||||
payers: [{ user_id: 1, amount: 8920000 }],
|
||||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||||
})
|
||||
const leftOut = { balances: [], flows: [], settlements: [], currency: 'AUD', unconverted: { item_ids: [501], settlement_ids: [], currencies: ['VND'] } }
|
||||
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) })
|
||||
clearExchangeRateCache()
|
||||
resetFreezeAttempts()
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
it('FE-W5COSTS-090: VND/AUD regression: an editor heals the bill with the cached quote and the healed balances load', async () => {
|
||||
let healed = false
|
||||
const frozen: unknown[] = []
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [pho()] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json(healed
|
||||
? {
|
||||
// 8,920,000 VND at 18,241.3 is 489.00 AUD, half of it Bob's.
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 244.5 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -244.5 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 244.5 }],
|
||||
settlements: [],
|
||||
currency: 'AUD',
|
||||
unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||||
}
|
||||
: leftOut)),
|
||||
http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => {
|
||||
frozen.push(await request.json())
|
||||
healed = true
|
||||
return HttpResponse.json({ items: [{ ...pho(), exchange_rate: 18241.3 }], settlements: [], unresolved: [] })
|
||||
}),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// Only the currency the settlement is missing, at the rate the browser holds.
|
||||
await waitFor(() => expect(frozen).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }]))
|
||||
// The settlement is read again and Bob's half can be settled.
|
||||
expect(await screen.findByRole('button', { name: 'Settle' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/244\.50/).length).toBeGreaterThan(0)
|
||||
// Nothing on the page counts the bill as 8,920,000 dollars any more.
|
||||
expect(screen.queryByText(/\$8,920,000/)).toBeNull()
|
||||
expect(frozen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-091: a viewer\'s browser never freezes anything', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'admin' } })
|
||||
let frozen = 0
|
||||
server.use(http.post('/api/trips/1/budget/freeze-rates', () => {
|
||||
frozen += 1
|
||||
return HttpResponse.json({ items: [], settlements: [], unresolved: [] })
|
||||
}))
|
||||
mount([pho()], leftOut)
|
||||
|
||||
await screen.findByText('Pho')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(frozen).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-092: settle-up in the display currency lends its rate and asks the settlement in it', async () => {
|
||||
// Read in euros: the browser has the AUD table, the server has no quote at all.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
const asked: { base: string | null; baseRate: string | null }[] = []
|
||||
const posted: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
|
||||
return HttpResponse.json({
|
||||
balances: [], settlements: [], currency: 'EUR', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
|
||||
})
|
||||
}),
|
||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ settlement: { id: 1 } })
|
||||
}),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Settle' }))
|
||||
await waitFor(() => expect(posted).toHaveLength(1))
|
||||
// The transfer freezes at the rate the settlement was labelled with, so it cancels its
|
||||
// flow to the cent.
|
||||
expect(posted[0]).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||||
expect(asked.length).toBeGreaterThan(0)
|
||||
expect(asked.every(a => a.base === 'EUR' && a.baseRate === '0.61')).toBe(true)
|
||||
|
||||
const settleAll = screen.getByRole('button', { name: 'Settle up' })
|
||||
fireEvent.click(settleAll)
|
||||
await waitFor(() => expect(posted).toHaveLength(2))
|
||||
expect(posted[1]).toMatchObject({ amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-093: a payment added by hand lends the rate for its currency too', async () => {
|
||||
const user = userEvent.setup()
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1 } })
|
||||
}))
|
||||
mount([])
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted).toMatchObject({ amount: 25, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } })
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-094: a display rate the settlement query would refuse is not sent', async () => {
|
||||
// A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the query
|
||||
// takes, and sent as `base_rate` it would fail the whole settlement with a 400.
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XAU' }) })
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'IRR' } })
|
||||
localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() }))
|
||||
const asked: { base: string | null; baseRate: string | null }[] = []
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') })
|
||||
return HttpResponse.json({
|
||||
balances: [], flows: [], settlements: [], currency: 'XAU', unconverted: { item_ids: [], settlement_ids: [], currencies: [] },
|
||||
})
|
||||
}),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await waitFor(() => expect(asked.length).toBeGreaterThan(0))
|
||||
// Still asked in rials, only without the figure; the server answers in the trip
|
||||
// currency when it has no quote of its own.
|
||||
expect(asked.every(a => a.base === 'IRR' && a.baseRate === null)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,9 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates'
|
||||
import { splitShareLabel, useExpenseFx } from './expenseFx'
|
||||
import { useFreezeMissingRates } from './useFreezeMissingRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
@@ -21,7 +23,7 @@ import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted } from '@trek/shared'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
@@ -59,6 +61,11 @@ interface SettlementData {
|
||||
// What the trip ends up costing each participant. Computed server-side off the
|
||||
// same ledger as the balances, so the breakdown can't contradict them.
|
||||
finalBudgets: BudgetParticipantFinal[]
|
||||
// The currency the figures are in: the display currency, or the trip's own when
|
||||
// neither the server nor `base_rate` could quote the pair.
|
||||
currency?: string
|
||||
// Rows no rate could convert, left out of every figure above.
|
||||
unconverted?: BudgetUnconverted
|
||||
}
|
||||
|
||||
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||
@@ -85,7 +92,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
|
||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
// Anchored on the trip currency's quote, the one the server books with (#2525).
|
||||
const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency)
|
||||
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
// A failed settlement read leaves `settlement` null, which the empty views would
|
||||
@@ -119,14 +127,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
||||
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
||||
|
||||
// The browser's own figure for the display currency goes along, for the server to
|
||||
// answer in it when it cannot fetch a quote itself.
|
||||
const loadSettlement = useCallback(() => {
|
||||
budgetApi.settlement(tripId, base)
|
||||
budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
|
||||
.then(s => { setSettlement(s); setSettlementError(false) })
|
||||
.catch(() => setSettlementError(true))
|
||||
}, [tripId, base])
|
||||
}, [tripId, base, tripCurrency, displayPerTrip])
|
||||
|
||||
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
|
||||
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
|
||||
useEffect(() => { loadSettlement() }, [budgetItems.length, loadSettlement])
|
||||
|
||||
// Rows the server could not count get a rate frozen from the browser's, and the
|
||||
// settlement is read again once they count.
|
||||
useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement })
|
||||
|
||||
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -145,12 +159,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
[convert, tripCurrency],
|
||||
)
|
||||
const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e)
|
||||
// A transfer freezes its own rate at settle time, in its own table (#1445).
|
||||
// A transfer freezes its own rate at settle time, in its own table (#1445). One
|
||||
// without a currency predates that and was entered in the display currency, which
|
||||
// is how the server settles it too, not in the trip's.
|
||||
const settled = useCallback(
|
||||
(s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert),
|
||||
[convert, tripCurrency],
|
||||
(s: Settlement) => convertBooked(s.amount, s.currency || base, s.exchange_rate, tripCurrency, convert),
|
||||
[convert, tripCurrency, base],
|
||||
)
|
||||
const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e)
|
||||
// The line under an amount shown converted: what was entered, then where it went (#2525).
|
||||
const lineOf = (amount: number, e: BudgetItem, shown: number) =>
|
||||
convertedLine(amount, e.currency, e.exchange_rate, tripCurrency, base, shown)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
@@ -169,6 +188,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return booked(myShare, e)
|
||||
}
|
||||
|
||||
// `booked` carries the rates. They can land after the expenses, and without it in the
|
||||
// deps the cards kept the sums they were first added up with while the rows moved on.
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
|
||||
@@ -178,7 +199,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
|
||||
const outstandingCount = budgetItems.filter(isUnfinished).length
|
||||
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
|
||||
}, [budgetItems, settlement, me])
|
||||
}, [budgetItems, settlement, me, booked])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
@@ -192,7 +213,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
||||
return list
|
||||
}, [budgetItems, filter, search, catFilter, dayFilter, me])
|
||||
}, [budgetItems, filter, search, catFilter, dayFilter, me, booked])
|
||||
|
||||
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||
@@ -245,7 +266,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
try {
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base })
|
||||
await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency))
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
@@ -256,7 +277,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const flows = settlement?.flows || []
|
||||
if (!flows.length) return
|
||||
try {
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, tripCurrency))
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
// Refresh even when one transfer failed: the ones created before it are real,
|
||||
// and leaving them in the flow list invites a second, doubled settle-up.
|
||||
@@ -290,15 +311,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
|
||||
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
|
||||
// Read in another currency than the trip's, what each row counts as in the trip
|
||||
// currency too, the figure every sum is built from (#2525).
|
||||
const tripCol = tripCurrency !== base
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? ['Amount (' + tripCurrency + ')'] : []), 'Amount (' + base + ')', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of items) {
|
||||
const cur = curOf(e)
|
||||
const note = readUserNote(e)
|
||||
const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, tripCurrency, convert)
|
||||
rows.push([
|
||||
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
|
||||
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
|
||||
...(tripCol ? [inTrip.toFixed(currencyDecimals(tripCurrency))] : []),
|
||||
baseTotal(e).toFixed(currencyDecimals(base)),
|
||||
esc(note),
|
||||
].join(sep))
|
||||
@@ -521,7 +547,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)}
|
||||
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base} tripCurrency={tripCurrency}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
@@ -768,7 +794,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
const cur = curOf(e)
|
||||
const line = lineOf(e.total_price || 0, e, baseTotal(e))
|
||||
const payers = (e.payers || []).filter(p => p.amount !== 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
@@ -837,9 +863,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
{line && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))}
|
||||
{fmt(line.entered.amount, line.entered.currency)} {'→'} {fmt(line.into.amount, line.into.currency)}
|
||||
</div>
|
||||
)}
|
||||
{/* Under the name, because a chip reading "Y 122,00" says nothing on
|
||||
@@ -894,6 +920,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
function SettlementRow({ s }: { s: Settlement }) {
|
||||
// Legacy transfers carry no currency and were entered in the display base.
|
||||
const cur = (s.currency || base).toUpperCase()
|
||||
// Booked in the trip currency like an expense, so it is explained the same way.
|
||||
const line = convertedLine(s.amount, cur, s.exchange_rate, tripCurrency, base, settled(s))
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
@@ -901,7 +929,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
|
||||
{t('costs.payment')}
|
||||
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(settled(s))}</span>}
|
||||
{line && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
@@ -1000,6 +1028,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// server's display cents, not a conversion of the expense list done here.
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
|
||||
// Beside a converted row's name, what was entered, so the breakdown names the bill
|
||||
// the way the list above it does rather than by a figure nobody typed.
|
||||
const enteredOf = (itemId: number, shown: number) => {
|
||||
const e = budgetItems.find(i => i.id === itemId)
|
||||
const line = e ? lineOf(paidByUser(e, row.user_id), e, shown) : null
|
||||
return line ? fmt(line.entered.amount, line.entered.currency) : null
|
||||
}
|
||||
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
|
||||
const detail = (label: string, value: string) => (
|
||||
<div style={lineCls}>
|
||||
@@ -1022,12 +1057,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalExpenses')}</div>
|
||||
<div style={listCls}>
|
||||
{fronted.map(r => (
|
||||
{fronted.map(r => {
|
||||
const entered = enteredOf(r.item_id, r.amount)
|
||||
return (
|
||||
<div key={r.item_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.name}{entered && <span className="text-content-faint"> · {entered}</span>}
|
||||
</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1145,8 +1185,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
// A transfer can be made in any currency — paying a rouble debt in euros is normal —
|
||||
// so it carries its own, defaulting to the display currency. The server freezes its
|
||||
// FX rate on write, the same way an expense's is frozen.
|
||||
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
|
||||
function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; tripCurrency: string; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -1167,7 +1207,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
|
||||
const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency)
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
@@ -1237,12 +1277,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
// A saved expense without a currency opens in the trip's own (#2525).
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [currency, setCurrency] = useState(editingCurrency)
|
||||
const [day, setDay] = useState(editing?.expense_date || localToday())
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
@@ -1250,7 +1291,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
|
||||
// which is also what the currency field starts on.
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
|
||||
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
|
||||
return ''
|
||||
})
|
||||
@@ -1334,6 +1375,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}, [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const fx = preview(totalNum, currency)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
@@ -1591,12 +1633,18 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && totalNum !== 0 && (
|
||||
{fx && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
{fx.inTrip != null && <>
|
||||
<span className="text-content-faint">→</span>
|
||||
<span className={fx.shown == null ? 'text-content' : undefined} style={fx.shown == null ? { fontWeight: 600 } : undefined}>{formatMoney(fx.inTrip, tripCur, locale)}</span>
|
||||
</>}
|
||||
{fx.shown != null && <>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(fx.shown, base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1823,7 +1871,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
{splitMode === 'equally' ? (
|
||||
<span className="text-content-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { editingCurrencyOf, expenseFxPreview, splitShareLabel } from './expenseFx'
|
||||
|
||||
// A euro trip read in dollars, converted with the euro's own quote: one euro is 1.1551
|
||||
// dollars today, and 1.17 on the day the hotel was booked (#2525).
|
||||
const convert = (amount: number, from: string | null | undefined): number =>
|
||||
(amount / ({ EUR: 1, USD: 1.1551, JPY: 170 } as Record<string, number>)[(from || 'USD').toUpperCase()]) * 1.1551
|
||||
const hotel = { currency: 'USD', exchange_rate: 1.17 }
|
||||
const base = { editing: hotel, editingCurrency: 'USD', tripCurrency: 'EUR', base: 'USD', convert }
|
||||
|
||||
describe('editingCurrencyOf', () => {
|
||||
it('opens a saved expense without a currency in the trip currency', () => {
|
||||
expect(editingCurrencyOf({ currency: null }, 'eur', 'USD')).toBe('EUR')
|
||||
expect(editingCurrencyOf({ currency: 'jpy' }, 'EUR', 'USD')).toBe('JPY')
|
||||
})
|
||||
|
||||
it('starts a new expense, or one on a trip without a currency, in the display currency', () => {
|
||||
expect(editingCurrencyOf(null, 'EUR', 'usd')).toBe('USD')
|
||||
expect(editingCurrencyOf({ currency: null }, null, 'usd')).toBe('USD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expenseFxPreview', () => {
|
||||
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
|
||||
expect(fx.inTrip).toBeCloseTo(685.26, 2)
|
||||
expect(fx.shown).toBeCloseTo(791.55, 2)
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
|
||||
expect(expenseFxPreview({ ...base, editing: { currency: 'USD', exchange_rate: 1.1551 }, total: 12345.67, currency: 'USD' })).toBeNull()
|
||||
expect(expenseFxPreview({ ...base, editing: null, total: 250, currency: 'USD' })).toBeNull()
|
||||
expect(expenseFxPreview({ ...base, total: 0, currency: 'USD' })).toBeNull()
|
||||
})
|
||||
|
||||
it('previews a new or re-currencied amount at today\'s rate', () => {
|
||||
expect(expenseFxPreview({ ...base, editing: null, total: 1700, currency: 'JPY' })).toEqual({ inTrip: null, shown: convert(1700, 'JPY') })
|
||||
// Switching the hotel to yen re-freezes on save, so the old rate does not apply.
|
||||
expect(expenseFxPreview({ ...base, total: 1700, currency: 'JPY' })?.inTrip).toBeNull()
|
||||
})
|
||||
|
||||
it('stops at the trip amount when the viewer reads the trip currency', () => {
|
||||
expect(expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })).toEqual({ inTrip: 801.76 / 1.17, shown: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitShareLabel', () => {
|
||||
const sym = (c: string) => (c === 'USD' ? '$' : c + ' ')
|
||||
|
||||
it('prints the entered share, then the share the row lends or borrows', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77')
|
||||
// Read in the trip currency, the share is the booked euros.
|
||||
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
})
|
||||
|
||||
it('prints the entered share alone when nothing is converted', () => {
|
||||
expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00')
|
||||
expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { currencyDecimals, formatMoney } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
|
||||
* phone MCostSheet so the two can never preview the same save differently (#2525). The
|
||||
* editors only lay it out.
|
||||
*/
|
||||
|
||||
interface EditedExpense {
|
||||
currency?: string | null
|
||||
exchange_rate?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The currency an edited expense opens in. A saved expense without a currency is in the
|
||||
* trip's own, which is how the lists read it; opening it in the display currency relabelled
|
||||
* the amount, and a save then stored it in that currency.
|
||||
*/
|
||||
export function editingCurrencyOf(editing: EditedExpense | null | undefined, tripCurrency: string | null | undefined, base: string): string {
|
||||
return ((editing && (editing.currency || tripCurrency)) || base).toUpperCase()
|
||||
}
|
||||
|
||||
export interface ExpenseFx {
|
||||
/** The amount in the trip currency, when the save keeps a rate frozen on entry. */
|
||||
inTrip: number | null
|
||||
/** The amount in the display currency at today's rate; null when `inTrip` already is. */
|
||||
shown: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
|
||||
* frozen when the expense was entered (the server re-freezes only when the currency changes),
|
||||
* so it is previewed at that rate, the way the list will show it after the save. Anything
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
|
||||
* in the display currency that still reads back as typed.
|
||||
*/
|
||||
export function expenseFxPreview(args: {
|
||||
total: number
|
||||
currency: string
|
||||
editing: EditedExpense | null | undefined
|
||||
editingCurrency: string
|
||||
tripCurrency: string
|
||||
base: string
|
||||
convert: (amount: number, from: string | null | undefined) => number
|
||||
}): ExpenseFx | null {
|
||||
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
|
||||
if (total === 0) return null
|
||||
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
|
||||
if (currency === base && inTrip == null) return null
|
||||
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
|
||||
const unit = 10 ** currencyDecimals(base)
|
||||
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
|
||||
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
|
||||
}
|
||||
|
||||
/**
|
||||
* One equal share as the split summary prints it, "$400.88", followed by what that share
|
||||
* counts as when the amount is converted, "$400.88 → $390.54". That second figure is the
|
||||
* one the row's "you lent" is made of, so the editor and the row cannot disagree.
|
||||
*/
|
||||
export function splitShareLabel(
|
||||
each: number,
|
||||
currency: string,
|
||||
fx: ExpenseFx | null,
|
||||
participants: number,
|
||||
base: string,
|
||||
sym: (currency: string) => string,
|
||||
locale: string,
|
||||
): string {
|
||||
const entered = sym(currency) + each.toFixed(2)
|
||||
const whole = fx ? (fx.shown ?? fx.inTrip) : null
|
||||
if (whole == null || participants <= 0) return entered
|
||||
return `${entered} → ${formatMoney(whole / participants, base, locale)}`
|
||||
}
|
||||
|
||||
/** Everything an expense editor needs to preview a conversion, from the trip in the store. */
|
||||
export function useExpenseFx(base: string, editing: EditedExpense | null | undefined) {
|
||||
const storedTripCurrency = useTripStore(s => s.trip?.currency)
|
||||
const tripCurrency = (storedTripCurrency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base, tripCurrency)
|
||||
const editingCurrency = editingCurrencyOf(editing, storedTripCurrency, base)
|
||||
const preview = (total: number, currency: string) =>
|
||||
expenseFxPreview({ total, currency, editing, editingCurrency, tripCurrency, base, convert })
|
||||
return { tripCurrency, editingCurrency, preview }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// FE-FREEZE-RATES-001 to FE-FREEZE-RATES-006
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import type { BudgetUnconverted } from '@trek/shared'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
|
||||
import { resetFreezeAttempts, useFreezeMissingRates } from './useFreezeMissingRates'
|
||||
|
||||
// The reporter's setup: an AUD trip whose server cannot fetch rates, and a VND bill that
|
||||
// never froze one. The browser has today's AUD table.
|
||||
const bill = () => buildBudgetItem({ id: 11, trip_id: 5, name: 'Pho', total_price: 8920000, currency: 'VND', exchange_rate: 1 })
|
||||
const pending: BudgetUnconverted = { item_ids: [11], settlement_ids: [3], currencies: ['VND'] }
|
||||
|
||||
type Props = Parameters<typeof useFreezeMissingRates>[0]
|
||||
|
||||
function mountHook(over: Partial<Props> = {}) {
|
||||
const onHealed = vi.fn()
|
||||
const props: Props = { tripId: 5, tripCurrency: 'AUD', canEdit: true, unconverted: pending, onHealed, ...over }
|
||||
const view = renderHook((p: Props) => useFreezeMissingRates(p), { initialProps: props })
|
||||
return { ...view, onHealed, props }
|
||||
}
|
||||
|
||||
let posted: unknown[] = []
|
||||
|
||||
function serveFreeze(reply: () => Response = () => HttpResponse.json({
|
||||
items: [{ ...bill(), exchange_rate: 18241.3 }],
|
||||
settlements: [{ id: 3, trip_id: 5, from_user_id: 2, to_user_id: 1, amount: 100000, currency: 'VND', exchange_rate: 18241.3 }],
|
||||
unresolved: [],
|
||||
})) {
|
||||
server.use(http.post('/api/trips/5/budget/freeze-rates', async ({ request }) => {
|
||||
posted.push(await request.json())
|
||||
return reply()
|
||||
}))
|
||||
}
|
||||
|
||||
/** Lets a heal that was never going to send anything run to its end. */
|
||||
const settle = () => act(async () => { await new Promise(r => setTimeout(r, 30)) })
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
resetFreezeAttempts()
|
||||
clearExchangeRateCache()
|
||||
posted = []
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
|
||||
seedStore(useTripStore, { budgetItems: [bill()] })
|
||||
serveFreeze()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setForcedOffline(false)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useFreezeMissingRates', () => {
|
||||
it('FE-FREEZE-RATES-001: lends the fresh rates for the listed currencies and reloads once rows count', async () => {
|
||||
const { onHealed } = mountHook()
|
||||
|
||||
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
|
||||
// Only the currency the settlement is missing goes out, never the whole table.
|
||||
expect(posted).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }])
|
||||
expect(useTripStore.getState().budgetItems[0].exchange_rate).toBe(18241.3)
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-002: asks once per set of rows, however often the settlement is read', async () => {
|
||||
const { onHealed, props, rerender } = mountHook()
|
||||
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The same rows in another order, then again after a reload: nothing new to ask.
|
||||
rerender({ ...props, unconverted: { item_ids: [11], settlement_ids: [3], currencies: ['VND'] } })
|
||||
rerender({ ...props, unconverted: { settlement_ids: [3], item_ids: [11], currencies: ['VND'] } })
|
||||
await settle()
|
||||
expect(posted).toHaveLength(1)
|
||||
|
||||
// Another row is another attempt.
|
||||
rerender({ ...props, unconverted: { item_ids: [11, 12], settlement_ids: [3], currencies: ['VND'] } })
|
||||
await waitFor(() => expect(posted).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-003: never asks for a viewer, offline, or with nothing left out', async () => {
|
||||
mountHook({ canEdit: false })
|
||||
mountHook({ unconverted: { item_ids: [], settlement_ids: [], currencies: [] } })
|
||||
mountHook({ unconverted: undefined })
|
||||
setForcedOffline(true)
|
||||
mountHook({ unconverted: { item_ids: [21], settlement_ids: [], currencies: ['VND'] } })
|
||||
await settle()
|
||||
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-004: sends nothing when the browser has no rate for the currency either', async () => {
|
||||
const { onHealed } = mountHook({ unconverted: { item_ids: [31], settlement_ids: [], currencies: ['JPY'] } })
|
||||
await settle()
|
||||
|
||||
expect(posted).toEqual([])
|
||||
expect(onHealed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-005: does not reload when the server froze nothing', async () => {
|
||||
serveFreeze(() => HttpResponse.json({ items: [], settlements: [], unresolved: ['VND'] }))
|
||||
const { onHealed } = mountHook()
|
||||
|
||||
await waitFor(() => expect(posted).toHaveLength(1))
|
||||
await settle()
|
||||
expect(onHealed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-FREEZE-RATES-006: a refused heal is logged, not thrown, and not retried in a loop', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
serveFreeze(() => HttpResponse.json({ error: 'The trip currency changed. Reload and try again.' }, { status: 409 }))
|
||||
const { onHealed, props, rerender } = mountHook()
|
||||
|
||||
await waitFor(() => expect(warn).toHaveBeenCalled())
|
||||
rerender({ ...props })
|
||||
await settle()
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(onHealed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { BudgetFallbackFx, BudgetUnconverted } from '@trek/shared'
|
||||
import { fetchFreshRates, isSendableRate } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
|
||||
/**
|
||||
* Every set of rows already tried, by trip, trip currency and ids. The settlement lists a
|
||||
* row it cannot count until a rate is frozen on it; one the browser has no rate for either
|
||||
* stays listed, and without this every reload of the settlement would ask again.
|
||||
*/
|
||||
const tried = new Set<string>()
|
||||
|
||||
/** Test-only: the attempts outlive a vitest file's individual tests. */
|
||||
export function resetFreezeAttempts(): void {
|
||||
tried.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals the rows the settlement leaves out (`unconverted`): expenses and transfers in a
|
||||
* foreign currency that never froze a rate, while the server cannot fetch one. That is the
|
||||
* setup where the server's own fetch fails and the browser's works, so an editor's browser
|
||||
* lends its fresh rates to POST …/budget/freeze-rates. The server still prefers its own rate
|
||||
* and never touches a row that is frozen already.
|
||||
*
|
||||
* Runs for an editor only, online, and once per set of rows. No request goes out when the
|
||||
* browser has no rate for any of the listed currencies either. `onHealed` runs when the
|
||||
* server froze something, so the caller can reload the settlement.
|
||||
*
|
||||
* Deliberately not in the mutation queue: the heal is derived and idempotent, and a replay
|
||||
* hours later would freeze a rate from another day.
|
||||
*/
|
||||
export function useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted, onHealed }: {
|
||||
tripId: number
|
||||
tripCurrency: string
|
||||
canEdit: boolean
|
||||
unconverted: BudgetUnconverted | null | undefined
|
||||
onHealed: () => void
|
||||
}): void {
|
||||
const onHealedRef = useRef(onHealed)
|
||||
useEffect(() => { onHealedRef.current = onHealed })
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => { mounted.current = false }
|
||||
}, [])
|
||||
|
||||
const trip = (tripCurrency || '').toUpperCase()
|
||||
const itemIds = [...(unconverted?.item_ids ?? [])].sort((a, b) => a - b).join(',')
|
||||
const settlementIds = [...(unconverted?.settlement_ids ?? [])].sort((a, b) => a - b).join(',')
|
||||
const currencies = (unconverted?.currencies ?? []).join(',')
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit || !trip || (!itemIds && !settlementIds) || isEffectivelyOffline()) return
|
||||
const key = `${tripId}:${trip}:${itemIds}:${settlementIds}`
|
||||
if (tried.has(key)) return
|
||||
tried.add(key)
|
||||
|
||||
const heal = async (): Promise<void> => {
|
||||
const rates = await fetchFreshRates(trip)
|
||||
const lent: Record<string, number> = {}
|
||||
for (const cur of currencies.split(',')) {
|
||||
const rate = rates?.[cur]
|
||||
if (isSendableRate(rate)) lent[cur] = rate
|
||||
}
|
||||
if (Object.keys(lent).length === 0) return
|
||||
const fallback: BudgetFallbackFx = { base: trip, rates: lent }
|
||||
const result = await useTripStore.getState().freezeMissingRates(tripId, fallback)
|
||||
if (mounted.current && (result.items.length > 0 || result.settlements.length > 0)) onHealedRef.current()
|
||||
}
|
||||
heal().catch((err: unknown) => console.warn('Could not freeze the missing exchange rates:', err))
|
||||
}, [canEdit, trip, tripId, itemIds, settlementIds, currencies])
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Clicking a place pin after the map has been dragged (#2504).
|
||||
*
|
||||
* Nothing Leaflet-related is mocked here on purpose. The bug lived in the space between
|
||||
* our drag wiring and Leaflet's own event routing: the pins swallow mousedown so a drag
|
||||
* onto a day can start without the map sliding away, and Leaflet only forgets that the
|
||||
* map was panned when it sees the next mousedown. A mocked react-leaflet, like the one
|
||||
* MapView.test.tsx uses, has no such memory and kept passing throughout.
|
||||
*
|
||||
* The click itself has to keep taking Leaflet's route and the page's: it is what closes
|
||||
* an open popup on the map, and an open context menu beside it.
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { render, waitFor, act } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
import L from 'leaflet'
|
||||
import { MapView } from './MapView'
|
||||
import { ContextMenu } from '../shared/ContextMenu'
|
||||
|
||||
// Every map this file builds, newest last, for the tests that reach past the component.
|
||||
const maps: L.Map[] = []
|
||||
L.Map.addInitHook(function (this: L.Map) { maps.push(this) })
|
||||
|
||||
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
|
||||
const RASTER = 'https://tiles.example.test/{z}/{x}/{y}.png'
|
||||
|
||||
// Leaflet sizes the map from the container, which jsdom lays out at 0 x 0. The cluster
|
||||
// group only draws pins inside the visible bounds, so the map needs a real size.
|
||||
const sized = ['clientWidth', 'clientHeight'] as const
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>()
|
||||
beforeAll(() => {
|
||||
// The pans here are synthetic and instant. With inertia, Leaflet would carry them on in
|
||||
// animation frames that can land after a test has already unmounted its map.
|
||||
L.Map.mergeOptions({ inertia: false })
|
||||
for (const key of sized) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key))
|
||||
Object.defineProperty(HTMLElement.prototype, key, { configurable: true, get: () => (key === 'clientWidth' ? 1000 : 700) })
|
||||
}
|
||||
})
|
||||
afterAll(() => {
|
||||
L.Map.mergeOptions({ inertia: true })
|
||||
for (const key of sized) {
|
||||
const original = originals.get(key)
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, key, original)
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
/** Leaflet only starts a pan for the primary button, which it reads off `which`. */
|
||||
function mouse(type: string, x: number, y: number): MouseEvent {
|
||||
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: y })
|
||||
Object.defineProperty(event, 'which', { value: 1 })
|
||||
return event
|
||||
}
|
||||
|
||||
/** Drag the map by its background, the way a user pans it. */
|
||||
function panMap(container: Element) {
|
||||
act(() => {
|
||||
container.dispatchEvent(mouse('mousedown', 300, 300))
|
||||
container.dispatchEvent(mouse('mousemove', 340, 320))
|
||||
container.dispatchEvent(mouse('mousemove', 380, 340))
|
||||
container.dispatchEvent(mouse('mouseup', 380, 340))
|
||||
})
|
||||
}
|
||||
|
||||
/** A plain click on an element: press, release, click, all on the same spot. */
|
||||
function clickOn(el: Element) {
|
||||
act(() => {
|
||||
el.dispatchEvent(mouse('mousedown', 500, 350))
|
||||
el.dispatchEvent(mouse('mouseup', 500, 350))
|
||||
el.dispatchEvent(mouse('click', 500, 350))
|
||||
})
|
||||
}
|
||||
|
||||
// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster.
|
||||
const places = [
|
||||
{ ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null },
|
||||
{ ...buildPlace({ id: 2, name: 'Kasuga Taisha', lat: 34.6812, lng: 135.8484 }), category_name: null, category_color: null, category_icon: null },
|
||||
]
|
||||
|
||||
async function renderMap() {
|
||||
const onMarkerClick = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} onMapClick={onMapClick} />,
|
||||
)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
// The cluster group takes its markers in on a timer (chunkedLoading).
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
|
||||
const pins = [...container.querySelectorAll('.leaflet-marker-icon[draggable="true"]')] as HTMLElement[]
|
||||
return { container, pins, onMarkerClick, onMapClick }
|
||||
}
|
||||
|
||||
describe('MapView on a real Leaflet map', () => {
|
||||
it('FE-MAPCLICK-001: a pin opens its place on a map nobody has dragged yet', async () => {
|
||||
const { pins, onMarkerClick, onMapClick } = await renderMap()
|
||||
clickOn(pins[0])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
// Still the marker's click, not one on the empty map behind it.
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-002: a pin still opens its place after the map was dragged (#2504)', async () => {
|
||||
const { container, pins, onMarkerClick, onMapClick } = await renderMap()
|
||||
panMap(container)
|
||||
clickOn(pins[0])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
// And again: the report was that every click after the pan went nowhere.
|
||||
clickOn(pins[1])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(2)
|
||||
expect(onMarkerClick.mock.calls.map(call => call[0]).sort()).toEqual([1, 2])
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-003: the click that ends a pan still counts for nothing', async () => {
|
||||
const { container, onMarkerClick, onMapClick } = await renderMap()
|
||||
panMap(container)
|
||||
// The browser aims the click that closes a drag at the element both ends share,
|
||||
// which is the map rather than the pin the pointer happened to stop on. Leaflet
|
||||
// swallows it, and must go on doing so: it is not a click on the empty map either.
|
||||
act(() => { container.dispatchEvent(mouse('click', 380, 340)) })
|
||||
expect(onMarkerClick).not.toHaveBeenCalled()
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("FE-MAPCLICK-004: a pin on a touch screen keeps Leaflet's own click and no drag wiring", async () => {
|
||||
const original = Object.getOwnPropertyDescriptor(window.navigator, 'maxTouchPoints')
|
||||
Object.defineProperty(window.navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||
try {
|
||||
const onMarkerClick = vi.fn()
|
||||
const view = render(<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} />)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon').length).toBe(2))
|
||||
const pin = container.querySelector('.leaflet-marker-icon') as HTMLElement
|
||||
expect(pin.getAttribute('draggable')).toBeNull()
|
||||
clickOn(pin)
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
if (original) Object.defineProperty(window.navigator, 'maxTouchPoints', original)
|
||||
else delete (window.navigator as unknown as Record<string, unknown>).maxTouchPoints
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-005: the place a pin opens stays open when the pin redraws mid-click', async () => {
|
||||
// The selection a pin makes redraws its icon, which replaces the element the click
|
||||
// landed on. Leaflet has to have found the pin before that happens: reading the target
|
||||
// afterwards, it finds it cut out of the map and takes the click for one on the empty
|
||||
// background, which would close the place again straight away.
|
||||
const onMapClick = vi.fn()
|
||||
function Planner() {
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
return (
|
||||
<>
|
||||
<output data-testid="selected">{selected ?? ''}</output>
|
||||
<MapView
|
||||
places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER}
|
||||
selectedPlaceId={selected}
|
||||
onMarkerClick={(id: number) => flushSync(() => setSelected(id))}
|
||||
onMapClick={() => { onMapClick(); setSelected(null) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const view = render(<Planner />)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
|
||||
const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement
|
||||
// The pointer lands on the photo inside the pin, which the redraw replaces.
|
||||
clickOn(pin.firstElementChild as HTMLElement)
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
expect(view.getByTestId('selected').textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-006: a pin click after a pan still closes an open popup on the map', async () => {
|
||||
const { container, pins, onMarkerClick } = await renderMap()
|
||||
const map = maps[maps.length - 1]
|
||||
panMap(container)
|
||||
// A hazard or plugin popup, as the planner opens them. Leaflet closes those on the
|
||||
// "preclick" it sends ahead of every click it routes, a pin's included.
|
||||
const popup = L.popup({ autoPan: false }).setLatLng([34.685, 135.844]).setContent('Road closed')
|
||||
act(() => { popup.openOn(map) })
|
||||
expect(map.hasLayer(popup)).toBe(true)
|
||||
clickOn(pins[0])
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
expect(map.hasLayer(popup)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-007: a pin click after a pan still reaches the page and closes an open context menu', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onMarkerClick = vi.fn()
|
||||
const view = render(
|
||||
<>
|
||||
{/* The menu the places list and the day plan open on a right click. It closes on
|
||||
any click that reaches the document. */}
|
||||
<ContextMenu menu={{ x: 20, y: 20, items: [{ label: 'Edit' }] }} onClose={onClose} />
|
||||
<MapView places={places} center={[34.685, 135.844]} zoom={15} tileUrl={RASTER} onMarkerClick={onMarkerClick} />
|
||||
</>,
|
||||
)
|
||||
const container = view.container.querySelector('.leaflet-container') as HTMLElement
|
||||
await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2))
|
||||
const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement
|
||||
panMap(container)
|
||||
clickOn(pin)
|
||||
expect(onMarkerClick).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MAPCLICK-008: pressing a pin after a pan still holds the map still for a drag onto a day', async () => {
|
||||
const { container, pins } = await renderMap()
|
||||
const map = maps[maps.length - 1]
|
||||
panMap(container)
|
||||
const before = map.getCenter()
|
||||
// Press on the pin and move off it, as the start of a drag towards the day plan.
|
||||
act(() => {
|
||||
pins[0].dispatchEvent(mouse('mousedown', 500, 350))
|
||||
document.dispatchEvent(mouse('mousemove', 560, 390))
|
||||
document.dispatchEvent(mouse('mousemove', 620, 430))
|
||||
document.dispatchEvent(mouse('mouseup', 620, 430))
|
||||
})
|
||||
expect(map.getCenter()).toEqual(before)
|
||||
// The pan handler is back on, and the background still pans the map.
|
||||
expect(map.dragging.enabled()).toBe(true)
|
||||
panMap(container)
|
||||
expect(map.getCenter()).not.toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -622,6 +622,32 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a place pin a drag source without losing its clicks to an old pan.
|
||||
*
|
||||
* The drag wiring swallows mousedown so the map holds still while a drag starts. Leaflet
|
||||
* tells a click from the end of a pan by a flag it only clears on the next mousedown it
|
||||
* sees, so once the map had been dragged, every click on a pin was taken for the tail of
|
||||
* that pan and dropped, until something else on the map was pressed (#2504). A press on
|
||||
* the pin now clears that flag itself, as the map would have. Switching the pan handler
|
||||
* off and on again is the public way to do that, and nothing is being panned at that
|
||||
* moment. The click then takes Leaflet's normal route, so it still closes open popups
|
||||
* and still reaches listeners further up the page, like an open context menu.
|
||||
*/
|
||||
function wireDraggablePin(el: HTMLElement, placeId: number, map: L.Map): () => void {
|
||||
const undoDrag = makeMarkerDraggable(el, placeId)
|
||||
const forgetPan = (e: MouseEvent) => {
|
||||
if (e.button !== 0 || !map.dragging.enabled()) return
|
||||
map.dragging.disable()
|
||||
map.dragging.enable()
|
||||
}
|
||||
el.addEventListener('mousedown', forgetPan)
|
||||
return () => {
|
||||
undoDrag()
|
||||
el.removeEventListener('mousedown', forgetPan)
|
||||
}
|
||||
}
|
||||
|
||||
interface MemoMarkerProps {
|
||||
place: any
|
||||
isSelected: boolean
|
||||
@@ -640,6 +666,7 @@ const MemoMarker = memo(function MemoMarker({
|
||||
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable, onRegister,
|
||||
}: MemoMarkerProps) {
|
||||
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
|
||||
const map = useMap()
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
// react-leaflet compares `position` by reference and calls setLatLng whenever it
|
||||
// differs, and the cluster group answers a moved child by taking it out and putting
|
||||
@@ -658,7 +685,7 @@ const MemoMarker = memo(function MemoMarker({
|
||||
// so the wiring is redone on every add rather than once on mount.
|
||||
add: (e: any) => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = draggable ? makeMarkerDraggable(e.target.getElement() as HTMLElement, place.id) : null
|
||||
cleanupRef.current = draggable ? wireDraggablePin(e.target.getElement() as HTMLElement, place.id, map) : null
|
||||
},
|
||||
remove: () => { cleanupRef.current?.(); cleanupRef.current = null },
|
||||
click: () => onClickPlace(place.id),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns } from './dayRoutePlan'
|
||||
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
@@ -142,6 +142,30 @@ describe('buildDayRouteRuns', () => {
|
||||
expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-015: the hotel points say which end of the day they are, a stop on the same spot does not (#2501)', () => {
|
||||
const hotel = { id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(2, inputs({
|
||||
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })],
|
||||
assignments: { '2': [at(48.86, 2.35, 0), at(48.80, 2.30, 1), at(48.88, 2.36, 2)] },
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
const legs = runs.flatMap(run => run.slice(1).map((p, i) => hotelBookendOf(run[i], p) ?? null))
|
||||
// Out of the hotel, three stops (one of them on the hotel's own spot), back to it.
|
||||
expect(legs).toEqual(['morning', null, null, 'evening'])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-016: the one drive of a moving day without stops is its morning leg (#2476)', () => {
|
||||
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
||||
const stays = [
|
||||
{ id: 1, trip_id: 1, place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 },
|
||||
{ id: 2, trip_id: 1, place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 3 },
|
||||
] as unknown as Accommodation[]
|
||||
const [run] = buildDayRouteRuns(2, inputs({ days, accommodations: stays, optimizeFromAccommodation: true }))
|
||||
expect(run.map(p => p.hotel)).toEqual(['morning', 'evening'])
|
||||
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -13,6 +13,19 @@ export interface DayRoutePoint {
|
||||
isPlace: boolean
|
||||
leg_transport_mode?: string | null
|
||||
incoming_leg_transport_mode?: string | null
|
||||
/** Only on an accommodation bookend: the stay the day starts from or ends at. */
|
||||
hotel?: 'morning' | 'evening'
|
||||
}
|
||||
|
||||
/**
|
||||
* Which accommodation bookend the leg between two neighbouring run points is, if any.
|
||||
* A day that is nothing but the drive from one stay to the next counts as its morning
|
||||
* leg, so it shows once at the top rather than at both ends (#2476).
|
||||
*/
|
||||
export function hotelBookendOf(from: DayRoutePoint, to: DayRoutePoint): 'morning' | 'evening' | undefined {
|
||||
if (from.hotel === 'morning') return 'morning'
|
||||
if (to.hotel === 'evening') return 'evening'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Everything the plan depends on, passed in rather than read from the store, so the
|
||||
@@ -131,9 +144,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
}
|
||||
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
||||
// falls through to the day default for hotel-adjacent legs unless the place endpoint
|
||||
// carries its own override.
|
||||
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
|
||||
// carries its own override. It says which end of the day it is, so the legs routed
|
||||
// from it can be told apart from a stop planned on the hotel's own spot (#2501).
|
||||
const hotelPt = (a: Accommodation | undefined, hotel: 'morning' | 'evening'): DayRoutePoint | null =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false, hotel } : null
|
||||
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
|
||||
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
|
||||
// an arrival/departure day (#1321, #2133) all draw no bookend.
|
||||
@@ -154,8 +168,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
|
||||
const firstWay = flatPts[0]
|
||||
const lastWay = flatPts[flatPts.length - 1]
|
||||
const morningHotel = hotelPt(bookends?.morning)
|
||||
const eveningHotel = hotelPt(bookends?.evening)
|
||||
const morningHotel = hotelPt(bookends?.morning, 'morning')
|
||||
const eveningHotel = hotelPt(bookends?.evening, 'evening')
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
@@ -176,8 +190,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
// waypoint behind, so the gates above never see it; no line beats a wrong one.
|
||||
const dayHasCarrierBooking = dayTransports.some(r => isCarrierTransport(r))
|
||||
if (runsWithHotel.length === 0 && drawMorning && drawEvening && !dayHasCarrierBooking) {
|
||||
const m = hotelPt(bookends?.morning)
|
||||
const e = hotelPt(bookends?.evening)
|
||||
const m = hotelPt(bookends?.morning, 'morning')
|
||||
const e = hotelPt(bookends?.evening, 'evening')
|
||||
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
|
||||
// The map pans on mousedown anywhere on its surface, and a marker sits on that
|
||||
// surface. Without this the map slides away under the pointer while the drag
|
||||
// is starting, which makes the day plan an impossible target to reach.
|
||||
// The map then never sees the press that starts a click on the marker, which is why the Leaflet
|
||||
// renderer clears the map's pan flag itself when a pin is pressed (wireDraggablePin, #2504).
|
||||
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
|
||||
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
|
||||
@@ -33,6 +33,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// An amount is printed in its own currency, unconverted. One saved without a
|
||||
// currency is in the trip's own (#2525), which is how Costs reads it; labelling
|
||||
// it with the display currency turned a 120 EUR deposit into $120.00.
|
||||
const ownCurrency = (currency: string | null | undefined) => currency || trip?.currency || base
|
||||
const linked = reservationId
|
||||
? budgetItems.find(i => i.reservation_id === reservationId)
|
||||
: placeId
|
||||
@@ -54,7 +58,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -72,7 +76,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, ownCurrency(linked.currency), locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-102
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-105
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -1026,7 +1026,7 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText('Linked expense')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation' });
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation', currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => {
|
||||
@@ -1336,6 +1336,55 @@ describe('ReservationModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-103: a linked cost saved without a currency is shown in the trip currency, not the display one (#2525)', () => {
|
||||
// A booking's own cost entry is written without a currency, which means the
|
||||
// trip's. Reading in USD on a EUR trip must not turn the 120 EUR into $120.00.
|
||||
budgetEnabled();
|
||||
seedStore(useSettingsStore, { settings: { default_currency: 'USD' } });
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1, currency: 'EUR' }),
|
||||
budgetItems: [
|
||||
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||
expect(screen.getByText('120,00 €')).toBeInTheDocument();
|
||||
expect(screen.queryByText('$120.00')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-104: an imported price keeps the currency it was quoted in, in the preview and on save (#2525)', async () => {
|
||||
// A euro trip, a confirmation priced in dollars. The preview said $801.76 and the
|
||||
// save sent the amount alone, which the server stored as 801.76 EUR.
|
||||
budgetEnabled();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 82 });
|
||||
const prefill = hotelPrefill({ metadata: { price: 801.76, priceCurrency: 'usd' } });
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
|
||||
|
||||
expect(screen.getByText('$801.76')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 801.76, category: 'accommodation', currency: 'USD' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-105: a parsed currency that is not a code previews and saves in the trip currency', async () => {
|
||||
budgetEnabled();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 83 });
|
||||
const prefill = hotelPrefill({ metadata: { price: 50, priceCurrency: 'dollars' } });
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
|
||||
|
||||
// The server drops such a currency too, so the preview names the one it will store.
|
||||
expect(screen.getByText('50,00 €')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 50, category: 'accommodation' });
|
||||
});
|
||||
|
||||
// ── File error paths ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -306,11 +307,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) saveData.create_budget_entry = entry
|
||||
}
|
||||
const saved = await onSave(saveData)
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
@@ -352,12 +350,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be
|
||||
// linked: the same entry the save sends, so the two cannot name different currencies.
|
||||
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
|
||||
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
|
||||
@@ -95,6 +95,36 @@ describe('TransportModal', () => {
|
||||
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003b: shows a delete action when editing with onDelete', () => {
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} onDelete={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003c: no delete action when creating, or when onDelete is omitted', () => {
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
const { rerender } = render(<TransportModal {...defaultProps} reservation={null} onDelete={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
rerender(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003d: confirming delete calls onDelete then onClose', async () => {
|
||||
const onDelete = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} onDelete={onDelete} onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /delete/i }));
|
||||
expect(screen.getByText('Delete booking?')).toBeInTheDocument();
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||
|
||||
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
@@ -1090,7 +1120,9 @@ describe('TransportModal', () => {
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights' });
|
||||
// The parsed currency travels with the price (#2525), or the server would store
|
||||
// it in whatever the trip currency is.
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights', currency: 'EUR' });
|
||||
});
|
||||
|
||||
// ── File edge cases ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { BookingCodeInput } from '../shared/BookingCode'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -18,6 +19,7 @@ import apiClient from '../../api/client'
|
||||
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -166,6 +168,8 @@ interface TransportModalProps {
|
||||
files?: TripFile[]
|
||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||
onFileDelete?: (fileId: number) => Promise<void>
|
||||
/** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */
|
||||
onDelete?: () => void | Promise<void>
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
// Pre-fill a brand-new transport booking from a parsed import item (review-
|
||||
// before-save); like `reservation` for the form but stays in create mode.
|
||||
@@ -185,7 +189,7 @@ interface TransportModalProps {
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
// The trip's places, offered by every location field of the manual tab (#2468).
|
||||
@@ -203,6 +207,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// Manual vs Automated (public transit search) creation mode (#1065).
|
||||
const [automated, setAutomated] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
@@ -612,11 +617,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) (payload as Record<string, unknown>).create_budget_entry = entry
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
@@ -660,12 +662,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be
|
||||
// linked: the same entry the save sends, so the two cannot name different currencies.
|
||||
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
|
||||
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -728,7 +728,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||
size="2xl"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}>
|
||||
<div>
|
||||
{!automated && reservation?.id && onDelete && (
|
||||
<button type="button" onClick={() => setShowDeleteConfirm(true)} className="text-danger" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '8px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Trash2 size={13} /> {t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
@@ -738,6 +746,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Manual vs Automated creation switch (#1065) — creating only; editing a
|
||||
@@ -1249,6 +1258,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
</form>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('reservations.confirm.deleteTitle')}
|
||||
message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onConfirm={async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete?.()
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The linked cost an imported booking's parsed price becomes, or null when it has none.
|
||||
*
|
||||
* The price keeps the currency it was quoted in (#2525). The forms used to send the
|
||||
* amount alone, so an imported $801.76 was stored as 801.76 in the trip's own currency
|
||||
* while the form had just previewed it in dollars. A currency that is not a three-letter
|
||||
* code is left out, which is what the server does with one too, so the preview and the
|
||||
* saved cost read the same either way.
|
||||
*/
|
||||
export function importedPriceEntry(
|
||||
metadata: unknown,
|
||||
type: string,
|
||||
): { total_price: number; category: string; currency?: string } | null {
|
||||
const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
|
||||
const price = Number(meta.price)
|
||||
if (!Number.isFinite(price) || price <= 0) return null
|
||||
const code = typeof meta.priceCurrency === 'string' ? meta.priceCurrency.trim().toUpperCase() : ''
|
||||
return {
|
||||
total_price: price,
|
||||
category: typeToCostCategory(type),
|
||||
...(/^[A-Z]{3}$/.test(code) ? { currency: code } : {}),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary';
|
||||
import * as chunkReload from '../../utils/chunkReload';
|
||||
|
||||
// Only the Reload button's way out is replaced; the boundary's own automatic reload
|
||||
// (reloadOnceForChunk) keeps running the real code.
|
||||
vi.mock('../../utils/chunkReload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/chunkReload')>()),
|
||||
reloadOntoCurrentBuild: vi.fn(),
|
||||
}));
|
||||
|
||||
// React logs every caught error itself; without this each of these tests prints a
|
||||
// full component stack and buries the real output.
|
||||
@@ -146,9 +154,10 @@ describe('ErrorBoundary', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-ERRBOUND-011: a chunk failure reloads once, then stops', () => {
|
||||
// The recovery reload is a location.replace onto a fresh URL (chunkReload).
|
||||
const reload = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...window.location, reload },
|
||||
value: { ...window.location, replace: reload },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
@@ -214,3 +223,40 @@ describe('RootErrorFallback', () => {
|
||||
expect(screen.getByText('A new version is available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reload button', () => {
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
|
||||
vi.mocked(chunkReload.reloadOntoCurrentBuild).mockClear();
|
||||
});
|
||||
|
||||
// A plain reload is answered by the service worker with the shell that just
|
||||
// failed, which is how the update screen outlived every reload (#2524).
|
||||
it('FE-COMP-ERRBOUND-017: after a dead chunk it reloads onto the current build', () => {
|
||||
render(<RootErrorFallback error={new Error(CHUNK_MESSAGE)} reset={() => {}} isChunkError />);
|
||||
fireEvent.click(screen.getByText('Reload page'));
|
||||
expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-ERRBOUND-018: the route fallback for a dead chunk does the same', () => {
|
||||
sessionStorage.setItem('trek:chunk-reload', '1');
|
||||
render(
|
||||
<ErrorBoundary boundaryId="route" level="route">
|
||||
<Boom message={CHUNK_MESSAGE} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Reload page'));
|
||||
expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-ERRBOUND-019: any other failure keeps the plain reload', () => {
|
||||
render(<RootErrorFallback error={new Error('boot failed')} reset={() => {}} isChunkError={false} />);
|
||||
fireEvent.click(screen.getByText('Reload page'));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(chunkReload.reloadOntoCurrentBuild).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
import { isChunkLoadError, reloadOnceForChunk, reloadOntoCurrentBuild } from '../../utils/chunkReload'
|
||||
|
||||
/**
|
||||
* The app had none of these, so a single throw during render unmounted the whole
|
||||
@@ -95,6 +95,16 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback's Reload button. After a dead chunk a plain reload can come back
|
||||
* to this very screen, because the service worker answers it with the shell it
|
||||
* already failed to load (#2524), so that case goes through the chunk recovery.
|
||||
*/
|
||||
function reloadPage(isChunkError: boolean) {
|
||||
if (isChunkError) reloadOntoCurrentBuild()
|
||||
else window.location.reload()
|
||||
}
|
||||
|
||||
interface FallbackProps extends FallbackState {
|
||||
level: Level
|
||||
variant: 'desktop' | 'mobile'
|
||||
@@ -161,7 +171,7 @@ export function ErrorFallback({ error, reset, isChunkError, level, variant, labe
|
||||
{(!isPanel || isChunkError) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
onClick={() => reloadPage(isChunkError)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium',
|
||||
mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
|
||||
@@ -201,7 +211,7 @@ export function RootErrorFallback({ error, isChunkError }: FallbackState) {
|
||||
{message && <code className="max-w-full truncate text-caption text-content-faint">{message}</code>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
onClick={() => reloadPage(isChunkError)}
|
||||
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { convertBooked, fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates'
|
||||
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
|
||||
|
||||
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
|
||||
|
||||
@@ -44,6 +45,94 @@ describe('fetchExchangeRates (#1561)', () => {
|
||||
server.use(http.get(FX_URL, () => HttpResponse.error()))
|
||||
expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 })
|
||||
})
|
||||
|
||||
// A request the network never answers held the Costs panel's heal for as long as the
|
||||
// browser kept the socket open. It gives up after 10 s like any other failure.
|
||||
it('gives up on a request that hangs after 10 s and serves the stale cache', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
try {
|
||||
localStorage.setItem('trek_fx_NOK', JSON.stringify({ rates: { NOK: 1, USD: 0.1 }, ts: Date.now() - 24 * 60 * 60 * 1000 }))
|
||||
let signal: AbortSignal | undefined
|
||||
fetchSpy.mockImplementation((_input, init) => new Promise((_resolve, reject) => {
|
||||
signal = init?.signal ?? undefined
|
||||
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
||||
}))
|
||||
const pending = fetchExchangeRates('NOK')
|
||||
await vi.advanceTimersByTimeAsync(9_999)
|
||||
expect(signal?.aborted).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(await pending).toEqual({ NOK: 1, USD: 0.1 })
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The rates the client lends the server to freeze a rate with. Only a fresh table counts:
|
||||
// a stale one would pin a figure from another day on the row for good.
|
||||
describe('freshRates and fetchFreshRates', () => {
|
||||
beforeEach(() => {
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
|
||||
it('reads a fresh cache and ignores a stale or missing one', () => {
|
||||
expect(freshRates('AUD')).toBeNull()
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
||||
expect(freshRates('aud')).toBeNull()
|
||||
clearExchangeRateCache()
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() }))
|
||||
expect(freshRates('aud')).toEqual({ AUD: 1, VND: 18241.3 })
|
||||
})
|
||||
|
||||
it('fetches when the cache is stale and gives the new table', async () => {
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
||||
server.use(http.get(FX_URL, () => HttpResponse.json([{ quote: 'VND', rate: 18241.3 }])))
|
||||
expect(await fetchFreshRates('AUD')).toEqual({ AUD: 1, VND: 18241.3 })
|
||||
})
|
||||
|
||||
it('is null when the fetch fails and only a stale table is left', async () => {
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
||||
server.use(http.get(FX_URL, () => HttpResponse.error()))
|
||||
expect(await fetchFreshRates('AUD')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withFallbackFx', () => {
|
||||
beforeEach(() => {
|
||||
clearExchangeRateCache()
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61, XAU: 1e-10 }, ts: Date.now() }))
|
||||
})
|
||||
|
||||
it('lends the fresh trip-currency rate for a foreign currency', () => {
|
||||
expect(withFallbackFx({ name: 'Pho', currency: 'vnd', total_price: 8920000 }, 'aud')).toEqual({
|
||||
name: 'Pho', currency: 'vnd', total_price: 8920000,
|
||||
fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } },
|
||||
})
|
||||
expect(withFallbackFx({ from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR' }, 'AUD').fallback_fx)
|
||||
.toEqual({ base: 'AUD', rates: { EUR: 0.61 } })
|
||||
})
|
||||
|
||||
it('leaves the data alone when there is nothing to lend', () => {
|
||||
const inTrip = { name: 'Tram', currency: 'AUD' }
|
||||
expect(withFallbackFx(inTrip, 'AUD')).toBe(inTrip)
|
||||
// No currency at all is the trip's own.
|
||||
expect(withFallbackFx({ name: 'Tram', currency: null }, 'AUD')).toEqual({ name: 'Tram', currency: null })
|
||||
// An explicit rate is the caller's and wins on the server anyway.
|
||||
expect(withFallbackFx({ currency: 'VND', exchange_rate: 17000 }, 'AUD')).toEqual({ currency: 'VND', exchange_rate: 17000 })
|
||||
// No quote for the currency, no trip currency, or a rate the contract would refuse.
|
||||
expect(withFallbackFx({ currency: 'JPY' }, 'AUD')).toEqual({ currency: 'JPY' })
|
||||
expect(withFallbackFx({ currency: 'VND' }, null)).toEqual({ currency: 'VND' })
|
||||
expect(withFallbackFx({ currency: 'XAU' }, 'AUD')).toEqual({ currency: 'XAU' })
|
||||
})
|
||||
|
||||
it('lends nothing from a stale table', () => {
|
||||
clearExchangeRateCache()
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
||||
expect(withFallbackFx({ currency: 'VND' }, 'AUD')).toEqual({ currency: 'VND' })
|
||||
})
|
||||
})
|
||||
|
||||
// A cost entered in a foreign currency is money that was actually paid, at a rate that
|
||||
@@ -92,4 +181,184 @@ describe('convertBooked', () => {
|
||||
expect(convertBooked(120, 'usd', 1.2, 'eur', live)).toBe(100)
|
||||
expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100)
|
||||
})
|
||||
|
||||
// #2525: a euro trip read in dollars, the bill entered in dollars. It was booked at 685.26
|
||||
// EUR, and that is what the balances and settle-up net and then show at today's rate. The
|
||||
// row has to show the same figure, or paying back the "you lent" it printed reopens the trip.
|
||||
it('reads an amount in the display currency through the trip currency too', () => {
|
||||
const liveUsd = (amount: number, from: string | null | undefined): number => {
|
||||
const rates: Record<string, number> = { USD: 1, EUR: 1 / 1.1398 }
|
||||
const r = rates[(from || 'USD').toUpperCase()]
|
||||
return r && r > 0 ? amount / r : amount
|
||||
}
|
||||
// 685.26 EUR, then at today's 1.1398 dollars to the euro.
|
||||
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).toBeCloseTo((801.76 / 1.17) * 1.1398, 6)
|
||||
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).not.toBeCloseTo(801.76, 1)
|
||||
// Without a booked rate there is nothing to go through, and dollars stay dollars.
|
||||
expect(convertBooked(801.76, 'USD', 1, 'EUR', liveUsd)).toBe(801.76)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bookedInTrip', () => {
|
||||
it('gives the trip-currency amount a foreign row was booked at', () => {
|
||||
expect(bookedInTrip(801.76, 'USD', 1.17, 'EUR')).toBeCloseTo(685.26, 2)
|
||||
expect(bookedInTrip(12000, 'jpy', 170, 'eur')).toBeCloseTo(70.59, 2)
|
||||
})
|
||||
|
||||
it('has nothing to give for a row in the trip currency or one that never froze a rate', () => {
|
||||
expect(bookedInTrip(100, 'EUR', 1.17, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, null, 1.17, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, 'USD', 1, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, 'USD', null, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, 'USD', 0, 'EUR')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// #2525: the server freezes an entry rate from the trip currency's quote and converts the
|
||||
// settlement back with it. Read through the display currency's own quote, a separately
|
||||
// rounded figure, a same-day 12,345.67 USD bill on a euro trip came back as $12,346.05.
|
||||
describe('useExchangeRates anchored on the trip currency (#2525)', () => {
|
||||
beforeEach(() => {
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
|
||||
const quotes = (): void => {
|
||||
server.use(http.get(FX_URL, ({ request }) => {
|
||||
const base = new URL(request.url).searchParams.get('base')
|
||||
if (base === 'EUR') return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }, { quote: 'JPY', rate: 170 }])
|
||||
if (base === 'USD') return HttpResponse.json([{ quote: 'EUR', rate: 0.87732 }, { quote: 'JPY', rate: 149.1 }])
|
||||
return HttpResponse.error()
|
||||
}))
|
||||
}
|
||||
|
||||
it('converts the trip currency with its own quote, so a same-day bill reads as typed', async () => {
|
||||
quotes()
|
||||
const { result } = renderHook(() => useExchangeRates('usd', 'eur'))
|
||||
await waitFor(() => expect(result.current.convert(1, 'EUR')).toBe(1.1398))
|
||||
const shown = convertBooked(12345.67, 'USD', 1.1398, 'EUR', result.current.convert)
|
||||
expect(Math.round(shown * 100)).toBe(1234567)
|
||||
expect(Math.round(convertBooked(250, 'USD', 1.1398, 'EUR', result.current.convert) * 100)).toBe(25000)
|
||||
// A foreign row that never froze a rate goes through the same quote as well.
|
||||
expect(result.current.convert(170, 'JPY')).toBeCloseTo(1.1398, 10)
|
||||
// The display currency's own rates stay what the hook hands out.
|
||||
expect(result.current.rates).toEqual({ USD: 1, EUR: 0.87732, JPY: 149.1 })
|
||||
})
|
||||
|
||||
it('falls back to the display currency\'s quote while the trip\'s is missing', async () => {
|
||||
server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD'
|
||||
? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
||||
: HttpResponse.error())))
|
||||
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
|
||||
await waitFor(() => expect(result.current.convert(8, 'EUR')).toBe(10))
|
||||
expect(result.current.convert(5, 'USD')).toBe(5)
|
||||
})
|
||||
|
||||
it('fetches one set of rates when the trip is in the display currency', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get(FX_URL, () => { calls++; return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }]) }))
|
||||
const { result } = renderHook(() => useExchangeRates('EUR', 'EUR'))
|
||||
await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1))
|
||||
expect(calls).toBe(1)
|
||||
expect(result.current.displayPerTrip).toBe(1)
|
||||
})
|
||||
|
||||
// What the settlement is asked to label its figures with when the server has no quote
|
||||
// (`base_rate`): the same figure `convert` takes a trip amount to the display currency with.
|
||||
it('gives the display currency per 1 trip currency from the trip\'s quote', async () => {
|
||||
quotes()
|
||||
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
|
||||
await waitFor(() => expect(result.current.displayPerTrip).toBe(1.1398))
|
||||
expect(result.current.convert(1, 'EUR')).toBe(result.current.displayPerTrip)
|
||||
})
|
||||
|
||||
it('falls back to the display currency\'s quote for it, and is null without either', async () => {
|
||||
server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD'
|
||||
? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
||||
: HttpResponse.error())))
|
||||
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
|
||||
await waitFor(() => expect(result.current.displayPerTrip).toBe(1.25))
|
||||
|
||||
clearExchangeRateCache()
|
||||
let calls = 0
|
||||
server.use(http.get(FX_URL, () => { calls++; return HttpResponse.error() }))
|
||||
const none = renderHook(() => useExchangeRates('GBP', 'AUD'))
|
||||
await waitFor(() => expect(calls).toBe(2))
|
||||
expect(none.result.current.displayPerTrip).toBeNull()
|
||||
})
|
||||
|
||||
// A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the
|
||||
// settlement query takes, and sent as `base_rate` it would fail the whole read with a 400.
|
||||
it('is null for a figure the settlement query would refuse, which convert still uses', () => {
|
||||
localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() }))
|
||||
const high = renderHook(() => useExchangeRates('IRR', 'XAU'))
|
||||
expect(high.result.current.convert(1, 'XAU')).toBe(2.6e9)
|
||||
expect(high.result.current.displayPerTrip).toBeNull()
|
||||
// The other way round the figure falls under 1e-9.
|
||||
const low = renderHook(() => useExchangeRates('XAU', 'IRR'))
|
||||
expect(low.result.current.convert(2.6e9, 'IRR')).toBeCloseTo(1, 10)
|
||||
expect(low.result.current.displayPerTrip).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('crossRate', () => {
|
||||
it('reads units of one currency per another from rates on any base', () => {
|
||||
expect(crossRate({ EUR: 1, USD: 1.25, GBP: 0.8 }, 'USD', 'GBP')).toBe(1.5625)
|
||||
expect(crossRate({ EUR: 1, USD: 1.25 }, 'USD', 'EUR')).toBe(1.25)
|
||||
})
|
||||
|
||||
it('has nothing to say without both quotes', () => {
|
||||
expect(crossRate(null, 'USD', 'EUR')).toBeNull()
|
||||
expect(crossRate({ EUR: 1 }, 'USD', 'EUR')).toBeNull()
|
||||
expect(crossRate({ EUR: 0, USD: 1 }, 'USD', 'EUR')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tripAmountOf', () => {
|
||||
// Display USD, trip EUR: one euro buys 1.25 dollars and 100 yen.
|
||||
const live = (amount: number, from: string | null | undefined): number =>
|
||||
amount * ({ EUR: 1.25, USD: 1, JPY: 0.0125 } as Record<string, number>)[(from || 'USD').toUpperCase()]
|
||||
|
||||
it('is the booked amount for a row booked through the trip currency', () => {
|
||||
expect(tripAmountOf(801.76, 'USD', 1.17, 'EUR', live)).toBeCloseTo(685.26, 2)
|
||||
})
|
||||
|
||||
it('is the amount itself for a row in the trip currency', () => {
|
||||
expect(tripAmountOf(100, null, null, 'eur', live)).toBe(100)
|
||||
expect(tripAmountOf(100, 'EUR', 1, 'EUR', live)).toBe(100)
|
||||
})
|
||||
|
||||
it('counts a row that never froze a rate at today\'s', () => {
|
||||
expect(tripAmountOf(1000, 'JPY', 1, 'EUR', live)).toBeCloseTo(10, 10)
|
||||
// Without rates the conversion is the identity, as it is on the server.
|
||||
expect(tripAmountOf(1000, 'JPY', null, 'EUR', (a: number) => a)).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertedLine', () => {
|
||||
it('shows the entered amount and the booked trip amount for a bill booked through the trip currency', () => {
|
||||
expect(convertedLine(801.76, 'usd', 1.17, 'EUR', 'USD', 791.55)).toEqual({
|
||||
entered: { amount: 801.76, currency: 'USD' },
|
||||
into: { amount: 801.76 / 1.17, currency: 'EUR' },
|
||||
})
|
||||
expect(convertedLine(12000, 'JPY', 170, 'EUR', 'USD', 80.46)?.into).toEqual({ amount: 12000 / 170, currency: 'EUR' })
|
||||
})
|
||||
|
||||
it('has no line for a bill in the display currency that still reads as typed', () => {
|
||||
expect(convertedLine(12345.67, 'USD', 1.1398, 'EUR', 'USD', 12345.670000000002)).toBeNull()
|
||||
// Rounded to the display currency's own decimals.
|
||||
expect(convertedLine(1500, 'JPY', 0.0125, 'EUR', 'JPY', 1500.2)).toBeNull()
|
||||
})
|
||||
|
||||
it('has no line for an amount of 0 or a row that was never converted', () => {
|
||||
expect(convertedLine(0, 'USD', 1.17, 'EUR', 'USD', 0)).toBeNull()
|
||||
expect(convertedLine(0, 'JPY', 170, 'EUR', 'USD', 0)).toEqual({ entered: { amount: 0, currency: 'JPY' }, into: { amount: 0, currency: 'USD' } })
|
||||
expect(convertedLine(50, 'USD', 1, 'EUR', 'USD', 50)).toBeNull()
|
||||
expect(convertedLine(50, null, null, 'USD', '', 50)).toBeNull()
|
||||
})
|
||||
|
||||
it('goes straight to the display value for a row in the trip currency, or when the viewer reads the trip currency', () => {
|
||||
expect(convertedLine(100, null, null, 'EUR', 'USD', 113.98)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 113.98, currency: 'USD' } })
|
||||
expect(convertedLine(801.76, 'USD', 1.17, 'EUR', 'EUR', 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { BudgetFallbackFx } from '@trek/shared'
|
||||
import { currencyDecimals } from '../utils/formatters'
|
||||
|
||||
/**
|
||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||
@@ -9,6 +11,9 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
*/
|
||||
|
||||
const TTL_MS = 6 * 60 * 60 * 1000 // 6h
|
||||
// A hung request would otherwise hold every caller, the freeze-rates heal included,
|
||||
// for as long as the browser keeps the socket open.
|
||||
const FETCH_TIMEOUT_MS = 10_000
|
||||
const mem = new Map<string, { rates: Record<string, number>; ts: number }>()
|
||||
|
||||
function readCache(base: string): { rates: Record<string, number>; ts: number } | null {
|
||||
@@ -34,8 +39,12 @@ export async function fetchExchangeRates(base: string): Promise<Record<string, n
|
||||
const upper = (base || 'EUR').toUpperCase()
|
||||
const cached = readCache(upper)
|
||||
if (cached && Date.now() - cached.ts < TTL_MS) return cached.rates
|
||||
// A controller rather than AbortSignal.timeout, which Safari only has from 16 on;
|
||||
// without it the call would throw and never fetch at all.
|
||||
const stop = new AbortController()
|
||||
const timer = setTimeout(() => stop.abort(), FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
|
||||
const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`, { signal: stop.signal })
|
||||
.then(r => r.json()) as Array<{ quote?: string; rate?: number }>
|
||||
if (!Array.isArray(d)) return cached?.rates ?? null
|
||||
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
|
||||
@@ -49,9 +58,58 @@ export async function fetchExchangeRates(base: string): Promise<Record<string, n
|
||||
return rates
|
||||
} catch {
|
||||
return cached?.rates ?? null // offline → stale beats nothing
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached rates for `base`, and only while they are fresh (younger than the TTL). This is
|
||||
* the table the client lends the server to freeze a rate with (`fallback_fx`): a stale one
|
||||
* would pin a figure from another day on the row for good.
|
||||
*/
|
||||
export function freshRates(base: string): Record<string, number> | null {
|
||||
const cached = readCache((base || 'EUR').toUpperCase())
|
||||
return cached && Date.now() - cached.ts < TTL_MS ? cached.rates : null
|
||||
}
|
||||
|
||||
/** `freshRates`, fetching first when the cache is not fresh. Null when the fetch fails too. */
|
||||
export async function fetchFreshRates(base: string): Promise<Record<string, number> | null> {
|
||||
await fetchExchangeRates(base)
|
||||
return freshRates(base)
|
||||
}
|
||||
|
||||
const CURRENCY_CODE = /^[A-Z]{3}$/
|
||||
|
||||
/**
|
||||
* Whether a rate is one the server's contract takes (budget.schema.ts): finite and from
|
||||
* 1e-9 to 1e9, for a lent `fallback_fx` rate as for the settlement's `base_rate`. Anything
|
||||
* else fails the whole request with a 400, so the client leaves it out instead.
|
||||
*/
|
||||
export function isSendableRate(rate: number | null | undefined): rate is number {
|
||||
return rate != null && Number.isFinite(rate) && rate >= 1e-9 && rate <= 1e9
|
||||
}
|
||||
|
||||
/**
|
||||
* `data` with its currency's rate against the trip currency lent alongside, for a write the
|
||||
* server freezes a rate on (an expense, a transfer). The server prefers its own rate and
|
||||
* uses this one only when it has none, which is the setup where its fetch fails and the
|
||||
* browser's does not. Sent only for a currency foreign to the trip with a fresh quote in
|
||||
* the cache, and never beside an explicit `exchange_rate`; anything else comes back as it
|
||||
* was. A rate outside what the contract accepts would fail the whole write, so it is left out.
|
||||
*/
|
||||
export function withFallbackFx<T extends { currency?: string | null; exchange_rate?: number | null }>(
|
||||
data: T,
|
||||
tripCurrency: string | null | undefined,
|
||||
): T & { fallback_fx?: BudgetFallbackFx } {
|
||||
const trip = (tripCurrency || '').toUpperCase()
|
||||
const cur = (data.currency || '').toUpperCase()
|
||||
if (!CURRENCY_CODE.test(trip) || !CURRENCY_CODE.test(cur) || cur === trip || data.exchange_rate != null) return data
|
||||
const rate = freshRates(trip)?.[cur]
|
||||
if (!isSendableRate(rate)) return data
|
||||
return { ...data, fallback_fx: { base: trip, rates: { [cur]: rate } } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a booked amount the way the server's settlement does (#1335, #1445): the FX
|
||||
* rate frozen when the expense or transfer was entered wins over today's rate, so a cost
|
||||
@@ -59,6 +117,13 @@ export async function fetchExchangeRates(base: string): Promise<Record<string, n
|
||||
* "units of the row's currency per 1 *trip* currency", which is why the amount goes to the
|
||||
* trip currency first and only then, live, to whatever currency the viewer reads in.
|
||||
*
|
||||
* That holds for an amount in the viewer's own currency too. A bill of 801.76 USD on a euro
|
||||
* trip is booked at 685.26 EUR, and a viewer reading in dollars sees those euros at today's
|
||||
* rate, which is what the balances and settle-up offer them as well (#2525). With the
|
||||
* conversion anchored on the trip currency (useExchangeRates' second argument) that is
|
||||
* the bill exactly as typed until the rate moves; after that it is what the bill counts
|
||||
* as, and convertedLine explains the difference.
|
||||
*
|
||||
* `convertLive` is the hook's own `convert`. Passing it in keeps this a plain function that
|
||||
* both shells and the PDF can share, rather than three copies of the same three branches.
|
||||
*/
|
||||
@@ -70,13 +135,89 @@ export function convertBooked(
|
||||
convertLive: (amount: number, from: string | null | undefined) => number,
|
||||
): number {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip)
|
||||
if (inTrip != null) return convertLive(inTrip, trip)
|
||||
// A NULL currency means the trip's own, and then there was never anything to freeze.
|
||||
return convertLive(amount, (rowCurrency || trip).toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount a row was booked at in the trip currency, when it was booked through one: a
|
||||
* foreign currency with a frozen rate. Null for a row in the trip currency, and for one
|
||||
* written before the freeze existed. A rate of exactly 1 is the column default, not a booked
|
||||
* rate, and those rows still convert live, as they always did.
|
||||
*
|
||||
* The lists print it between the amount entered and the amount shown, so a row whose value
|
||||
* moved with the rate shows where the move came from instead of a figure nobody typed.
|
||||
*/
|
||||
export function bookedInTrip(
|
||||
amount: number,
|
||||
rowCurrency: string | null | undefined,
|
||||
frozenRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
): number | null {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
const cur = (rowCurrency || trip).toUpperCase()
|
||||
if (cur === trip) return convertLive(amount, trip)
|
||||
// A rate of exactly 1 is the column default, not a booked rate: rows written before the
|
||||
// freeze existed carry it, and those still convert live, as they always did.
|
||||
if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return convertLive(amount / frozenRate, trip)
|
||||
return convertLive(amount, cur)
|
||||
if (cur === trip) return null
|
||||
if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return amount / frozenRate
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* What an amount of a row counts as in the trip currency, the figure the settlement nets:
|
||||
* the booked amount for a row booked through the trip currency, the amount itself for one
|
||||
* in it, and today's rate for a foreign row that never froze one, the way the server
|
||||
* counts that row too.
|
||||
*/
|
||||
export function tripAmountOf(
|
||||
amount: number,
|
||||
rowCurrency: string | null | undefined,
|
||||
frozenRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
convertLive: (amount: number, from: string | null | undefined) => number,
|
||||
): number {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip)
|
||||
if (inTrip != null) return inTrip
|
||||
const cur = (rowCurrency || trip).toUpperCase()
|
||||
if (cur === trip) return amount
|
||||
const perTrip = convertLive(1, trip)
|
||||
return perTrip > 0 ? convertLive(amount, cur) / perTrip : amount
|
||||
}
|
||||
|
||||
/** An amount and the currency it is in. */
|
||||
export interface MoneyPart { amount: number; currency: string }
|
||||
|
||||
/**
|
||||
* The line under an amount the lists show converted (#2525): what was entered, then where
|
||||
* it went. For a row booked through the trip currency that is the booked trip amount,
|
||||
* "$801.76 -> 685,26 EUR", since the display value beside it already shows the last step.
|
||||
* Any other foreign row goes straight to the display value, "100,00 EUR -> $113.98".
|
||||
*
|
||||
* Null when there is nothing to explain: a row in the display currency that still reads
|
||||
* as typed, or an amount of 0. `shown` is the display value printed beside the line.
|
||||
* Every list uses it, so the desktop and phone shells and the shared page can never
|
||||
* explain the same row two ways.
|
||||
*/
|
||||
export function convertedLine(
|
||||
amount: number,
|
||||
rowCurrency: string | null | undefined,
|
||||
frozenRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
displayCurrency: string,
|
||||
shown: number,
|
||||
): { entered: MoneyPart; into: MoneyPart } | null {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
const display = (displayCurrency || trip).toUpperCase()
|
||||
const cur = (rowCurrency || trip).toUpperCase()
|
||||
const entered = { amount, currency: cur }
|
||||
const inTrip = display !== trip && amount !== 0 ? bookedInTrip(amount, cur, frozenRate, trip) : null
|
||||
if (inTrip != null) {
|
||||
const unit = 10 ** currencyDecimals(display)
|
||||
if (cur === display && Math.round(shown * unit) === Math.round(amount * unit)) return null
|
||||
return { entered, into: { amount: inTrip, currency: trip } }
|
||||
}
|
||||
return cur === display ? null : { entered, into: { amount: shown, currency: display } }
|
||||
}
|
||||
|
||||
/** Test-only: the module-level cache outlives a vitest file's individual tests. */
|
||||
@@ -89,30 +230,72 @@ export function clearExchangeRateCache(): void {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function useExchangeRates(base: string) {
|
||||
const upper = (base || 'EUR').toUpperCase()
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(() => readCache(upper)?.rates ?? null)
|
||||
/** Rates for one base, from the cache first and then the network; null for no base. */
|
||||
function useRatesFor(base: string | null): Record<string, number> | null {
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(() => (base ? readCache(base)?.rates ?? null : null))
|
||||
|
||||
useEffect(() => {
|
||||
const cached = readCache(upper)
|
||||
if (!base) { setRates(null); return }
|
||||
const cached = readCache(base)
|
||||
if (cached) setRates(cached.rates)
|
||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||
let cancelled = false
|
||||
fetchExchangeRates(upper).then(r => {
|
||||
fetchExchangeRates(base).then(r => {
|
||||
if (!cancelled && r) setRates(r)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [upper])
|
||||
}, [base])
|
||||
|
||||
return rates
|
||||
}
|
||||
|
||||
/**
|
||||
* Units of `to` per 1 `from`, from rates quoted against any base (each rate is units of
|
||||
* that currency per 1 base). Null when either quote is missing.
|
||||
*/
|
||||
export function crossRate(rates: Record<string, number> | null | undefined, to: string, from: string): number | null {
|
||||
const rTo = rates?.[to]
|
||||
const rFrom = rates?.[from]
|
||||
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null
|
||||
}
|
||||
|
||||
/**
|
||||
* `tripCurrency`, when given, anchors the conversion on the trip currency's own quote
|
||||
* (#2525). That is the quote the server froze every entry rate from and converts the
|
||||
* settlement with, so an amount booked through the trip currency comes back exactly as
|
||||
* entered while the rate has not moved. The display currency's own quote is a separately
|
||||
* rounded figure, not the exact inverse: through it a same-day 12,345.67 USD bill on a
|
||||
* euro trip read as $12,346.05. Both quotes are cached like any other, so the anchor works
|
||||
* offline too; the display quote only stands in while the trip's is missing.
|
||||
*/
|
||||
export function useExchangeRates(base: string, tripCurrency?: string | null) {
|
||||
const upper = (base || 'EUR').toUpperCase()
|
||||
const anchor = (tripCurrency || upper).toUpperCase()
|
||||
const rates = useRatesFor(upper)
|
||||
const anchorRates = useRatesFor(anchor === upper ? null : anchor)
|
||||
|
||||
const convert = useCallback(
|
||||
(amount: number, from: string | null | undefined): number => {
|
||||
const f = (from || upper).toUpperCase()
|
||||
if (f === upper || !rates) return amount
|
||||
const r = rates[f]
|
||||
if (f === upper) return amount
|
||||
const viaAnchor = crossRate(anchorRates, upper, f)
|
||||
if (viaAnchor != null) return amount * viaAnchor
|
||||
const r = rates?.[f]
|
||||
return r && r > 0 ? amount / r : amount
|
||||
},
|
||||
[rates, upper],
|
||||
[rates, anchorRates, upper],
|
||||
)
|
||||
|
||||
return { rates, convert }
|
||||
// Units of the display currency per 1 trip currency, from the same quotes `convert` uses.
|
||||
// The settlement takes it as `base_rate` to answer in the display currency when the
|
||||
// server has no quote of its own. Null while neither quote is known, and null for a
|
||||
// figure the settlement query refuses (a trip in XAU shown in IRR runs past 1e9): sent
|
||||
// along, it would fail the whole read with a 400, healthy server or not.
|
||||
const perTrip = rates?.[anchor]
|
||||
const quoted = anchor === upper
|
||||
? 1
|
||||
: crossRate(anchorRates, upper, anchor) ?? (perTrip && perTrip > 0 ? 1 / perTrip : null)
|
||||
const displayPerTrip = isSendableRate(quoted) ? quoted : null
|
||||
|
||||
return { rates, convert, displayPerTrip }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { buildDayRouteRuns, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
|
||||
@@ -100,7 +100,12 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(chunk.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight())
|
||||
for (const leg of r.legs) allLegs.push({ ...leg, mode })
|
||||
// Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the
|
||||
// segment, which is how the phone timeline finds it (#2501).
|
||||
r.legs.forEach((leg, k) => {
|
||||
const hotelBookend = chunk[k + 1] ? hotelBookendOf(chunk[k], chunk[k + 1]) : undefined
|
||||
allLegs.push(hotelBookend ? { ...leg, mode, hotelBookend } : { ...leg, mode })
|
||||
})
|
||||
if (r.vias) allVias.push(...r.vias)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') throw err
|
||||
|
||||
@@ -36,6 +36,7 @@ import { startConnectivityProbe } from './sync/connectivity'
|
||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
|
||||
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
|
||||
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
|
||||
|
||||
maybeInstallTouchDragPolyfill()
|
||||
startConnectivityProbe()
|
||||
@@ -43,6 +44,9 @@ startConnectivityProbe()
|
||||
requestPersistentStorage()
|
||||
// Event handlers and async code never reach a boundary; this is where they land.
|
||||
installGlobalErrorHandlers()
|
||||
// A page the precache keeps under a stylesheet's or a script's name would be
|
||||
// handed on to every later release (#2524).
|
||||
schedulePrecacheRepair()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Cloud, CloudDrizzle, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, hidesOnMiddleDay, parseTimeToMinutes } from '../../../../utils/dayMerge'
|
||||
import {
|
||||
TRANSPORT_TYPES, getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, getTransportRouteEndpoints, hidesOnMiddleDay,
|
||||
parseTimeToMinutes,
|
||||
} from '../../../../utils/dayMerge'
|
||||
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
|
||||
@@ -73,10 +76,11 @@ const sameCoord = (a: [number, number], b: [number, number]): boolean =>
|
||||
|
||||
/**
|
||||
* Map the day's merged items to render rows and slot a travel-time connector
|
||||
* after every located place whose next located stop is again a place. Segments
|
||||
* are matched by their exact waypoint coordinates (the calculator echoes the
|
||||
* input waypoints back on each leg), so hotel-bookend or transport legs in the
|
||||
* pool simply never match and no index bookkeeping is needed.
|
||||
* under the row each drawn leg belongs to. Segments are matched by their exact
|
||||
* waypoint coordinates (the calculator echoes the input waypoints back on each
|
||||
* leg), so no index bookkeeping is needed. The hotel bookends are left out up
|
||||
* front: they belong to the day's edges, and a stop planned on the hotel's own
|
||||
* spot would otherwise pair with them (#2501).
|
||||
*/
|
||||
export function buildPlanRows(opts: {
|
||||
merged: MergedItem[]
|
||||
@@ -85,7 +89,7 @@ export function buildPlanRows(opts: {
|
||||
dayId: number
|
||||
}): PlanRow[] {
|
||||
const { merged, reservations, routeSegments, dayId } = opts
|
||||
const pool = [...routeSegments]
|
||||
const pool = routeSegments.filter(s => !s.hotelBookend)
|
||||
const takeSegment = (from: [number, number], to: [number, number]): RouteSegment | null => {
|
||||
const idx = pool.findIndex(s => sameCoord(s.from, from) && sameCoord(s.to, to))
|
||||
return idx >= 0 ? pool.splice(idx, 1)[0] : null
|
||||
@@ -121,31 +125,61 @@ export function buildPlanRows(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
const coordOf = (row: PlanRow): [number, number] | null =>
|
||||
row.kind === 'place' && row.assignment.place?.lat != null && row.assignment.place?.lng != null
|
||||
? [row.assignment.place.lat, row.assignment.place.lng]
|
||||
: null
|
||||
|
||||
const out: PlanRow[] = []
|
||||
// Walk the rows the way the route calc walks the day (buildDayRouteRuns). A
|
||||
// located place is a waypoint, possibly across notes. A located booking ends the
|
||||
// drive before it at its departure point and starts the next one at its arrival
|
||||
// point, so the drive from the arrival airport to the first stop gets its
|
||||
// connector too (#2502). Each leg goes under the row it leaves from, the place or
|
||||
// the booking it landed with, as on the desktop day plan.
|
||||
//
|
||||
// The route rides straight past a booking it has no location for, a taxi saved
|
||||
// without its stops or a concert, and draws the drive through it. The desktop
|
||||
// shows that leg under the booking, so it goes there, and it keeps the mode menu
|
||||
// of the stop it left, since that stop's mode is the one it was routed in. Only
|
||||
// transport bookings count, as in the route calc: a transit booking changed to
|
||||
// another type in the booking form keeps its stations, but the route ignores it.
|
||||
//
|
||||
// A booking whose station is the stop itself leaves a leg that goes nowhere: the
|
||||
// transit planner saves a journey between two of the day's places at their own
|
||||
// coordinates. That is no walk and no drive, so it gets no connector.
|
||||
type Origin = { at: [number, number]; row: number; assignmentId?: number }
|
||||
const legs = new Map<number, { seg: RouteSegment; assignmentId?: number }>()
|
||||
const connect = (origin: Origin | null, to: [number, number], toBooking = false) => {
|
||||
if (!origin) return
|
||||
const seg = takeSegment(origin.at, to)
|
||||
const fromBooking = origin.assignmentId == null
|
||||
if (!seg || ((toBooking || fromBooking) && sameCoord(origin.at, to))) return
|
||||
legs.set(origin.row, { seg, assignmentId: origin.assignmentId })
|
||||
}
|
||||
let prev: Origin | null = null
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
const row = base[i]
|
||||
out.push(row)
|
||||
const from = coordOf(row)
|
||||
if (!from) continue
|
||||
// Next located stop: a following place connects (possibly across notes);
|
||||
// any transport/transit in between means that hop is the ride, not a walk.
|
||||
let seg: RouteSegment | null = null
|
||||
for (let j = i + 1; j < base.length; j++) {
|
||||
const next = base[j]
|
||||
if (next.kind === 'transport' || next.kind === 'transit') break
|
||||
const to = coordOf(next)
|
||||
if (to) { seg = takeSegment(from, to); break }
|
||||
}
|
||||
if (row.kind === 'place') {
|
||||
const place = row.assignment.place
|
||||
if (place?.lat == null || place?.lng == null) continue
|
||||
const at: [number, number] = [place.lat, place.lng]
|
||||
connect(prev, at)
|
||||
// The leg's mode is stored on its ORIGIN place assignment (#1281), so carry
|
||||
// that id for the tap-to-change menu.
|
||||
if (seg) out.push({ key: `conn-${row.key}`, kind: 'conn', seg, assignmentId: row.kind === 'place' ? row.assignment.id : undefined })
|
||||
prev = { at, row: i, assignmentId: row.assignment.id }
|
||||
} else if (row.kind === 'transport' || row.kind === 'transit') {
|
||||
const { from, to } = TRANSPORT_TYPES.has(row.res.type)
|
||||
? getTransportRouteEndpoints(row.res, dayId)
|
||||
: { from: null, to: null }
|
||||
if (!from && !to) {
|
||||
if (prev) prev = { ...prev, row: i }
|
||||
continue
|
||||
}
|
||||
return out
|
||||
// Only out of a stop: two bookings back to back are no drive (#1394).
|
||||
if (from && prev?.assignmentId != null) connect(prev, [from.lat, from.lng], true)
|
||||
prev = to ? { at: [to.lat, to.lng], row: i } : null
|
||||
}
|
||||
}
|
||||
|
||||
return base.flatMap((row, i): PlanRow[] => {
|
||||
const leg = legs.get(i)
|
||||
return leg ? [row, { key: `conn-${row.key}`, kind: 'conn', seg: leg.seg, assignmentId: leg.assignmentId }] : [row]
|
||||
})
|
||||
}
|
||||
|
||||
export interface HotelChip {
|
||||
@@ -184,10 +218,18 @@ export interface HotelLegs { top: HotelLeg | null; bottom: HotelLeg | null }
|
||||
/**
|
||||
* The two accommodation bookend legs of a day: the drive from the day's hotel to
|
||||
* the first stop (top) and from the last stop back to the hotel (bottom). The
|
||||
* route calc already produced these via withHotelBookends — honouring the
|
||||
* optimize-from-accommodation setting and the should-draw gates — so we just
|
||||
* locate the pooled segment that starts (top) / ends (bottom) at the hotel's
|
||||
* coordinates. Its presence is exactly the signal that the leg should be drawn.
|
||||
* route calc already produced these via withHotelBookends, honouring the
|
||||
* optimize-from-accommodation setting and the should-draw gates, and tagged
|
||||
* them, so we just pick the tagged segments. Their presence is exactly the signal
|
||||
* that the leg should be drawn.
|
||||
*
|
||||
* Not by coordinates alone: a stop planned on the hotel's own spot starts and
|
||||
* ends legs there too. Matched that way, the drive from that stop to the next one
|
||||
* sat above a day that opens with a flight, and showed a second time in its own
|
||||
* place (#2501); an earlier drive onto that spot sat at the day's end (#2476).
|
||||
* The tag says which end of the day a leg is, and its hotel end still has to be
|
||||
* this stay: the calc keeps the last day's legs until the new day has routed, so
|
||||
* right after a day switch the tagged legs can belong to another hotel.
|
||||
*/
|
||||
export function hotelLegsForDay(
|
||||
day: Day,
|
||||
@@ -196,24 +238,15 @@ export function hotelLegsForDay(
|
||||
routeSegments: RouteSegment[],
|
||||
): HotelLegs {
|
||||
const { morning, evening } = getDayBookendHotels(day, days, accommodations)
|
||||
const legAt = (a: Accommodation | undefined, end: 'from' | 'to'): HotelLeg | null => {
|
||||
const legOf = (a: Accommodation | undefined, bookend: 'morning' | 'evening'): HotelLeg | null => {
|
||||
if (!a || a.place_lat == null || a.place_lng == null) return null
|
||||
const coord: [number, number] = [a.place_lat, a.place_lng]
|
||||
// The morning leg is the first segment leaving the hotel, the evening leg the
|
||||
// LAST one reaching it. Taking the first for both put an earlier drive that
|
||||
// happens to end on the evening hotel's spot under the day's end, instead of
|
||||
// the drive that closes the day (#2476).
|
||||
const seg = end === 'from'
|
||||
? routeSegments.find(s => sameCoord(s.from, coord))
|
||||
: [...routeSegments].reverse().find(s => sameCoord(s.to, coord))
|
||||
const at: [number, number] = [a.place_lat, a.place_lng]
|
||||
const seg = routeSegments.find(s => s.hotelBookend === bookend && sameCoord(bookend === 'morning' ? s.from : s.to, at))
|
||||
return seg ? { seg, name: accommodationName(a) } : null
|
||||
}
|
||||
const top = legAt(morning, 'from')
|
||||
const bottom = legAt(evening, 'to')
|
||||
// A moving day without stops is one drive from one hotel to the next (#1297): it
|
||||
// both leaves the morning hotel and reaches the evening one, so it shows once, at
|
||||
// the top, instead of again at the bottom (#2476).
|
||||
return { top, bottom: top && bottom && bottom.seg === top.seg ? null : bottom }
|
||||
// A moving day without stops is one drive from one hotel to the next (#1297). The
|
||||
// calc tags it as the morning leg, so it shows once, at the top (#2476).
|
||||
return { top: legOf(morning, 'morning'), bottom: legOf(evening, 'evening') }
|
||||
}
|
||||
|
||||
/** The day headline as city pills — a "Tokyo → Kyoto" title becomes two pills with an arrow. */
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader }
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
|
||||
import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { saveWithReceipts } from '../../../../components/Budget/receiptUploads'
|
||||
import { splitShareLabel, useExpenseFx } from '../../../../components/Budget/expenseFx'
|
||||
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
|
||||
import { localToday } from '../../../../components/Planner/today'
|
||||
@@ -58,8 +58,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { addBudgetItem, updateBudgetItem, deleteBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
// A saved expense without a currency opens in the trip's own (#2525), as on desktop.
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
|
||||
|
||||
// Internal open flag so the exit animation still plays even though the parent
|
||||
// unmounts us on close.
|
||||
@@ -75,13 +76,13 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [currency, setCurrency] = useState(editingCurrency)
|
||||
const [day, setDay] = useState(editing?.expense_date || localToday())
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175), same
|
||||
// as the desktop modal: a saved 4,90 must reopen as "4,90", not "4,9". A
|
||||
// prefill has no currency of its own and is read as `base`.
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
|
||||
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
|
||||
return ''
|
||||
})
|
||||
@@ -151,6 +152,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const fx = preview(totalNum, currency)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
@@ -404,12 +406,18 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
</div>
|
||||
|
||||
{/* CONVERSION HINT */}
|
||||
{currency !== base && totalNum !== 0 && (
|
||||
{fx && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-[0.71875rem] text-m-muted">
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
{fx.inTrip != null && <>
|
||||
<span className="text-m-faint">→</span>
|
||||
<span className={fx.shown == null ? 'font-semibold text-m-ink' : undefined}>{formatMoney(fx.inTrip, tripCur, locale)}</span>
|
||||
</>}
|
||||
{fx.shown != null && <>
|
||||
<span className="text-m-faint">≈</span>
|
||||
<span className="font-semibold text-m-ink">{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="font-semibold text-m-ink">{formatMoney(fx.shown, base, locale)}</span>
|
||||
<span className="text-m-faint">· {t('costs.liveRate')}</span>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -646,12 +654,12 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<div className="mt-2 text-[0.71875rem]">
|
||||
{splitMode === 'equally' ? (
|
||||
<span className="text-m-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`font-semibold ${customBalanced ? 'text-[color:var(--m-st-confirmed)]' : 'text-[color:var(--m-st-danger)]'}`}>
|
||||
{customBalanced
|
||||
? t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })
|
||||
? t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })
|
||||
: `${sym(currency)}${splitSum.toFixed(2)} / ${sym(currency)}${totalNum.toFixed(2)}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BookingCodeInput } from '../../../../components/shared/BookingCode'
|
||||
import AirportSelect, { type Airport } from '../../../../components/Planner/AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from '../../../../components/Planner/LocationSelect'
|
||||
import { toLocationPicks } from '../../../../components/Planner/locationPicks'
|
||||
import { importedPriceEntry } from '../../../../components/Planner/importedPrice'
|
||||
import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
@@ -572,11 +573,8 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
// Imported booking → auto-create the linked cost from the parsed price
|
||||
// (only on create and only when a price is present).
|
||||
if (!res && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
payload.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) payload.create_budget_entry = entry
|
||||
}
|
||||
const saved = await saveTransport(payload)
|
||||
// Persist the traveler assignment once we have the reservation id (from the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { convertBooked } from '../../../../hooks/useExchangeRates'
|
||||
import { convertBooked, convertedLine } from '../../../../hooks/useExchangeRates'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
|
||||
@@ -7,14 +7,15 @@ import {
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
|
||||
import { useExchangeRates, withFallbackFx } from '../../../../hooks/useExchangeRates'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { amountToInputString, formatMoney } from '../../../../utils/formatters'
|
||||
import { downloadBlob, openFile } from '../../../../utils/fileDownload'
|
||||
import { budgetApi } from '../../../../api/client'
|
||||
import MCostSheet from '../sheets/MCostSheet'
|
||||
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
|
||||
import { finalBudgetFor, finalBudgetSources, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { useFreezeMissingRates } from '../../../../components/Budget/useFreezeMissingRates'
|
||||
import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
@@ -27,8 +28,8 @@ import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/
|
||||
import { CountPill, TabScroller } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import {
|
||||
baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, currencyOf,
|
||||
dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, tint,
|
||||
baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals,
|
||||
dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, lineOf, memberShareOf, tint,
|
||||
type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse,
|
||||
} from './costsModel'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
@@ -55,18 +56,20 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, convert }), [me, tripCurrency, convert])
|
||||
// Anchored on the trip currency's quote, the one the server books with (#2525).
|
||||
const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency)
|
||||
const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert])
|
||||
|
||||
const [settlement, setSettlement] = useState<CostsSettlementResponse | null>(null)
|
||||
// A failed settlement read leaves `settlement` null, and the final budget would
|
||||
// read that as "the trip cost nobody anything", a claim we cannot make.
|
||||
const [settlementError, setSettlementError] = useState(false)
|
||||
// Sends the browser's own figure for the display currency, as CostsPanel.tsx does.
|
||||
const loadSettlement = useCallback(() => {
|
||||
budgetApi.settlement(tripId, base)
|
||||
budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
|
||||
.then(s => { setSettlement(s); setSettlementError(false) })
|
||||
.catch(() => setSettlementError(true))
|
||||
}, [tripId, base])
|
||||
}, [tripId, base, tripCurrency, displayPerTrip])
|
||||
|
||||
// Mirrors CostsPanel.tsx: items reload on trip change, settlement reloads on
|
||||
// trip/base change; further refreshes are explicit after each mutation below
|
||||
@@ -78,6 +81,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
useEffect(() => {
|
||||
loadSettlement()
|
||||
}, [loadSettlement])
|
||||
useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement })
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [segment, setSegment] = useState<CostsSegment>('all')
|
||||
@@ -339,7 +343,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
: <ChevronDown size={13} strokeWidth={2.2} className="flex-none text-m-faint" />}
|
||||
</button>
|
||||
{open && (
|
||||
<FinalBudgetBreakdown row={row} items={budgetItems} base={base} locale={locale} t={t} personName={personName} />
|
||||
<FinalBudgetBreakdown row={row} items={budgetItems} ctx={ctx} base={base} locale={locale} t={t} personName={personName} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -570,6 +574,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
}}
|
||||
tripId={tripId}
|
||||
base={base}
|
||||
tripCurrency={tripCurrency}
|
||||
people={tripMembers}
|
||||
me={me}
|
||||
toast={toast}
|
||||
@@ -622,8 +627,8 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
}) {
|
||||
const meta = catMeta(item.category)
|
||||
const Icon = meta.Icon
|
||||
const cur = currencyOf(item, ctx)
|
||||
const total = baseTotal(item, ctx)
|
||||
const line = lineOf(item.total_price || 0, item, ctx, total)
|
||||
const unfinished = isUnfinished(item, ctx)
|
||||
const borderColor = tint(meta.color, 0.55)
|
||||
const members = item.members || []
|
||||
@@ -670,9 +675,9 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
{line && (
|
||||
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
{formatMoney(item.total_price, cur, locale)} {'→'} {formatMoney(total, base, locale)}
|
||||
{formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
|
||||
</div>
|
||||
)}
|
||||
{members.length > 0 && (
|
||||
@@ -756,7 +761,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
}) {
|
||||
const cur = (settlement.currency || base).toUpperCase()
|
||||
// At the rate it was settled at, not today's (#1445), matching the desktop ledger.
|
||||
const amount = convertBooked(settlement.amount, settlement.currency, settlement.exchange_rate, ctx.tripCurrency, ctx.convert)
|
||||
// A transfer without a currency was entered in the display currency, not the trip's.
|
||||
const amount = convertBooked(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, ctx.convert)
|
||||
const line = convertedLine(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, base, amount)
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-[6px]">
|
||||
<div className="relative min-w-0 flex-1 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-card px-3 py-[12px]">
|
||||
@@ -767,9 +774,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{t('costs.payment')}</div>
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-faint">{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}</div>
|
||||
{cur !== base && (
|
||||
{line && (
|
||||
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
{formatMoney(settlement.amount, cur, locale)} {'→'} {formatMoney(amount, base, locale)}
|
||||
{formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -799,9 +806,10 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
* with the balances; each line is signed by what it does to the final, so the
|
||||
* column reads as a subtraction and the rows add up to the line above them.
|
||||
*/
|
||||
function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
|
||||
function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }: {
|
||||
row: BudgetParticipantFinal
|
||||
items: BudgetItem[]
|
||||
ctx: CostsCtx
|
||||
base: string
|
||||
locale: string
|
||||
t: TFn
|
||||
@@ -810,6 +818,12 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + formatMoney(Math.abs(v), base, locale)
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, items)
|
||||
const transfer = (fromId: number, toId: number) => `${personName(fromId)} → ${personName(toId)}`
|
||||
// Beside a converted row's name, what was entered, as the list above names it (#2525).
|
||||
const nameOf = (itemId: number, name: string, shown: number) => {
|
||||
const e = items.find(i => i.id === itemId)
|
||||
const entered = e ? lineOf(paidByUser(e, row.user_id), e, ctx, shown)?.entered : null
|
||||
return entered ? `${name} · ${formatMoney(entered.amount, entered.currency, locale)}` : name
|
||||
}
|
||||
const line = (key: string, label: string, value: string) => (
|
||||
<div key={key} className="flex items-baseline gap-2 py-[2px] font-geist text-[0.6875rem]">
|
||||
<span className="min-w-0 flex-1 truncate text-m-muted">{label}</span>
|
||||
@@ -828,7 +842,7 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
|
||||
{line('expenses', t('costs.finalExpenses'), signed(row.expenses))}
|
||||
{line('reimbursed', t('costs.finalReimbursed'), signed(-row.reimbursed))}
|
||||
{line('pending', t('costs.finalPending'), signed(-row.pending))}
|
||||
{section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, r.name, signed(r.amount))))}
|
||||
{section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, nameOf(r.item_id, r.name, r.amount), signed(r.amount))))}
|
||||
{section(t('costs.finalReimbursed'), moved.map(r => line(`s${r.settlement_id}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
|
||||
{section(t('costs.finalPending'), outstanding.map((r, i) => line(`f${i}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
|
||||
</div>
|
||||
@@ -871,12 +885,13 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: {
|
||||
* (editable like an expense's, unlike the legacy created_at-only date, see
|
||||
* `settlementDate` in CostsPanel.helpers.ts).
|
||||
*/
|
||||
function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toast, t, onSaved }: {
|
||||
function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, people, me, toast, t, onSaved }: {
|
||||
open: boolean
|
||||
editing: CostsSettlement | null
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
base: string
|
||||
tripCurrency: string
|
||||
people: TripMember[]
|
||||
me: number
|
||||
toast: { error: (message: string) => void }
|
||||
@@ -907,7 +922,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa
|
||||
const save = async () => {
|
||||
if (!valid || saving) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }
|
||||
const data = withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }, tripCurrency)
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BudgetParticipantFinal, CostCategory } from '@trek/shared'
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared'
|
||||
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
|
||||
import { convertBooked } from '../../../../hooks/useExchangeRates'
|
||||
import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates'
|
||||
import { currencyDecimals } from '../../../../utils/formatters'
|
||||
import type { BudgetItem } from '../../../../types'
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface CostsCtx {
|
||||
me: number
|
||||
/** The trip's own currency — what a NULL `budget_items.currency` means. */
|
||||
tripCurrency: string
|
||||
/** The currency everything is shown in, the base `convert` converts to. */
|
||||
displayCurrency: string
|
||||
convert: (amount: number, currency: string | null | undefined) => number
|
||||
}
|
||||
|
||||
@@ -32,6 +34,15 @@ export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
|
||||
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert)
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under an amount of this expense the list shows converted, what was entered and
|
||||
* where it went (#2525); null when there is nothing to explain. `shown` is the display
|
||||
* value printed beside it.
|
||||
*/
|
||||
export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) {
|
||||
return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown)
|
||||
}
|
||||
|
||||
/** Expense total converted to the display/base currency, at its booked rate. */
|
||||
export function baseTotal(e: BudgetItem, ctx: CostsCtx): number {
|
||||
return booked(e.total_price || 0, e, ctx)
|
||||
@@ -105,6 +116,10 @@ export interface CostsSettlementResponse {
|
||||
settlements: CostsSettlement[]
|
||||
/** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */
|
||||
finalBudgets: BudgetParticipantFinal[]
|
||||
/** The currency the figures are in: the display currency, or the trip's own without a quote for it. */
|
||||
currency?: string
|
||||
/** Rows no rate could convert, left out of every figure above. */
|
||||
unconverted?: BudgetUnconverted
|
||||
}
|
||||
|
||||
// ── hero / tile totals (spec §3.1-§3.3) ────────────────────────────────────
|
||||
@@ -315,12 +330,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
}
|
||||
}
|
||||
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', `Amount (${opts.base})`, 'Note']
|
||||
// Read in another currency than the trip's, what each row counts as in the trip currency
|
||||
// too, the figure every sum is built from (#2525). Same columns as the desktop export.
|
||||
const trip = opts.ctx.tripCurrency.toUpperCase()
|
||||
const tripCol = trip !== opts.base.toUpperCase()
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? [`Amount (${trip})`] : []), `Amount (${opts.base})`, 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of sorted) {
|
||||
const cur = currencyOf(e, opts.ctx)
|
||||
const note = readUserNote(e)
|
||||
const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert)
|
||||
rows.push(
|
||||
[
|
||||
esc(fmtDate(e.expense_date || '')),
|
||||
@@ -328,6 +348,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
esc(opts.t(catMeta(e.category).labelKey)),
|
||||
(e.total_price || 0).toFixed(currencyDecimals(cur)),
|
||||
cur,
|
||||
...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []),
|
||||
baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)),
|
||||
esc(note),
|
||||
].join(sep),
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { AxiosError } from 'axios';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { shareApi } from '../api/client';
|
||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||
import { buildSettings } from '../../tests/helpers/factories';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
@@ -595,6 +597,112 @@ describe('SharedTripPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-043: a foreign expense reads at the rate it was booked at, as in Costs (#2525)', () => {
|
||||
it('shows the frozen-rate value in whole cents, not today\'s rate', async () => {
|
||||
// CHF so no earlier test has cached rates for this base. Today 1 CHF buys 1.1395
|
||||
// USD; the expense froze 1.17 when it was entered. Costs reads 801.76 / 1.17 =
|
||||
// 685.26 CHF. Today's rate alone gives 703.61, printed as "703.607".
|
||||
server.use(
|
||||
http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'USD', rate: 1.1395 }])),
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== 'booked-token') return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' },
|
||||
baseCurrency: 'CHF',
|
||||
days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
|
||||
budget: [
|
||||
{ id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 },
|
||||
{ id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 },
|
||||
],
|
||||
categories: [],
|
||||
permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderSharedTrip('booked-token');
|
||||
await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Aparthotel Silver')).toBeInTheDocument());
|
||||
// Row and its category both read the booked value; the trip total adds the
|
||||
// 100 CHF tram pass to it.
|
||||
await waitFor(() => expect(screen.getAllByText('685.26 CHF')).toHaveLength(2));
|
||||
expect(screen.getByText('785.26 CHF')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/703\.6/)).toBeNull();
|
||||
// What was entered stays beside the converted row, as the Costs list shows it.
|
||||
expect(screen.getByText('· 801.76 USD')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads a same-day bill in the display currency exactly as typed, with the trip currency\'s quote', async () => {
|
||||
// ZAR trip read in USD (ZAR so no earlier test has cached its rates). The bill froze
|
||||
// 0.05911 USD per rand, today's ZAR quote. The USD quote, 16.918 rand per dollar, is
|
||||
// rounded on its own and is not its inverse.
|
||||
server.use(
|
||||
http.get('https://api.frankfurter.dev/v2/rates', ({ request }) => {
|
||||
const base = new URL(request.url).searchParams.get('base');
|
||||
return HttpResponse.json(base === 'ZAR' ? [{ quote: 'USD', rate: 0.05911 }] : [{ quote: 'ZAR', rate: 16.918 }]);
|
||||
}),
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== 'same-day-token') return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'ZAR' },
|
||||
baseCurrency: 'USD',
|
||||
days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
|
||||
budget: [
|
||||
{ id: 1, name: 'Villa', total_price: 12345.67, category: 'Accommodation', currency: 'USD', exchange_rate: 0.05911 },
|
||||
],
|
||||
categories: [],
|
||||
permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderSharedTrip('same-day-token');
|
||||
await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(3));
|
||||
expect(screen.queryByText(/^· /)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => {
|
||||
// The owner reads in USD on a CHF trip. The USD bill was booked at 685.26 CHF,
|
||||
// which today is 780.86 USD, the figure Costs and its balances use; the CHF one
|
||||
// converts at today's rate (100 CHF = 113.95 USD).
|
||||
server.use(
|
||||
http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'CHF', rate: 1 / 1.1395 }])),
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== 'display-token') return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' },
|
||||
baseCurrency: 'USD',
|
||||
days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
|
||||
budget: [
|
||||
{ id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 },
|
||||
{ id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 },
|
||||
],
|
||||
categories: [],
|
||||
permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderSharedTrip('display-token');
|
||||
await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(2));
|
||||
expect(screen.getAllByText('780.86 USD')).toHaveLength(2);
|
||||
expect(screen.getByText('894.81 USD')).toBeInTheDocument();
|
||||
expect(screen.getByText('· 801.76 USD')).toBeInTheDocument();
|
||||
expect(screen.getByText('· 100.00 CHF')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of
|
||||
// the page: header variants, the permission-driven tab strip, and every item kind
|
||||
// the day timeline, bookings, packing, costs and chat sections can produce.
|
||||
@@ -1544,4 +1652,177 @@ describe('SharedTripPage', () => {
|
||||
expect(header?.style.overflow).toBe('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2505: only a 404 means the link is dead ────────────────────────────
|
||||
|
||||
describe('FE-PAGE-SHARED-043: a failed load is not an expired link (#2505)', () => {
|
||||
const failWith = (status: number) =>
|
||||
server.use(http.get('/api/shared/:token', () => HttpResponse.json({ error: 'nope' }, { status })));
|
||||
|
||||
it.each([500, 502, 503, 429])('keeps the link when the server answers %i', async (status) => {
|
||||
failWith(status);
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A request with no response makes the api client probe /api/health for a
|
||||
// proxy wall first; answer it the way a healthy server does.
|
||||
const healthy = () => http.get('/api/health', () => HttpResponse.json({ status: 'ok' }));
|
||||
|
||||
it('keeps the link when the request never reaches the server', async () => {
|
||||
server.use(healthy(), http.get('/api/shared/:token', () => HttpResponse.error()));
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the link when the request times out', async () => {
|
||||
// What axios rejects with once its 8s deadline passes: no response at all.
|
||||
const timeout = new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED);
|
||||
const spy = vi.spyOn(shareApi, 'getSharedTrip').mockRejectedValueOnce(timeout);
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('still calls a 404 from the share endpoint an expired link, without a retry', async () => {
|
||||
server.use(
|
||||
http.get('/api/shared/:token', () => HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 })),
|
||||
);
|
||||
renderSharedTrip('gone-token');
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/link expired or invalid/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A reverse proxy that has no upstream for TREK (Traefik while the
|
||||
// container is stopped or still in its start period, nginx with a missing
|
||||
// location) answers 404 on its own. That is the restart window of the
|
||||
// report, not a dead link.
|
||||
it.each([
|
||||
['a plain text 404 from Traefik', '404 page not found\n', 'text/plain; charset=utf-8'],
|
||||
['an HTML 404 from nginx', '<html><head><title>404 Not Found</title></head><body></body></html>', 'text/html'],
|
||||
])('keeps the link on %s', async (_label, body, contentType) => {
|
||||
server.use(
|
||||
http.get('/api/shared/:token', () => new HttpResponse(body, { status: 404, headers: { 'content-type': contentType } })),
|
||||
);
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the failed screen instead of crashing when a 200 is not the share payload', async () => {
|
||||
// An auth wall or a captive portal answering the API call with its own page.
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/shared/:token', () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new HttpResponse('<!doctype html><html><body>Sign in</body></html>', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
}
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Past The Wall', start_date: '2026-07-01', end_date: '2026-07-02' },
|
||||
days: [],
|
||||
assignments: {},
|
||||
dayNotes: {},
|
||||
places: [],
|
||||
reservations: [],
|
||||
accommodations: [],
|
||||
permissions: { share_map: true },
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /try again/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Past The Wall')).toBeInTheDocument());
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('loads the trip when the retry gets through', async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/shared/:token', () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return new HttpResponse(null, { status: 503 });
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Back Online Trip', start_date: '2026-07-01', end_date: '2026-07-02' },
|
||||
days: [],
|
||||
assignments: {},
|
||||
dayNotes: {},
|
||||
places: [],
|
||||
reservations: [],
|
||||
accommodations: [],
|
||||
permissions: { share_map: true },
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
const retry = await screen.findByRole('button', { name: /try again/i });
|
||||
fireEvent.click(retry);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Back Online Trip')).toBeInTheDocument());
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the failed screen up with a busy button while the retry runs, and sends it once', async () => {
|
||||
let calls = 0;
|
||||
let release: () => void = () => {};
|
||||
server.use(
|
||||
http.get('/api/shared/:token', async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return new HttpResponse(null, { status: 500 });
|
||||
await new Promise<void>((resolve) => { release = resolve; });
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}),
|
||||
);
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
const retry = await screen.findByRole('button', { name: /try again/i });
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(retry).toBeDisabled());
|
||||
expect(retry).toHaveAttribute('aria-busy', 'true');
|
||||
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
|
||||
fireEvent.click(retry);
|
||||
|
||||
await waitFor(() => expect(calls).toBe(2));
|
||||
release();
|
||||
await waitFor(() => expect(retry).not.toBeDisabled());
|
||||
expect(calls).toBe(2);
|
||||
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lands on the expired screen when the retry finds the link gone', async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
healthy(),
|
||||
http.get('/api/shared/:token', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.error()
|
||||
: HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 });
|
||||
}),
|
||||
);
|
||||
renderSharedTrip('test-token');
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /try again/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/link expired or invalid/i)).toBeInTheDocument());
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,18 +27,20 @@ import { getCategoryIcon } from '../components/shared/categoryIcons';
|
||||
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults';
|
||||
import VectorBasemap from '../components/Map/VectorBasemap';
|
||||
import { convertBooked, convertedLine } from '../hooks/useExchangeRates';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { avatarSrc } from '../utils/avatarSrc';
|
||||
import { safeHexColor } from '../utils/safeColor';
|
||||
import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder';
|
||||
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
import { currencyDecimals, splitReservationDateTime } from '../utils/formatters';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
|
||||
import { resolveBasemap } from '../utils/tileUrl';
|
||||
import { useSharedTrip } from './sharedTrip/useSharedTrip';
|
||||
import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails';
|
||||
import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails';
|
||||
import { SharedTripErrorScreen } from './sharedTrip/SharedTripErrorScreen';
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
|
||||
|
||||
@@ -98,6 +100,8 @@ export default function SharedTripPage() {
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
retry,
|
||||
retrying,
|
||||
base,
|
||||
convert,
|
||||
selectedDay,
|
||||
@@ -108,23 +112,7 @@ export default function SharedTripPage() {
|
||||
setShowLangPicker,
|
||||
} = useSharedTrip();
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div
|
||||
className="bg-[#f3f4f6]"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<div style={{ fontSize: 'calc(48px * var(--fs-scale-title, 1))', marginBottom: 16 }}>🔒</div>
|
||||
<h1 className="text-[#111827]" style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700 }}>
|
||||
{t('shared.expired')}
|
||||
</h1>
|
||||
<p className="text-[#6b7280]" style={{ marginTop: 8 }}>
|
||||
{t('shared.expiredHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (error) return <SharedTripErrorScreen reason={error} retrying={retrying} onRetry={retry} />;
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
@@ -994,16 +982,31 @@ export default function SharedTripPage() {
|
||||
{activeTab === 'budget' &&
|
||||
(budget || []).length > 0 &&
|
||||
(() => {
|
||||
// Pre-rework rows store currency = NULL ("the trip's own currency"); convert
|
||||
// each expense into the owner's display base via live FX, mirroring CostsPanel.
|
||||
const curOf = (i: any) => i.currency || trip.currency || base;
|
||||
// Pre-rework rows store currency = NULL ("the trip's own currency"). Each
|
||||
// expense is read the way CostsPanel reads it (#2525): at the rate frozen when
|
||||
// it was entered, into the trip currency, then into the owner's display base.
|
||||
// Reading it at today's rate alone made the shared page disagree with the
|
||||
// trip's own Costs tab.
|
||||
type Expense = { total_price?: number | string | null; currency?: string | null; exchange_rate?: number | null };
|
||||
const tripCurrency = String(trip.currency || base).toUpperCase();
|
||||
const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0;
|
||||
const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert);
|
||||
// Whole cents: a converted amount otherwise printed a third decimal.
|
||||
const money = (v: number) =>
|
||||
v.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
// What was entered, beside a row shown converted, as the Costs list prints it.
|
||||
const enteredOf = (i: Expense): string | null => {
|
||||
const entered = convertedLine(amountOf(i), i.currency, i.exchange_rate, tripCurrency, base, valueOf(i))?.entered;
|
||||
if (!entered) return null;
|
||||
const d = currencyDecimals(entered.currency);
|
||||
return `${entered.amount.toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d })} ${entered.currency}`;
|
||||
};
|
||||
const grouped = (budget || []).reduce((g: any, i: any) => {
|
||||
const c = i.category || t('shared.other');
|
||||
(g[c] = g[c] || []).push(i);
|
||||
return g;
|
||||
}, {});
|
||||
const sumIn = (items: any[]) =>
|
||||
items.reduce((s: number, i: any) => s + convert(Number.parseFloat(i.total_price) || 0, curOf(i)), 0);
|
||||
const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + valueOf(i), 0);
|
||||
const total = sumIn(budget || []);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -1028,7 +1031,7 @@ export default function SharedTripPage() {
|
||||
{t('shared.totalBudget')}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(28px * var(--fs-scale-title, 1))', fontWeight: 700, marginTop: 4 }}>
|
||||
{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
|
||||
{money(total)} {base}
|
||||
</div>
|
||||
</div>
|
||||
{/* By category */}
|
||||
@@ -1058,7 +1061,7 @@ export default function SharedTripPage() {
|
||||
className="text-[#6b7280]"
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}
|
||||
>
|
||||
{sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
|
||||
{money(sumIn(items))} {base}
|
||||
</span>
|
||||
</div>
|
||||
{items.map((item: any) => (
|
||||
@@ -1074,13 +1077,16 @@ export default function SharedTripPage() {
|
||||
>
|
||||
<span className="text-[#111827]" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{item.name}
|
||||
{item.total_price && enteredOf(item) ? (
|
||||
<span className="text-content-faint"> · {enteredOf(item)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
className="text-[#111827]"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}
|
||||
>
|
||||
{item.total_price
|
||||
? `${convert(Number.parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}`
|
||||
? `${money(valueOf(item))} ${base}`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1103,7 +1103,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
{showTransportModal && (
|
||||
<ErrorBoundary boundaryId="planner-panel:transport" fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} tripMembers={tripMembers} />
|
||||
<TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onDelete={can('reservation_edit', trip) && editingTransport ? () => handleDeleteReservation(editingTransport.id) : undefined} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} tripMembers={tripMembers} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
@@ -1170,6 +1170,27 @@ describe('TripPlannerPage — modals', () => {
|
||||
expect(tripActions.deleteFile).toHaveBeenCalledWith(42, 2)
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-053b: the transport edit window deletes the booking when the user may edit reservations', async () => {
|
||||
const res = buildReservation({ id: 55, type: 'flight', title: 'LH 400' })
|
||||
renderPage({ showTransportModal: true, editingTransport: res })
|
||||
|
||||
await act(async () => { await props('transportModal').onDelete() })
|
||||
expect(hookState.handleDeleteReservation).toHaveBeenCalledWith(55)
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-053c: no transport delete without an edited booking or permission', () => {
|
||||
renderPage({ showTransportModal: true })
|
||||
expect(props('transportModal').onDelete).toBeUndefined()
|
||||
|
||||
cleanup()
|
||||
renderPage({
|
||||
showTransportModal: true,
|
||||
editingTransport: buildReservation({ id: 55, type: 'flight' }),
|
||||
can: vi.fn((action: string) => action !== 'reservation_edit'),
|
||||
})
|
||||
expect(props('transportModal').onDelete).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-054: during a review the transport modal exits advance the queue too', async () => {
|
||||
renderPage({ showTransportModal: true, importReviewActive: true })
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CloudOff, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SharedTripLoadError } from './sharedTripModel'
|
||||
|
||||
interface SharedTripErrorScreenProps {
|
||||
reason: SharedTripLoadError
|
||||
retrying: boolean
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The full-page state of a share link whose payload did not arrive. Only
|
||||
* 'expired' tells the viewer the link is dead; 'unavailable' says the load
|
||||
* failed and offers another go, since the same link usually works on the next
|
||||
* request (#2505).
|
||||
*/
|
||||
export function SharedTripErrorScreen({ reason, retrying, onRetry }: SharedTripErrorScreenProps) {
|
||||
const { t } = useTranslation()
|
||||
const expired = reason === 'expired'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-secondary">
|
||||
<div className="flex max-w-md flex-col items-center p-10 text-center" role={expired ? undefined : 'alert'}>
|
||||
{expired ? (
|
||||
<div aria-hidden style={{ fontSize: 'calc(48px * var(--fs-scale-title, 1))', marginBottom: 16 }}>
|
||||
🔒
|
||||
</div>
|
||||
) : (
|
||||
<CloudOff size={44} strokeWidth={1.75} className="mb-4 text-content-muted" aria-hidden />
|
||||
)}
|
||||
<h1 className="text-balance text-subtitle font-bold text-content">{t(expired ? 'shared.expired' : 'shared.loadFailed')}</h1>
|
||||
<p className="mt-2 text-balance text-body text-content-muted">
|
||||
{t(expired ? 'shared.expiredHint' : 'shared.loadFailedHint')}
|
||||
</p>
|
||||
{!expired && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
aria-busy={retrying}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text transition-colors hover:bg-accent-hover disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{retrying ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw size={15} aria-hidden />
|
||||
)}
|
||||
{t('shared.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel'
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import { formatDurationMinutes, isHttpUrl, isSharedTripPayload, linkHost, sharedTripLoadError } from './sharedTripModel'
|
||||
|
||||
describe('sharedTripModel (#2320)', () => {
|
||||
it('accepts http and https and nothing else', () => {
|
||||
@@ -35,3 +36,65 @@ describe('sharedTripModel (#2320)', () => {
|
||||
expect(linkHost('nonsense')).toBe('nonsense')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sharedTripLoadError (#2505)', () => {
|
||||
// What the share endpoint sends for an unknown, expired or revoked token.
|
||||
const ENDPOINT_404 = { error: 'Invalid or expired link' }
|
||||
|
||||
const answered = (status: number, data: unknown = ENDPOINT_404) =>
|
||||
new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
|
||||
status,
|
||||
statusText: '',
|
||||
data,
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
})
|
||||
|
||||
it('calls only the endpoint 404 an expired link', () => {
|
||||
expect(sharedTripLoadError(answered(404))).toBe('expired')
|
||||
})
|
||||
|
||||
it('treats every other answer as a failed load', () => {
|
||||
for (const status of [400, 401, 403, 408, 429, 500, 502, 503, 504]) {
|
||||
expect(sharedTripLoadError(answered(status))).toBe('unavailable')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not take a 404 without the TREK error body for an expired link', () => {
|
||||
// Traefik with the container stopped or still starting, nginx, an empty
|
||||
// answer: a 404 from something in front of TREK says nothing about the token.
|
||||
expect(sharedTripLoadError(answered(404, '404 page not found\n'))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(answered(404, '<html><body><h1>404 Not Found</h1></body></html>'))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(answered(404, ''))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(answered(404, null))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(answered(404, {}))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(answered(404, [{ error: 'x' }]))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(answered(404, { error: 404 }))).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('treats a request without any answer as a failed load', () => {
|
||||
expect(sharedTripLoadError(new AxiosError('Network Error', AxiosError.ERR_NETWORK))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(new TypeError('Failed to fetch'))).toBe('unavailable')
|
||||
expect(sharedTripLoadError(undefined)).toBe('unavailable')
|
||||
expect(sharedTripLoadError(null)).toBe('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSharedTripPayload (#2505)', () => {
|
||||
it('accepts what the share endpoint sends', () => {
|
||||
expect(isSharedTripPayload({ trip: { id: 1, title: 'Lisbon' }, days: [], permissions: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a 200 that is not the share payload', () => {
|
||||
// An auth wall or a captive portal answering the API call with its own page.
|
||||
expect(isSharedTripPayload('<!doctype html><html><body>Sign in</body></html>')).toBe(false)
|
||||
expect(isSharedTripPayload('')).toBe(false)
|
||||
expect(isSharedTripPayload(null)).toBe(false)
|
||||
expect(isSharedTripPayload(undefined)).toBe(false)
|
||||
expect(isSharedTripPayload([])).toBe(false)
|
||||
expect(isSharedTripPayload({})).toBe(false)
|
||||
expect(isSharedTripPayload({ trip: null })).toBe(false)
|
||||
expect(isSharedTripPayload({ trip: 'Lisbon' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,3 +46,39 @@ export function linkHost(url: string): string {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the share payload did not arrive (#2505).
|
||||
*
|
||||
* The share endpoint answers 404 with its JSON `{ error }` body for a token
|
||||
* that is unknown, expired or revoked, and that is the only answer that says
|
||||
* anything about the link. A 5xx while the server restarts, a rate limit, a
|
||||
* timeout or no response at all because the viewer is offline says the request
|
||||
* failed, not the link, so the page offers a retry instead of sending the
|
||||
* viewer back to the owner for a new link that would not have been needed.
|
||||
*
|
||||
* A 404 without that body is not TREK talking: a reverse proxy with no
|
||||
* upstream answers one on its own (Traefik while the container is stopped or
|
||||
* still starting, nginx with a missing location), so it counts as a failed
|
||||
* load too.
|
||||
*/
|
||||
export type SharedTripLoadError = 'expired' | 'unavailable'
|
||||
|
||||
export function sharedTripLoadError(err: unknown): SharedTripLoadError {
|
||||
const response = (err as { response?: { status?: number; data?: unknown } } | null | undefined)?.response
|
||||
if (response?.status !== 404) return 'unavailable'
|
||||
return isPlainObject(response.data) && typeof response.data.error === 'string' ? 'expired' : 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a 200 carried the share payload. An auth wall or a captive portal
|
||||
* can answer the API call with its own HTML page, which axios hands over as a
|
||||
* string; the page would crash on it, so the hook treats it as a failed load.
|
||||
*/
|
||||
export function isSharedTripPayload(payload: unknown): boolean {
|
||||
return isPlainObject(payload) && isPlainObject(payload.trip)
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -2,28 +2,61 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { shareApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { isSharedTripPayload, sharedTripLoadError, type SharedTripLoadError } from './sharedTripModel'
|
||||
|
||||
/**
|
||||
* Shared-trip (public) data hook — owns the token lookup, the read-only share
|
||||
* fetch and the view state (selected day, active tab, language picker).
|
||||
* SharedTripPage is a pure wiring container; the post-load derivations
|
||||
* fetch with its retry, and the view state (selected day, active tab, language
|
||||
* picker). SharedTripPage is a pure wiring container; the post-load derivations
|
||||
* (sortedDays, map places, …) stay in the page next to the JSX that uses them.
|
||||
* Behaviour is identical to the previous in-component logic.
|
||||
*/
|
||||
export function useSharedTrip() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
// The shared payload is an open-ended snapshot (trip, days, assignments, …),
|
||||
// matched 1:1 from the public share endpoint — kept loosely typed as before.
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [error, setError] = useState(false)
|
||||
// 'expired' only for the endpoint's own 404; any other failure, a proxy's
|
||||
// 404 or a 200 that is not the payload included, is 'unavailable' and keeps
|
||||
// the viewer on a retry (#2505).
|
||||
const [error, setError] = useState<SharedTripLoadError | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('plan')
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
|
||||
}, [token])
|
||||
let cancelled = false
|
||||
shareApi
|
||||
.getSharedTrip(token)
|
||||
.then((payload) => {
|
||||
if (cancelled) return
|
||||
if (!isSharedTripPayload(payload)) {
|
||||
setError('unavailable')
|
||||
return
|
||||
}
|
||||
setData(payload)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(sharedTripLoadError(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setRetrying(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token, attempt])
|
||||
|
||||
// The failed screen stays up while the next attempt runs, so a request that
|
||||
// fails again straight away does not flash the spinner.
|
||||
const retry = () => {
|
||||
if (retrying) return
|
||||
setRetrying(true)
|
||||
setAttempt((n) => n + 1)
|
||||
}
|
||||
|
||||
// The server now withholds the whole itinerary when the owner disabled the map
|
||||
// (share_map=false), so the Plan tab has nothing to show — land on the first
|
||||
@@ -44,7 +77,21 @@ export function useSharedTrip() {
|
||||
// Convert every expense into it via live FX, mirroring CostsPanel — a public
|
||||
// viewer has no settings store, so the base comes from the payload (#1361).
|
||||
const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
// Anchored on the trip currency's quote, as the Costs tab converts (#2525).
|
||||
const { convert } = useExchangeRates(base, data?.trip?.currency)
|
||||
|
||||
return { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker }
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
retry,
|
||||
retrying,
|
||||
base,
|
||||
convert,
|
||||
selectedDay,
|
||||
setSelectedDay,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
showLangPicker,
|
||||
setShowLangPicker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-016
|
||||
// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-021
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildBudgetItem } from '../../../tests/helpers/factories';
|
||||
import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useTripStore } from '../tripStore';
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
@@ -284,3 +285,75 @@ describe('budgetSlice', () => {
|
||||
expect(addToast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// An AUD trip whose server cannot fetch rates while the browser can: a write in another
|
||||
// currency lends the browser's rate, so the server can freeze one on entry.
|
||||
describe('budgetSlice: lent rates', () => {
|
||||
let sent: Record<string, unknown>[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
clearExchangeRateCache();
|
||||
sent = [];
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) });
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() }));
|
||||
server.use(
|
||||
http.post('/api/trips/:id/budget', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
sent.push(body);
|
||||
return HttpResponse.json({ item: buildBudgetItem({ trip_id: 1, name: String(body.name) }) });
|
||||
}),
|
||||
http.put('/api/trips/1/budget/:itemId', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
sent.push(body);
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ id: 50, trip_id: 1 }), ...body } });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(clearExchangeRateCache);
|
||||
|
||||
it('FE-STORE-BUDGET-018: addBudgetItem lends the fresh trip-currency rate for a foreign currency', async () => {
|
||||
await useTripStore.getState().addBudgetItem(1, { name: 'Pho', total_price: 8920000, currency: 'VND' });
|
||||
|
||||
expect(sent[0]).toMatchObject({ name: 'Pho', currency: 'VND', fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-019: nothing is lent for the trip currency, another trip or a stale table', async () => {
|
||||
await useTripStore.getState().addBudgetItem(1, { name: 'Tram', currency: 'AUD' });
|
||||
// The store only knows the open trip's currency, and a rate has to be quoted against it.
|
||||
await useTripStore.getState().addBudgetItem(2, { name: 'Pho', currency: 'VND' });
|
||||
clearExchangeRateCache();
|
||||
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }));
|
||||
await useTripStore.getState().addBudgetItem(1, { name: 'Banh mi', currency: 'VND' });
|
||||
|
||||
expect(sent).toHaveLength(3);
|
||||
for (const body of sent) expect(body).not.toHaveProperty('fallback_fx');
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-020: updateBudgetItem lends it too, for a change into a foreign currency', async () => {
|
||||
seedStore(useTripStore, { budgetItems: [buildBudgetItem({ id: 50, trip_id: 1, currency: 'USD', exchange_rate: 0.66 })] });
|
||||
|
||||
await useTripStore.getState().updateBudgetItem(1, 50, { currency: 'VND', total_price: 8920000 });
|
||||
|
||||
expect(sent[0]).toEqual({ currency: 'VND', total_price: 8920000, fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-021: freezeMissingRates sends the lent rates and takes the healed rows in', async () => {
|
||||
const bill = buildBudgetItem({ id: 60, trip_id: 1, currency: 'VND', exchange_rate: 1 });
|
||||
const other = buildBudgetItem({ id: 61, trip_id: 1, currency: 'AUD', exchange_rate: 1 });
|
||||
seedStore(useTripStore, { budgetItems: [bill, other] });
|
||||
const bodies: unknown[] = [];
|
||||
server.use(http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ items: [{ ...bill, exchange_rate: 18241.3 }], settlements: [], unresolved: [] });
|
||||
}));
|
||||
|
||||
const result = await useTripStore.getState().freezeMissingRates(1, { base: 'AUD', rates: { VND: 18241.3 } });
|
||||
// Without a table of its own to lend, the call asks the server to use its own rates.
|
||||
await useTripStore.getState().freezeMissingRates(1);
|
||||
|
||||
expect(bodies).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }, {}]);
|
||||
expect(result.unresolved).toEqual([]);
|
||||
expect(useTripStore.getState().budgetItems.map(i => [i.id, i.exchange_rate])).toEqual([[60, 18241.3], [61, 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@ import { budgetRepo } from '../../repo/budgetRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { BudgetItem, BudgetItemMember } from '../../types'
|
||||
import type { BudgetCreateItemRequest, BudgetUpdateItemRequest } from '@trek/shared'
|
||||
import type { BudgetCreateItemRequest, BudgetFallbackFx, BudgetFreezeRatesResponse, BudgetUpdateItemRequest } from '@trek/shared'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { withFallbackFx } from '../../hooks/useExchangeRates'
|
||||
import { notify } from '../notify'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
@@ -19,6 +20,16 @@ export interface BudgetSlice {
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
|
||||
freezeMissingRates: (tripId: number | string, fallback?: BudgetFallbackFx) => Promise<BudgetFreezeRatesResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* The open trip's currency when `tripId` is that trip, which is what a lent rate has to be
|
||||
* quoted against. Null for any other trip: the store only knows the one that is open.
|
||||
*/
|
||||
function openTripCurrency(get: GetState, tripId: number | string): string | null {
|
||||
const trip = get().trip
|
||||
return trip && String(trip.id) === String(tripId) ? trip.currency : null
|
||||
}
|
||||
|
||||
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
|
||||
@@ -33,7 +44,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
addBudgetItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
// A foreign currency goes out with the rate the browser holds for it, so the server
|
||||
// can freeze one even when its own fetch fails.
|
||||
const result = await budgetApi.create(tripId, withFallbackFx(data, openTripCurrency(get, tripId)))
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -43,7 +56,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
updateBudgetItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
const result = await budgetApi.update(tripId, id, withFallbackFx(data, openTripCurrency(get, tripId)))
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
@@ -151,4 +164,15 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
notify(getApiErrorMessage(err, 'Error reordering budget items'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
// Freezes a rate onto the rows the settlement could not count (see useFreezeMissingRates).
|
||||
// Not optimistic: nothing changes locally until the server says which rows it froze.
|
||||
freezeMissingRates: async (tripId, fallback) => {
|
||||
const result = await budgetApi.freezeRates(tripId, fallback ? { fallback_fx: fallback } : {})
|
||||
if (result.items.length > 0) {
|
||||
const healed = new Map(result.items.map(item => [item.id, item]))
|
||||
set(state => ({ budgetItems: state.budgetItems.map(item => healed.get(item.id) ?? item) }))
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -255,6 +255,12 @@ export interface RouteSegment {
|
||||
noteText?: string
|
||||
/** The travel mode this leg was routed with (#1281) — drives the connector icon. */
|
||||
mode?: string
|
||||
/**
|
||||
* Set on the drive from the day's accommodation to its first stop ('morning') or
|
||||
* from its last stop back to it ('evening'), so a list can show that leg at the
|
||||
* day's edge without guessing it from coordinates a stop may share (#2501).
|
||||
*/
|
||||
hotelBookend?: 'morning' | 'evening'
|
||||
}
|
||||
|
||||
/** An intermediate stop a plugin route places on the drawn line (charging stop, rest area). */
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker } from './chunkReload';
|
||||
import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker, reloadFresh, FRESH_LOAD_PARAM } from './chunkReload';
|
||||
|
||||
const net = vi.hoisted(() => ({
|
||||
probeNow: vi.fn(async () => 'online' as 'online' | 'offline' | 'proxy-wall'),
|
||||
isEffectivelyOffline: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock('../sync/connectivity', () => ({ probeNow: net.probeNow }));
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: net.isEffectivelyOffline }));
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
@@ -27,13 +34,55 @@ describe('isChunkLoadError', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadOnceForChunk', () => {
|
||||
function stubReload() {
|
||||
/**
|
||||
* A recovery reload is a location.replace onto a fresh URL (reloadFresh), so the
|
||||
* stub records the replace. `plain` is the location.reload() it must not use.
|
||||
*/
|
||||
function stubLocation(href = 'http://localhost/trips/4?tab=plan#day-2') {
|
||||
const reload = vi.fn();
|
||||
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
|
||||
return reload;
|
||||
const plain = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...window.location, href, replace: reload, reload: plain },
|
||||
writable: true,
|
||||
});
|
||||
return { reload, plain };
|
||||
}
|
||||
|
||||
describe('reloadFresh', () => {
|
||||
it('FE-UTIL-CHUNK-015: loads the same page under a URL no cache has seen (#2524)', () => {
|
||||
const { reload, plain } = stubLocation();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
|
||||
reloadFresh();
|
||||
expect(plain).not.toHaveBeenCalled();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
const target = new URL(reload.mock.calls[0][0] as string);
|
||||
// Path, query and hash stay, so the app comes back where it was.
|
||||
expect(target.pathname).toBe('/trips/4');
|
||||
expect(target.searchParams.get('tab')).toBe('plan');
|
||||
expect(target.hash).toBe('#day-2');
|
||||
expect(target.searchParams.get(FRESH_LOAD_PARAM)).toBe((1790358416100).toString(36));
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-016: two reloads never share a URL', () => {
|
||||
const { reload } = stubLocation('http://localhost/dashboard');
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
|
||||
reloadFresh();
|
||||
now.mockReturnValue(1790358416101);
|
||||
reloadFresh();
|
||||
expect(reload.mock.calls[0][0]).not.toBe(reload.mock.calls[1][0]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-017: falls back to a plain reload when the URL cannot be built', () => {
|
||||
const { reload, plain } = stubLocation('not a url');
|
||||
reloadFresh();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(plain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadOnceForChunk', () => {
|
||||
const stubReload = () => stubLocation().reload;
|
||||
|
||||
it('FE-UTIL-CHUNK-004: reloads the first time and refuses afterwards', () => {
|
||||
const reload = stubReload();
|
||||
expect(reloadOnceForChunk()).toBe(true);
|
||||
@@ -71,3 +120,122 @@ describe('reloadOnceForChunk', () => {
|
||||
expect(() => clearChunkReloadMarker()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadOntoCurrentBuild', () => {
|
||||
class FakeWorker {}
|
||||
|
||||
// The worker that served the page is read when serviceWorkerShell loads, so the
|
||||
// fake container goes in first and the modules are loaded afresh.
|
||||
async function setUp({ servedBy, controller }: { servedBy: FakeWorker | null; controller: FakeWorker | null }) {
|
||||
const registration = { unregister: vi.fn(async () => true) };
|
||||
const container = { controller: servedBy, getRegistration: vi.fn(async () => registration) };
|
||||
Object.defineProperty(navigator, 'serviceWorker', { value: container, configurable: true });
|
||||
const deleted: string[] = [];
|
||||
Object.defineProperty(globalThis, 'caches', {
|
||||
value: {
|
||||
keys: vi.fn(async () => ['workbox-precache-v2-http://localhost/', 'map-tiles']),
|
||||
delete: vi.fn(async (name: string) => { deleted.push(name); return true; }),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const { reload } = stubLocation();
|
||||
vi.resetModules();
|
||||
const mod = await import('./chunkReload');
|
||||
container.controller = controller;
|
||||
return { mod, reload, registration, deleted, container };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
net.probeNow.mockReset().mockResolvedValue('online');
|
||||
net.isEffectivelyOffline.mockReset().mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
Reflect.deleteProperty(globalThis, 'caches');
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-008: just reloads once a newer worker has taken over since the page loaded', async () => {
|
||||
const { mod, reload, registration, deleted } = await setUp({ servedBy: new FakeWorker(), controller: new FakeWorker() });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
// That worker answers the reload with the new shell; nothing to throw away.
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(net.probeNow).not.toHaveBeenCalled();
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-009: just reloads when the page came from the network', async () => {
|
||||
const { mod, reload, registration } = await setUp({ servedBy: null, controller: null });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-010: drops the precache and the worker first when that worker would answer the reload again (#2524)', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration, deleted } = await setUp({ servedBy: worker, controller: worker });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
// Not before the shell is gone: the reload would come back to the same error.
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(deleted).toEqual(['workbox-precache-v2-http://localhost/']);
|
||||
expect(registration.unregister).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['offline', 'proxy-wall'] as const)(
|
||||
'FE-UTIL-CHUNK-011: keeps the shell when the server does not answer (%s)',
|
||||
async (state) => {
|
||||
net.probeNow.mockResolvedValue(state);
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration, deleted } = await setUp({ servedBy: worker, controller: worker });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
// Offline, the precache is the only way the app starts at all.
|
||||
expect(deleted).toEqual([]);
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('FE-UTIL-CHUNK-012: keeps the shell in offline mode without even asking the server', async () => {
|
||||
net.isEffectivelyOffline.mockReturnValue(true);
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(net.probeNow).not.toHaveBeenCalled();
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-013: still reloads when dropping the shell fails', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
|
||||
registration.unregister.mockRejectedValue(new Error('InvalidStateError'));
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-018: drops nothing when a newer worker takes over while the server is asked', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration, deleted, container } = await setUp({ servedBy: worker, controller: worker });
|
||||
net.probeNow.mockImplementation(async () => {
|
||||
// The browser's own sw.js check finished the handover in the meantime. Its
|
||||
// precache is the new build's, and dropping it would cost a full download.
|
||||
container.controller = new FakeWorker();
|
||||
return 'online';
|
||||
});
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(deleted).toEqual([]);
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-014: the automatic reload after a dead chunk takes the same way, once', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
|
||||
expect(mod.reloadOnceForChunk()).toBe(true);
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(registration.unregister).toHaveBeenCalledTimes(1);
|
||||
expect(mod.reloadOnceForChunk()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
* Kept out of the component module so main.tsx can use it without pulling React in.
|
||||
*/
|
||||
|
||||
import { probeNow } from '../sync/connectivity';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
import { dropAppShell, reloadHitsSameWorker } from './serviceWorkerShell';
|
||||
|
||||
const RELOAD_MARKER = 'trek:chunk-reload';
|
||||
|
||||
/** Vite's own message, plus what Chrome/Firefox/Safari say for a failed module fetch. */
|
||||
@@ -28,6 +32,64 @@ export function isChunkLoadError(error: unknown): boolean {
|
||||
return CHUNK_ERROR_PATTERNS.some(p => haystack.includes(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* The query parameter a recovery reload carries. public/shell-guard.js strips it
|
||||
* again before the app reads the URL.
|
||||
*/
|
||||
export const FRESH_LOAD_PARAM = 'trek-reload';
|
||||
|
||||
/**
|
||||
* location.reload(), except that the page is asked for under a URL no cache has
|
||||
* seen yet. A reverse proxy or CDN that keeps index.html against its headers
|
||||
* would answer a plain reload with the shell that just failed, and the page
|
||||
* would come back to the same missing chunk every time (#2524).
|
||||
*/
|
||||
export function reloadFresh(): void {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(FRESH_LOAD_PARAM, Date.now().toString(36));
|
||||
window.location.replace(url.href);
|
||||
} catch {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload so that the page comes back on the build the server runs now.
|
||||
*
|
||||
* Usually a fresh reload is that. The chunk is gone because a newer service
|
||||
* worker took over after this page loaded (it drops the previous build's files
|
||||
* from its precache as it does), or because there is no worker and the tab
|
||||
* outlived a deploy. Either way the reload is answered with the current shell.
|
||||
*
|
||||
* It is not when the worker that served this page is still the one in charge.
|
||||
* That worker would answer the reload with the very shell whose chunk it just
|
||||
* failed to deliver, so its precache is what is broken, and the page would come
|
||||
* back to the same error on every reload, cleared browser cache or not (#2524).
|
||||
* Then the precache and the worker are dropped first, so the reload reaches the
|
||||
* server. Only while the server answers and the app is not in offline mode:
|
||||
* offline, that precache is the only way the app starts at all, and a failed
|
||||
* chunk says nothing about it being broken. The worker is looked at again once
|
||||
* the server has answered, since a new one may have taken over in the meantime,
|
||||
* and then there is nothing to drop.
|
||||
*/
|
||||
export function reloadOntoCurrentBuild(): void {
|
||||
if (!reloadHitsSameWorker()) {
|
||||
reloadFresh();
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
if (!isEffectivelyOffline() && (await probeNow()) === 'online' && reloadHitsSameWorker()) {
|
||||
await dropAppShell();
|
||||
}
|
||||
} catch {
|
||||
// Reload regardless: at worst it shows this screen again.
|
||||
}
|
||||
reloadFresh();
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload once per session for a given chunk failure, then give up and let the
|
||||
* fallback show. Without the marker a chunk that is genuinely missing — a broken
|
||||
@@ -42,7 +104,7 @@ export function reloadOnceForChunk(): boolean {
|
||||
// loop, so prefer showing the fallback with its manual reload button.
|
||||
return false;
|
||||
}
|
||||
window.location.reload();
|
||||
reloadOntoCurrentBuild();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import { installGlobalErrorHandlers } from './globalErrorHandlers';
|
||||
|
||||
let teardown: () => void;
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
// A chunk failure reloads through chunkReload's fresh reload, a location.replace.
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
|
||||
Object.defineProperty(window, 'location', { value: { ...window.location, replace: reload }, writable: true });
|
||||
teardown = installGlobalErrorHandlers();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* The worker that served the page is read once, when the module loads, so every
|
||||
* test installs its fake service worker container first and imports afresh.
|
||||
*/
|
||||
|
||||
class FakeWorker {
|
||||
constructor(public scriptURL = '/sw.js') {}
|
||||
}
|
||||
|
||||
interface FakeRegistration {
|
||||
unregister: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function installContainer(controller: FakeWorker | null, registration?: FakeRegistration) {
|
||||
const container = {
|
||||
controller,
|
||||
getRegistration: vi.fn(async () => registration),
|
||||
}
|
||||
Object.defineProperty(navigator, 'serviceWorker', { value: container, configurable: true })
|
||||
return container
|
||||
}
|
||||
|
||||
function installCaches(names: string[]) {
|
||||
const deleted: string[] = []
|
||||
const store = {
|
||||
keys: vi.fn(async () => names),
|
||||
delete: vi.fn(async (name: string) => { deleted.push(name); return true }),
|
||||
}
|
||||
Object.defineProperty(globalThis, 'caches', { value: store, configurable: true })
|
||||
return deleted
|
||||
}
|
||||
|
||||
const net = vi.hoisted(() => ({ isEffectivelyOffline: vi.fn(() => false) }))
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: net.isEffectivelyOffline }))
|
||||
|
||||
async function load() {
|
||||
vi.resetModules()
|
||||
return import('./serviceWorkerShell')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker')
|
||||
Reflect.deleteProperty(globalThis, 'caches')
|
||||
vi.unstubAllGlobals()
|
||||
net.isEffectivelyOffline.mockReturnValue(false)
|
||||
})
|
||||
|
||||
const ORIGIN = 'http://localhost:3000'
|
||||
const PAGE = '<!doctype html><html><head><script type="module" src="/assets/index-a1.js"></script></head></html>'
|
||||
|
||||
function file(body: string, type: string, status = 200): Response {
|
||||
return new Response(body, { status, headers: { 'content-type': type } })
|
||||
}
|
||||
|
||||
/** One Workbox precache, as Cache Storage hands it to the page. */
|
||||
class FakePrecache {
|
||||
readonly entries: Map<string, Response>
|
||||
constructor(files: Record<string, Response>) {
|
||||
this.entries = new Map(Object.entries(files).map(([path, res]) => [ORIGIN + path, res]))
|
||||
}
|
||||
keys = vi.fn(async () => [...this.entries.keys()].map(url => new Request(url)))
|
||||
match = vi.fn(async (key: Request) => this.entries.get(key.url)?.clone())
|
||||
// Cache Storage lists a replaced entry last, as if it were new.
|
||||
put = vi.fn(async (key: Request, res: Response) => { this.entries.delete(key.url); this.entries.set(key.url, res) })
|
||||
delete = vi.fn(async (key: Request) => this.entries.delete(key.url))
|
||||
typeOf(path: string): string | null | undefined {
|
||||
return this.entries.get(ORIGIN + path)?.headers.get('content-type')
|
||||
}
|
||||
async text(path: string): Promise<string | undefined> {
|
||||
return this.entries.get(ORIGIN + path)?.clone().text()
|
||||
}
|
||||
}
|
||||
|
||||
function installPrecache(files: Record<string, Response>) {
|
||||
const precache = new FakePrecache(files)
|
||||
const others = new FakePrecache({ '/tiles/1/2/3.png': file('png', 'text/html') })
|
||||
const byName: Record<string, FakePrecache> = {
|
||||
[`workbox-precache-v2-${ORIGIN}/`]: precache,
|
||||
// Not the precache: whatever a tile server put there is not ours to judge.
|
||||
'map-tiles': others,
|
||||
}
|
||||
Object.defineProperty(globalThis, 'caches', {
|
||||
value: { keys: vi.fn(async () => Object.keys(byName)), open: vi.fn(async (name: string) => byName[name]) },
|
||||
configurable: true,
|
||||
})
|
||||
return { precache, others }
|
||||
}
|
||||
|
||||
/** The server behind the precache: what it answers for each path it is asked for. */
|
||||
function serve(files: Record<string, Response | Error>) {
|
||||
const fetch = vi.fn(async (url: string) => {
|
||||
const answer = files[new URL(url).pathname]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer ? answer.clone() : file('{"error":"Not found"}', 'application/json', 404)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
return fetch
|
||||
}
|
||||
|
||||
const CSS = 'body{font-family:Poppins}'
|
||||
|
||||
describe('serviceWorkerShell', () => {
|
||||
it('FE-UTIL-SWSHELL-001: notes the worker that served the page', async () => {
|
||||
const worker = new FakeWorker()
|
||||
installContainer(worker)
|
||||
const shell = await load()
|
||||
expect(shell.servingWorker()).toBe(worker)
|
||||
expect(shell.currentController()).toBe(worker)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-002: a reload hits the same worker until another one takes over', async () => {
|
||||
const container = installContainer(new FakeWorker())
|
||||
const shell = await load()
|
||||
expect(shell.reloadHitsSameWorker()).toBe(true)
|
||||
|
||||
container.controller = new FakeWorker()
|
||||
expect(shell.reloadHitsSameWorker()).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-003: a page that came from the network has no worker to blame', async () => {
|
||||
const container = installContainer(null)
|
||||
const shell = await load()
|
||||
expect(shell.servingWorker()).toBeNull()
|
||||
expect(shell.reloadHitsSameWorker()).toBe(false)
|
||||
|
||||
// A worker that claims the page later did not serve it.
|
||||
container.controller = new FakeWorker()
|
||||
expect(shell.reloadHitsSameWorker()).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-004: without service worker support there is no controller', async () => {
|
||||
const shell = await load()
|
||||
expect(shell.currentController()).toBeNull()
|
||||
expect(shell.reloadHitsSameWorker()).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-005: reading the controller where site data is blocked counts as none', async () => {
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
get() { throw new Error('SecurityError') },
|
||||
configurable: true,
|
||||
})
|
||||
const shell = await load()
|
||||
expect(shell.currentController()).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-006: dropping the shell deletes the precache and the worker, and nothing else', async () => {
|
||||
const registration = { unregister: vi.fn(async () => true) }
|
||||
installContainer(new FakeWorker(), registration)
|
||||
const deleted = installCaches([
|
||||
'workbox-precache-v2-https://trek.example/',
|
||||
'map-tiles',
|
||||
'gl-map-styles',
|
||||
'gl-map-offline',
|
||||
'user-uploads',
|
||||
])
|
||||
const shell = await load()
|
||||
|
||||
await shell.dropAppShell()
|
||||
|
||||
// Offline map regions and cached uploads were downloaded on purpose.
|
||||
expect(deleted).toEqual(['workbox-precache-v2-https://trek.example/'])
|
||||
expect(registration.unregister).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-007: dropping the shell copes with no registration and no Cache Storage', async () => {
|
||||
installContainer(null, undefined)
|
||||
const shell = await load()
|
||||
await expect(shell.dropAppShell()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repairPrecache (#2524)', () => {
|
||||
it('FE-UTIL-SWSHELL-010: puts a stylesheet kept as a page back, fetched past the precache', async () => {
|
||||
const { precache, others } = installPrecache({
|
||||
'/assets/index-N6AFa1mB.css': file(PAGE, 'text/html; charset=utf-8'),
|
||||
'/assets/index-a1.js': file('export {}', 'text/javascript'),
|
||||
'/index.html?__WB_REVISION__=131d93be': file(PAGE, 'text/html'),
|
||||
'/?__WB_REVISION__=1': file(PAGE, 'text/html'),
|
||||
})
|
||||
const fetch = serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css; charset=UTF-8') })
|
||||
const shell = await load()
|
||||
|
||||
expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
|
||||
|
||||
expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/css; charset=UTF-8')
|
||||
expect(await precache.text('/assets/index-N6AFa1mB.css')).toBe(CSS)
|
||||
// Only the broken entry is fetched, under a URL the precache route does not know.
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(new URL(url).pathname).toBe('/assets/index-N6AFa1mB.css')
|
||||
expect(new URL(url).searchParams.get('trek-repair')).toBeTruthy()
|
||||
expect(init).toMatchObject({ cache: 'no-store', credentials: 'same-origin', redirect: 'error' })
|
||||
// Kept without the one-off URL it came from, since a module is known by it.
|
||||
const kept = precache.put.mock.calls[0][1] as Response
|
||||
expect(kept.url).toBe('')
|
||||
// Pages are pages, and other caches are not the precache.
|
||||
expect(precache.typeOf('/index.html?__WB_REVISION__=131d93be')).toBe('text/html')
|
||||
expect(precache.typeOf('/?__WB_REVISION__=1')).toBe('text/html')
|
||||
expect(others.match).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-011: a revisioned file is fetched without its revision and kept under it', async () => {
|
||||
const { precache } = installPrecache({
|
||||
'/fonts/Poppins-Bold.ttf?__WB_REVISION__=92934d92': file(PAGE, 'text/html'),
|
||||
'/icons/icon-192x192.png?__WB_REVISION__=5f1a': file(PAGE, 'text/html'),
|
||||
'/assets/vendor-core-Dbh_uXoX.js': file(PAGE, 'text/html'),
|
||||
})
|
||||
const fetch = serve({
|
||||
'/fonts/Poppins-Bold.ttf': file('ttf', 'font/ttf'),
|
||||
'/icons/icon-192x192.png': file('png', 'image/png'),
|
||||
'/assets/vendor-core-Dbh_uXoX.js': file('export {}', 'text/javascript; charset=UTF-8'),
|
||||
})
|
||||
const shell = await load()
|
||||
|
||||
expect((await shell.repairPrecache()).sort()).toEqual([
|
||||
'/assets/vendor-core-Dbh_uXoX.js', '/fonts/Poppins-Bold.ttf', '/icons/icon-192x192.png',
|
||||
])
|
||||
|
||||
expect(precache.typeOf('/fonts/Poppins-Bold.ttf?__WB_REVISION__=92934d92')).toBe('font/ttf')
|
||||
expect(precache.typeOf('/icons/icon-192x192.png?__WB_REVISION__=5f1a')).toBe('image/png')
|
||||
expect(precache.typeOf('/assets/vendor-core-Dbh_uXoX.js')).toBe('text/javascript; charset=UTF-8')
|
||||
const fontUrl = new URL((fetch.mock.calls as unknown as [string][]).map(([u]) => u).find(u => u.includes('Poppins'))!)
|
||||
expect(fontUrl.searchParams.has('__WB_REVISION__')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-012: an entry the server has no file for is removed, so the network and the next install get it', async () => {
|
||||
const { precache } = installPrecache({ '/assets/DashboardPage-v4ODOTOr.js': file(PAGE, 'text/html') })
|
||||
serve({})
|
||||
const shell = await load()
|
||||
|
||||
expect(await shell.repairPrecache()).toEqual([])
|
||||
|
||||
expect(precache.entries.size).toBe(0)
|
||||
expect(localStorage.getItem('trek:precache-checked')).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no network', new TypeError('Failed to fetch')],
|
||||
['a redirect to an auth wall', new TypeError('Failed to fetch: redirect')],
|
||||
['the login page of a proxy', file(PAGE, 'text/html; charset=utf-8')],
|
||||
['an auth wall that refuses', file('Unauthorized', 'text/plain', 401)],
|
||||
['a server error', file('{"error":"Internal"}', 'application/json', 500)],
|
||||
])('FE-UTIL-SWSHELL-013: with %s the entry stays, and the next launch tries again', async (_label, answer) => {
|
||||
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
|
||||
const fetch = serve({ '/assets/index-N6AFa1mB.css': answer })
|
||||
const shell = await load()
|
||||
|
||||
expect(await shell.repairPrecache()).toEqual([])
|
||||
expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/html')
|
||||
expect(precache.delete).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem('trek:precache-checked')).toBeNull()
|
||||
|
||||
fetch.mockImplementation(async () => file(CSS, 'text/css'))
|
||||
expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-014: a sound precache is looked through once, until its entries change', async () => {
|
||||
const { precache } = installPrecache({
|
||||
'/assets/index-N6AFa1mB.css': file(CSS, 'text/css'),
|
||||
'/assets/index-a1.js': file('export {}', 'text/javascript'),
|
||||
})
|
||||
const fetch = serve({})
|
||||
const shell = await load()
|
||||
|
||||
await shell.repairPrecache()
|
||||
expect(precache.match).toHaveBeenCalledTimes(2)
|
||||
|
||||
await shell.repairPrecache()
|
||||
expect(precache.match).toHaveBeenCalledTimes(2)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
|
||||
// A new install brought entries, and with them maybe a broken one.
|
||||
precache.entries.set(`${ORIGIN}/assets/index-b2.js`, file(PAGE, 'text/html'))
|
||||
fetch.mockImplementation(async () => file('export {}', 'text/javascript'))
|
||||
expect(await shell.repairPrecache()).toEqual(['/assets/index-b2.js'])
|
||||
expect(precache.match).toHaveBeenCalledTimes(5)
|
||||
|
||||
// The repaired entry moved to the end of the list, which changes nothing.
|
||||
await shell.repairPrecache()
|
||||
expect(precache.match).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-015: without Cache Storage there is nothing to put right', async () => {
|
||||
const shell = await load()
|
||||
await expect(shell.repairPrecache()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-016: with storage blocked it does nothing, rather than look through the precache on every launch', async () => {
|
||||
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
|
||||
const fetch = serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css') })
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('blocked', 'SecurityError') })
|
||||
const shell = await load()
|
||||
|
||||
expect(await shell.repairPrecache()).toEqual([])
|
||||
|
||||
expect(precache.keys).not.toHaveBeenCalled()
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-017: a failed fetch leaves the rest for the next launch, and keeps what it put right', async () => {
|
||||
const { precache } = installPrecache({
|
||||
'/assets/index-N6AFa1mB.css': file(PAGE, 'text/html'),
|
||||
'/assets/maplibre-B2k4QVOw.css': file(PAGE, 'text/html'),
|
||||
'/assets/Inter-latin.woff2': file(PAGE, 'text/html'),
|
||||
})
|
||||
const fetch = serve({
|
||||
'/assets/index-N6AFa1mB.css': file(CSS, 'text/css'),
|
||||
'/assets/maplibre-B2k4QVOw.css': new TypeError('Failed to fetch'),
|
||||
'/assets/Inter-latin.woff2': file('woff2', 'font/woff2'),
|
||||
})
|
||||
const shell = await load()
|
||||
|
||||
expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
expect(precache.typeOf('/assets/Inter-latin.woff2')).toBe('text/html')
|
||||
expect(localStorage.getItem('trek:precache-checked')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-018: a server that does not answer is given up on after a while', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
|
||||
vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
|
||||
init.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
||||
})))
|
||||
const shell = await load()
|
||||
|
||||
const done = shell.repairPrecache()
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
await expect(done).resolves.toEqual([])
|
||||
expect(localStorage.getItem('trek:precache-checked')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('restyle and schedulePrecacheRepair (#2524)', () => {
|
||||
function stylesheet(href: string): HTMLLinkElement {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.crossOrigin = ''
|
||||
link.href = href
|
||||
document.head.appendChild(link)
|
||||
return link
|
||||
}
|
||||
|
||||
const sheets = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')]
|
||||
|
||||
afterEach(() => {
|
||||
document.head.querySelectorAll('link').forEach(link => link.remove())
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-020: loads a stylesheet taken from a broken entry again, and drops the old link once it has', async () => {
|
||||
const broken = stylesheet('/assets/index-N6AFa1mB.css')
|
||||
const sound = stylesheet('/assets/leaflet-B1.css')
|
||||
const shell = await load()
|
||||
|
||||
shell.restyle(['/assets/index-N6AFa1mB.css'])
|
||||
|
||||
const fresh = sheets().find(l => l !== broken && l !== sound)!
|
||||
expect(new URL(fresh.href).pathname).toBe('/assets/index-N6AFa1mB.css')
|
||||
expect(fresh.crossOrigin).toBe('')
|
||||
expect(broken.isConnected).toBe(true)
|
||||
|
||||
fresh.dispatchEvent(new Event('load'))
|
||||
expect(broken.isConnected).toBe(false)
|
||||
// In the old link's place, so the cascade stays as the build ordered it.
|
||||
expect(sheets()).toEqual([fresh, sound])
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-021: a stylesheet that fails to load again leaves the page as it was', async () => {
|
||||
const broken = stylesheet('/assets/index-N6AFa1mB.css')
|
||||
const shell = await load()
|
||||
|
||||
shell.restyle(['/assets/index-N6AFa1mB.css'])
|
||||
sheets().find(l => l !== broken)!.dispatchEvent(new Event('error'))
|
||||
|
||||
expect(sheets()).toEqual([broken])
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-027: a font put right loads the app\'s own stylesheets again, where its face is declared', async () => {
|
||||
const entry = stylesheet('/assets/index-N6AFa1mB.css')
|
||||
const lazy = stylesheet('/assets/maplibre-B2k4QVOw.css')
|
||||
const foreign = stylesheet('https://tiles.example.org/assets/style.css')
|
||||
const other = stylesheet('/custom/theme.css')
|
||||
const shell = await load()
|
||||
|
||||
shell.restyle(['/assets/poppins-latin-400-normal-cpxAROuN.woff2'])
|
||||
|
||||
const fresh = sheets().filter(l => ![entry, lazy, foreign, other].includes(l))
|
||||
expect(fresh.map(l => new URL(l.href).pathname)).toEqual(['/assets/index-N6AFa1mB.css', '/assets/maplibre-B2k4QVOw.css'])
|
||||
fresh.forEach(l => l.dispatchEvent(new Event('load')))
|
||||
expect(sheets()).toEqual([...fresh, foreign, other])
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-028: an icon or a script put right leaves the stylesheets alone', async () => {
|
||||
const entry = stylesheet('/assets/index-N6AFa1mB.css')
|
||||
const shell = await load()
|
||||
|
||||
shell.restyle(['/icons/icon-192x192.png', '/assets/dashboardModel-C_5ygTOX.js'])
|
||||
|
||||
expect(sheets()).toEqual([entry])
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-022: once the page has loaded and the browser is idle, the repair runs and restyles the page', async () => {
|
||||
const idle = vi.fn((run: () => void) => { run(); return 1 })
|
||||
vi.stubGlobal('requestIdleCallback', idle)
|
||||
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
|
||||
serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css') })
|
||||
const broken = stylesheet('/assets/index-N6AFa1mB.css')
|
||||
const shell = await load()
|
||||
|
||||
shell.schedulePrecacheRepair()
|
||||
|
||||
// jsdom's document has finished loading, so it only waits for idle time.
|
||||
expect(idle).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() => expect(sheets()).toHaveLength(2))
|
||||
expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/css')
|
||||
sheets()[1].dispatchEvent(new Event('load'))
|
||||
expect(broken.isConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-023: offline, or in offline mode, the precache is left as it is', async () => {
|
||||
vi.stubGlobal('requestIdleCallback', (run: () => void) => { run(); return 1 })
|
||||
net.isEffectivelyOffline.mockReturnValue(true)
|
||||
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
|
||||
const fetch = serve({})
|
||||
const shell = await load()
|
||||
|
||||
shell.schedulePrecacheRepair()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
|
||||
expect(precache.keys).not.toHaveBeenCalled()
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-024: a page still loading waits for its load event first', async () => {
|
||||
const idle = vi.fn()
|
||||
vi.stubGlobal('requestIdleCallback', idle)
|
||||
const readyState = vi.spyOn(document, 'readyState', 'get').mockReturnValue('interactive')
|
||||
const shell = await load()
|
||||
|
||||
shell.schedulePrecacheRepair()
|
||||
expect(idle).not.toHaveBeenCalled()
|
||||
|
||||
readyState.mockRestore()
|
||||
window.dispatchEvent(new Event('load'))
|
||||
expect(idle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-025: without idle callbacks it waits a moment instead, and a refusing Cache Storage is left for the next launch', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
const keys = vi.fn(async () => { throw new DOMException('blocked', 'SecurityError') })
|
||||
Object.defineProperty(globalThis, 'caches', { value: { keys }, configurable: true })
|
||||
const shell = await load()
|
||||
|
||||
shell.schedulePrecacheRepair()
|
||||
expect(keys).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
expect(keys).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-UTIL-SWSHELL-026: nothing put right leaves the stylesheets alone', async () => {
|
||||
const link = stylesheet('/assets/index-N6AFa1mB.css')
|
||||
const shell = await load()
|
||||
|
||||
shell.restyle([])
|
||||
|
||||
expect(sheets()).toEqual([link])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* The service worker that answers this page's navigations, and the way out when
|
||||
* the app shell it keeps can no longer start.
|
||||
*
|
||||
* Kept free of React so main.tsx and the error handlers can use it early.
|
||||
*/
|
||||
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
/** Part of the name of every Workbox precache, and of no other cache the app keeps. */
|
||||
const PRECACHE = '-precache-'
|
||||
|
||||
/** The worker controlling this page, or null. Reading it can throw where site data is blocked. */
|
||||
export function currentController(): ServiceWorker | null {
|
||||
try {
|
||||
return 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// The worker that served this page, noted when the app loads. The browser checks
|
||||
// sw.js on every navigation by itself, so a new build can take over while this
|
||||
// page still runs the previous one. A controller other than this one means the
|
||||
// new shell is only a reload away. A page served without a worker came from the
|
||||
// network, so a worker claiming it later is no takeover.
|
||||
const servedBy = currentController()
|
||||
|
||||
/** The worker that served this page when it loaded, or null when it came from the network. */
|
||||
export function servingWorker(): ServiceWorker | null {
|
||||
return servedBy
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a reload would be answered by the same worker that served this page,
|
||||
* which means with the same app shell.
|
||||
*/
|
||||
export function reloadHitsSameWorker(): boolean {
|
||||
const worker = currentController()
|
||||
return worker !== null && worker === servedBy
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws away the app shell this browser keeps: the Workbox precache and the
|
||||
* worker that serves it. The next navigation then goes to the server, gets the
|
||||
* index.html of the build it runs now, and registers that build's worker, which
|
||||
* precaches from scratch instead of reusing an entry of the broken cache.
|
||||
*
|
||||
* Map tiles, GL styles and cached uploads live in caches of their own and stay.
|
||||
* Only call this while the server answers: offline, the precache is the only way
|
||||
* the app can start at all.
|
||||
*
|
||||
* public/shell-guard.js does the same for a shell whose entry cannot load, before
|
||||
* any of this code runs. Keep the two in step.
|
||||
*/
|
||||
export async function dropAppShell(): Promise<void> {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.filter(name => name.includes(PRECACHE)).map(name => caches.delete(name)))
|
||||
}
|
||||
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
||||
if (reg) await reg.unregister()
|
||||
}
|
||||
|
||||
// Workbox keys a file without a hash in its name as url?__WB_REVISION__=<hash>.
|
||||
const REVISION_PARAM = '__WB_REVISION__'
|
||||
// A build file asked for under this parameter gets past the precache, whose route
|
||||
// only knows the plain URL, and past any cache in between that keeps files by URL.
|
||||
const REPAIR_PARAM = 'trek-repair'
|
||||
const REPAIR_TIMEOUT_MS = 15_000
|
||||
// The entries the precache held when it was last found sound, so that an
|
||||
// unchanged precache is not looked through again on every launch.
|
||||
const CHECKED_KEY = 'trek:precache-checked'
|
||||
// What the precache keeps besides index.html (globPatterns in vite.config.js,
|
||||
// plus the web manifest). None of these may ever be a page.
|
||||
const BUILD_FILE = /\.(?:js|css|svg|png|woff2?|ttf|webmanifest)$/i
|
||||
|
||||
interface Precache { name: string, cache: Cache, keys: readonly Request[] }
|
||||
|
||||
function isPage(response: Response): boolean {
|
||||
return (response.headers.get('content-type') ?? '').toLowerCase().includes('text/html')
|
||||
}
|
||||
|
||||
/**
|
||||
* Which entries the precaches hold, in a few characters. Order does not count:
|
||||
* a repaired entry moves to the end of the list.
|
||||
*/
|
||||
function fingerprint(precaches: readonly Precache[]): string {
|
||||
const urls = precaches.flatMap(({ name, keys }) => keys.map(key => `${name} ${key.url}`)).sort((a, b) => a.localeCompare(b))
|
||||
let hash = 0x811c9dc5
|
||||
for (const text of urls) {
|
||||
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193)
|
||||
}
|
||||
return `${urls.length}.${(hash >>> 0).toString(36)}`
|
||||
}
|
||||
|
||||
/** The last fingerprint found sound, '' for none, or null when storage is blocked. */
|
||||
function lastChecked(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(CHECKED_KEY) ?? ''
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function noteChecked(print: string): void {
|
||||
try {
|
||||
localStorage.setItem(CHECKED_KEY, print)
|
||||
} catch {
|
||||
// The next launch looks through the precache once more.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The build file behind a precache entry, fetched past every cache. 'gone' when
|
||||
* the server says it has no such file. Throws when no usable answer came: no
|
||||
* network, a timeout, a redirect to an auth wall, its login page, a server error.
|
||||
*/
|
||||
async function fetchBuildFile(key: Request): Promise<Response | 'gone'> {
|
||||
const url = new URL(key.url)
|
||||
url.searchParams.delete(REVISION_PARAM)
|
||||
url.searchParams.set(REPAIR_PARAM, Date.now().toString(36))
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), REPAIR_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(url.href, { cache: 'no-store', credentials: 'same-origin', redirect: 'error', signal: ctrl.signal })
|
||||
if (res.status === 404 || res.status === 410) return 'gone'
|
||||
if (res.status !== 200 || isPage(res)) throw new Error(`${res.status} for ${url.pathname}`)
|
||||
// A response of its own, without the one-off URL it was fetched from: a
|
||||
// module is known by its response URL. The body streams into the cache.
|
||||
return new Response(res.body, { status: 200, statusText: res.statusText, headers: res.headers })
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts right precache entries that hold a page instead of the build file their
|
||||
* URL names, and returns the paths of the ones it put right (#2524).
|
||||
*
|
||||
* A precache install keeps whatever answered with a 200. A server that answered
|
||||
* a missing build file with index.html, as 4.3.2 and older did, or a proxy that
|
||||
* answered with its login page, left a page under the name of a script, a
|
||||
* stylesheet or a font. Workbox reuses an entry for as long as a later release
|
||||
* lists its URL, without fetching it again, and every hashed file under
|
||||
* /assets/ keeps its URL until its content changes. So the broken entry is
|
||||
* carried from release to release. A broken script at least fails its import,
|
||||
* and the app recovers from that (chunkReload, shell-guard.js). A broken
|
||||
* stylesheet, font or icon raises no error at all: the app just loses its
|
||||
* layout or its typeface, on every launch, and offline too.
|
||||
*
|
||||
* Each such entry is fetched from the server again and put back in its place.
|
||||
* One the server does not have is removed, so the file is asked for on the
|
||||
* network and the next install stores it. When a fetch fails, the rest is left
|
||||
* for the next launch. A precache found sound is not looked at again until its
|
||||
* entries change, and without storage to note that in, nothing is done.
|
||||
*/
|
||||
export async function repairPrecache(): Promise<string[]> {
|
||||
const checked = lastChecked()
|
||||
if (checked === null || typeof caches === 'undefined') return []
|
||||
const names = (await caches.keys()).filter(name => name.includes(PRECACHE))
|
||||
const precaches = await Promise.all(names.map(async (name): Promise<Precache> => {
|
||||
const cache = await caches.open(name)
|
||||
return { name, cache, keys: await cache.keys() }
|
||||
}))
|
||||
const print = fingerprint(precaches)
|
||||
if (checked === print) return []
|
||||
|
||||
const repaired: string[] = []
|
||||
for (const { cache, keys } of precaches) {
|
||||
// Only the headers are read here, never a body.
|
||||
const broken = (await Promise.all(keys.map(async key => {
|
||||
if (!BUILD_FILE.test(new URL(key.url).pathname)) return null
|
||||
const response = await cache.match(key)
|
||||
return response && isPage(response) ? key : null
|
||||
}))).filter((key): key is Request => key !== null)
|
||||
|
||||
// One at a time: this is a way out, not a race, and a big chunk is not held
|
||||
// in memory next to another.
|
||||
for (const key of broken) {
|
||||
let fresh: Response | 'gone'
|
||||
try {
|
||||
fresh = await fetchBuildFile(key)
|
||||
} catch {
|
||||
return repaired
|
||||
}
|
||||
if (fresh === 'gone') {
|
||||
await cache.delete(key)
|
||||
} else {
|
||||
await cache.put(key, fresh)
|
||||
repaired.push(new URL(key.url).pathname)
|
||||
}
|
||||
}
|
||||
}
|
||||
noteChecked(print)
|
||||
return repaired
|
||||
}
|
||||
|
||||
const FONT_FILE = /\.(?:woff2?|ttf)$/i
|
||||
|
||||
/**
|
||||
* Loads the stylesheets this page took from a broken precache entry again, now
|
||||
* that the entry holds the stylesheet. The old link goes once the new one has
|
||||
* loaded, so the page keeps what styles it has until then.
|
||||
*
|
||||
* A font face that failed is not tried again in the same document. When a font
|
||||
* was put right, the app's own stylesheets, which declare every face it uses,
|
||||
* are loaded again too, and the faces they declare anew load the repaired file.
|
||||
*/
|
||||
export function restyle(paths: readonly string[]): void {
|
||||
if (paths.length === 0) return
|
||||
const fontRepaired = paths.some(path => FONT_FILE.test(path))
|
||||
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel~="stylesheet"][href]')) {
|
||||
const url = new URL(link.href, document.baseURI)
|
||||
const declaresFonts = fontRepaired && url.origin === location.origin && url.pathname.startsWith('/assets/')
|
||||
if (!declaresFonts && !paths.includes(url.pathname)) continue
|
||||
const fresh = link.cloneNode() as HTMLLinkElement
|
||||
fresh.addEventListener('load', () => link.remove(), { once: true })
|
||||
fresh.addEventListener('error', () => fresh.remove(), { once: true })
|
||||
link.after(fresh)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@link repairPrecache} once the page has loaded and the browser is idle,
|
||||
* and only while the app is online: offline there is nothing to fetch the files
|
||||
* from.
|
||||
*/
|
||||
export function schedulePrecacheRepair(): void {
|
||||
const run = () => {
|
||||
if (isEffectivelyOffline()) return
|
||||
repairPrecache().then(restyle, () => {
|
||||
// Cache Storage refused, so the next launch tries again.
|
||||
})
|
||||
}
|
||||
const whenIdle = () => {
|
||||
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 10_000 })
|
||||
else setTimeout(run, 2_000)
|
||||
}
|
||||
if (document.readyState === 'complete') whenIdle()
|
||||
else window.addEventListener('load', whenIdle, { once: true })
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class FakeWorker extends EventTarget {
|
||||
}
|
||||
|
||||
class FakeRegistration {
|
||||
active: FakeWorker | null = null
|
||||
installing: FakeWorker | null = null
|
||||
waiting: FakeWorker | null = null
|
||||
update = vi.fn(async () => this)
|
||||
@@ -40,6 +41,7 @@ class FakeContainer extends EventTarget {
|
||||
|
||||
function installWorker(controller: FakeWorker | null = new FakeWorker('activated')) {
|
||||
const registration = new FakeRegistration()
|
||||
registration.active = controller
|
||||
const container = new FakeContainer(controller)
|
||||
container.getRegistration.mockResolvedValue(registration)
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
@@ -71,11 +73,20 @@ function breakStorage(method: 'getItem' | 'setItem', broken: () => Storage) {
|
||||
}
|
||||
|
||||
const marker = () => localStorage.getItem('trek_app_version')
|
||||
// The version the bundle under test is built as (vite define, from package.json).
|
||||
const BUILT = __TREK_UI_VERSION__
|
||||
let reload: ReturnType<typeof vi.fn>
|
||||
let plainReload: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
// Every reload here asks for the page under a fresh URL (reloadFresh), which is
|
||||
// a location.replace. `plainReload` is the location.reload() it must not use.
|
||||
reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
|
||||
plainReload = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, href: 'http://localhost/dashboard', replace: reload, reload: plainReload },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -216,6 +227,80 @@ describe('reconcileAppVersion: a new version without a handover to wait for', ()
|
||||
container.takeOver()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-015: a page from the network built as the new version records it without a reload (#2524)', async () => {
|
||||
const { container } = installWorker(null)
|
||||
container.getRegistration.mockResolvedValue(undefined)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
|
||||
expect(marker()).toBe(BUILT)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(plainReload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-016: ...and the worker registered after it claims the page without a reload (#2524)', async () => {
|
||||
// Where the way out of a broken shell lands: the precache and the worker were
|
||||
// dropped, the page came from the server, and the new worker is installing.
|
||||
const { container, registration } = installWorker(null)
|
||||
updateInstalls(registration)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
container.takeOver()
|
||||
|
||||
expect(marker()).toBe(BUILT)
|
||||
expect(registration.update).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-019: ...and so does a page the worker registered after it has claimed already (#2524)', async () => {
|
||||
const { container, registration } = installWorker(null)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
// The fresh precache was quick, and its worker took the page before the server answered.
|
||||
container.takeOver()
|
||||
registration.active = container.controller
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
|
||||
expect(marker()).toBe(BUILT)
|
||||
expect(registration.update).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-017: a page a worker served goes through the handover whatever it was built as', async () => {
|
||||
const { container, registration } = installWorker()
|
||||
updateInstalls(registration)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
expect(marker()).toBe('4.3.0')
|
||||
|
||||
container.takeOver()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-018: the reload asks for the page under a fresh URL, past a proxy that keeps index.html (#2524)', async () => {
|
||||
const { container } = installWorker(null)
|
||||
container.getRegistration.mockResolvedValue(undefined)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
// A kept index.html runs an older bundle than the server reports.
|
||||
await reconcileAppVersion('4.3.1')
|
||||
|
||||
expect(plainReload).not.toHaveBeenCalled()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
const target = new URL(reload.mock.calls[0][0] as string)
|
||||
expect(target.pathname).toBe('/dashboard')
|
||||
expect(target.searchParams.get('trek-reload')).toBeTruthy()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('reconcileAppVersion: handing over to the new build', () => {
|
||||
@@ -289,6 +374,79 @@ describe('reconcileAppVersion: handing over to the new build', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileAppVersion: a page loaded past an older worker', () => {
|
||||
// A hard reload, or a load with the worker bypassed, gets the new bundle from
|
||||
// the network while the previous build's worker stays registered and would
|
||||
// serve its own shell to the next tab or launch (#2524).
|
||||
function pastOlderWorker() {
|
||||
const installed = installWorker(null)
|
||||
installed.registration.active = new FakeWorker('activated')
|
||||
return installed
|
||||
}
|
||||
|
||||
it('FE-UTIL-VERSION-025: asks for the new worker and records the version only once it has taken over', async () => {
|
||||
const { container, registration } = pastOlderWorker()
|
||||
const incoming = updateInstalls(registration)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
|
||||
expect(registration.update).toHaveBeenCalledTimes(1)
|
||||
expect(marker()).toBe('4.3.0')
|
||||
|
||||
incoming.become('activated')
|
||||
container.takeOver()
|
||||
expect(marker()).toBe(BUILT)
|
||||
// The page runs that bundle already, so there is nothing to reload for.
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(plainReload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-026: an install that goes redundant leaves the marker for the next launch', async () => {
|
||||
const { container, registration } = pastOlderWorker()
|
||||
const incoming = updateInstalls(registration)
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
incoming.become('redundant')
|
||||
container.takeOver()
|
||||
|
||||
expect(marker()).toBe('4.3.0')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-027: an unchanged sw.js means that worker is this build, so the version is recorded', async () => {
|
||||
const { container, registration } = pastOlderWorker()
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
// It claimed the page while the update was being checked.
|
||||
registration.update.mockImplementation(async () => {
|
||||
container.controller = registration.active
|
||||
return registration
|
||||
})
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
|
||||
expect(registration.update).toHaveBeenCalledTimes(1)
|
||||
expect(marker()).toBe(BUILT)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-028: an update that fails leaves the marker for the next launch', async () => {
|
||||
const { registration } = pastOlderWorker()
|
||||
registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
await reconcileAppVersion(BUILT)
|
||||
|
||||
expect(marker()).toBe('4.3.0')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileAppVersion: the reload guard', () => {
|
||||
it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => {
|
||||
const { reconcileAppVersion } = await loadModule()
|
||||
@@ -309,9 +467,9 @@ describe('reconcileAppVersion: the reload guard', () => {
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
|
||||
await reconcileAppVersion('4.3.1')
|
||||
await reconcileAppVersion('4.3.2')
|
||||
await reconcileAppVersion('4.3.1-1')
|
||||
|
||||
expect(marker()).toBe('4.3.2')
|
||||
expect(marker()).toBe('4.3.1-1')
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,25 +17,20 @@
|
||||
* version and session.
|
||||
*/
|
||||
|
||||
import { reloadFresh } from './chunkReload'
|
||||
import { currentController, servingWorker } from './serviceWorkerShell'
|
||||
|
||||
const VERSION_KEY = 'trek_app_version'
|
||||
const RELOAD_KEY = 'trek_app_version_reload'
|
||||
|
||||
/** The worker controlling this page, or null. Reading it can throw where site data is blocked. */
|
||||
function currentController(): ServiceWorker | null {
|
||||
try {
|
||||
return 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// The version this bundle was built as (vite.config.js). A bare tsc run has none,
|
||||
// and then no version counts as this bundle's own.
|
||||
const BUNDLE_VERSION: string = typeof __TREK_UI_VERSION__ === 'string' ? __TREK_UI_VERSION__ : ''
|
||||
|
||||
// The worker that served this page, noted when the app loads. The browser checks
|
||||
// sw.js on every navigation by itself, and when that check installs the new build
|
||||
// before the server has told us its version, the takeover is over before anyone
|
||||
// listens for it. A controller other than this one means the new shell is only a
|
||||
// reload away. A page served without a worker came from the network, so a worker
|
||||
// claiming it later is no takeover.
|
||||
const servedBy = currentController()
|
||||
// The worker that served this page, noted when the app loads (serviceWorkerShell).
|
||||
// When the browser's own check of sw.js installs the new build before the server
|
||||
// has told us its version, the takeover is over before anyone listens for it.
|
||||
const servedBy = servingWorker()
|
||||
|
||||
function markApplied(version: string): void {
|
||||
try { localStorage.setItem(VERSION_KEY, version) } catch { /* site data blocked */ }
|
||||
@@ -54,10 +49,18 @@ function reloadOnce(version: string): void {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
window.location.reload()
|
||||
// Under a URL no cache has seen yet: without a worker, a proxy that keeps
|
||||
// index.html against its headers would answer a plain reload with the very
|
||||
// shell this page runs, and the marker has moved on already (#2524).
|
||||
reloadFresh()
|
||||
}
|
||||
|
||||
async function handOver(reg: ServiceWorkerRegistration, version: string): Promise<void> {
|
||||
/**
|
||||
* Asks the worker for the new build and moves the marker once it has taken over.
|
||||
* `runsIt` is a page that already runs the new bundle, so the takeover needs no
|
||||
* reload to show it.
|
||||
*/
|
||||
async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt: boolean): Promise<void> {
|
||||
// Listening before asking for the update, so a quick takeover is not missed.
|
||||
// Giving up disarms the listener: the reload belongs to this launch, and one
|
||||
// arriving later in the session would hit the user in the middle of an edit.
|
||||
@@ -65,7 +68,7 @@ async function handOver(reg: ServiceWorkerRegistration, version: string): Promis
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (!armed) return
|
||||
markApplied(version)
|
||||
reloadOnce(version)
|
||||
if (!runsIt) reloadOnce(version)
|
||||
}, { once: true })
|
||||
|
||||
try {
|
||||
@@ -80,7 +83,7 @@ async function handOver(reg: ServiceWorkerRegistration, version: string): Promis
|
||||
// sw.js is unchanged, so the release changed nothing the precache holds and
|
||||
// the bundle this page runs is the current one.
|
||||
armed = false
|
||||
if (currentController() === servedBy) markApplied(version)
|
||||
if (runsIt || currentController() === servedBy) markApplied(version)
|
||||
return
|
||||
}
|
||||
// Precaching is all or nothing: one failed request out of several hundred and
|
||||
@@ -129,14 +132,31 @@ export async function reconcileAppVersion(reported: unknown): Promise<void> {
|
||||
// Left for the next launch. A reload now would only reach the old worker again.
|
||||
return
|
||||
}
|
||||
// Without a worker the page came from the network and runs the new bundle
|
||||
// already, so the reload only confirms it. A controller other than the one
|
||||
// that served the page means the browser finished the handover by itself,
|
||||
// and the reload is what shows the new build.
|
||||
|
||||
// A page that came from the network and was built as this version runs it
|
||||
// already. That is where the way out of a broken app shell lands, before the
|
||||
// new app could move the marker (#2524): the old worker is gone, and the one
|
||||
// registered since is this build. Reloading once it has taken over would only
|
||||
// interrupt the user a few seconds in, with nothing new to show. An older
|
||||
// worker still in charge (this page was loaded past it, as a hard reload
|
||||
// does) would serve its own build to the next tab or launch, so it is handed
|
||||
// over like any other, just without the reload.
|
||||
const runsIt = !servedBy && version === BUNDLE_VERSION
|
||||
if (runsIt && (!reg?.active || reg.active === currentController())) {
|
||||
markApplied(version)
|
||||
return
|
||||
}
|
||||
|
||||
// Without a worker the page came from the network, yet was not built as this
|
||||
// version: a proxy or CDN handed out an index.html it kept, or the server
|
||||
// reports a version its bundle was not built as, and then the reload only
|
||||
// confirms what runs. A controller other than the one that served the page
|
||||
// means the browser finished the handover by itself, and the reload is what
|
||||
// shows the new build.
|
||||
if (!reg || (servedBy && currentController() !== servedBy)) {
|
||||
markApplied(version)
|
||||
reloadOnce(version)
|
||||
return
|
||||
}
|
||||
await handOver(reg, version)
|
||||
await handOver(reg, version, runsIt)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ export const sharedHandlers = [
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
const { token } = params;
|
||||
|
||||
// What the share endpoint answers for an unknown, expired or revoked token.
|
||||
if (token === 'invalid-token' || token === 'expired-token') {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
return HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 });
|
||||
}
|
||||
|
||||
const trip = { ...buildTrip({ start_date: '2026-07-01', end_date: '2026-07-05' }), title: 'Shared Paris Trip' };
|
||||
|
||||
@@ -2,9 +2,10 @@ import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { hotelLegsForDay } from '../../../src/mobile/screens/trip/plan/planTimelineModel';
|
||||
import { buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
import type { TripStoreState } from '../../../src/store/tripStore';
|
||||
import type { RouteSegment } from '../../../src/types';
|
||||
import type { Day, RouteSegment } from '../../../src/types';
|
||||
|
||||
vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../../src/components/Map/RouteCalculator')>();
|
||||
@@ -996,4 +997,129 @@ describe('useRouteCalculation', () => {
|
||||
expect(result.current.route).toBeNull();
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('hotel bookend legs are tagged on the segments (#2501)', () => {
|
||||
// Every request answers one leg per neighbouring waypoint pair, echoing the
|
||||
// waypoints the way OSRM's legs do.
|
||||
const echoLegs = () => (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(waypoints: { lat: number; lng: number }[]) => Promise.resolve({
|
||||
coordinates: [] as [number, number][],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
legs: waypoints.slice(0, -1).map((w, i) => ({
|
||||
...MOCK_SEGMENTS[0],
|
||||
from: [w.lat, w.lng] as [number, number],
|
||||
to: [waypoints[i + 1].lat, waypoints[i + 1].lng] as [number, number],
|
||||
})),
|
||||
}),
|
||||
);
|
||||
const tagsOf = (segments: RouteSegment[]) =>
|
||||
segments.map(s => [`${s.from[0]},${s.from[1]} > ${s.to[0]},${s.to[1]}`, s.hotelBookend ?? null]);
|
||||
|
||||
it('FE-HOOK-ROUTE-036: a day out of the hotel tags the drive out and the drive back, nothing else', async () => {
|
||||
echoLegs();
|
||||
const hotel = { lat: 41.39, lng: 2.16 };
|
||||
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
|
||||
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
|
||||
const store = { assignments: { '2': [
|
||||
buildAssignment({ day_id: 2, order_index: 0, place: actA }),
|
||||
buildAssignment({ day_id: 2, order_index: 1, place: actB }),
|
||||
] } } as unknown as TripStoreState;
|
||||
useTripStore.setState({
|
||||
assignments: store.assignments,
|
||||
reservations: [],
|
||||
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
|
||||
} as unknown as Partial<TripStoreState>);
|
||||
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRouteCalculation(store, 2, true, 'driving', accommodations as unknown as Parameters<typeof useRouteCalculation>[4])
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(tagsOf(result.current.routeSegments)).toEqual([
|
||||
[`${hotel.lat},${hotel.lng} > ${actA.lat},${actA.lng}`, 'morning'],
|
||||
[`${actA.lat},${actA.lng} > ${actB.lat},${actB.lng}`, null],
|
||||
[`${actB.lat},${actB.lng} > ${hotel.lat},${hotel.lng}`, 'evening'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-037: a hotel stop of your own after landing is no morning bookend', async () => {
|
||||
// The reported day: fly in, drive to the hotel you put on the day yourself, then
|
||||
// on to the town hall. Only the drive back to the hotel tonight is a bookend.
|
||||
echoLegs();
|
||||
const hotel = { lat: 53.5465, lng: 9.9727 };
|
||||
const ams = { lat: 52.3105, lng: 4.7683 };
|
||||
const ham = { lat: 53.6304, lng: 9.9882 };
|
||||
const hotelStop = buildPlace({ lat: hotel.lat, lng: hotel.lng });
|
||||
const townHall = buildPlace({ lat: 53.5503, lng: 9.9937 });
|
||||
const flight = {
|
||||
id: 90, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: -0.5,
|
||||
reservation_time: '2026-10-19T10:00', reservation_end_time: '2026-10-19T13:00',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, lat: ams.lat, lng: ams.lng },
|
||||
{ role: 'to', sequence: 1, lat: ham.lat, lng: ham.lng },
|
||||
],
|
||||
};
|
||||
const store = { assignments: { '1': [
|
||||
buildAssignment({ day_id: 1, order_index: 0, place: hotelStop }),
|
||||
buildAssignment({ day_id: 1, order_index: 1, place: townHall }),
|
||||
] } } as unknown as TripStoreState;
|
||||
useTripStore.setState({
|
||||
assignments: store.assignments,
|
||||
reservations: [flight],
|
||||
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
|
||||
} as unknown as Partial<TripStoreState>);
|
||||
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00', place_lat: hotel.lat, place_lng: hotel.lng }];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRouteCalculation(store, 1, true, 'driving', accommodations as unknown as Parameters<typeof useRouteCalculation>[4])
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(tagsOf(result.current.routeSegments)).toEqual([
|
||||
[`${ham.lat},${ham.lng} > ${hotel.lat},${hotel.lng}`, null],
|
||||
[`${hotel.lat},${hotel.lng} > ${townHall.lat},${townHall.lng}`, null],
|
||||
[`${townHall.lat},${townHall.lng} > ${hotel.lat},${hotel.lng}`, 'evening'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-038: after a day switch the last day\'s bookends are no legs of the new day\'s hotel', async () => {
|
||||
// Day 1 sleeps in Munich, day 3 in Hamburg. The segments of day 1 stay in place
|
||||
// until day 3 has routed, and that request may take a while or never answer.
|
||||
echoLegs();
|
||||
const days = [1, 2, 3, 4].map(n => ({ id: n, trip_id: 1, day_number: n, date: `2026-05-0${n}`, title: null }));
|
||||
const munich = { id: 1, trip_id: 1, place_name: 'Munich Inn', place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 };
|
||||
const hamburg = { id: 2, trip_id: 1, place_name: 'Hamburg Inn', place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 4 };
|
||||
const stays = [munich, hamburg] as unknown as Parameters<typeof useRouteCalculation>[4];
|
||||
const store = { assignments: {
|
||||
'1': [
|
||||
buildAssignment({ day_id: 1, order_index: 0, place: buildPlace({ lat: 48.14, lng: 11.58 }) }),
|
||||
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: 48.15, lng: 11.59 }) }),
|
||||
],
|
||||
'3': [
|
||||
buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 53.56, lng: 9.99 }) }),
|
||||
buildAssignment({ day_id: 3, order_index: 1, place: buildPlace({ lat: 53.57, lng: 10.0 }) }),
|
||||
],
|
||||
} } as unknown as TripStoreState;
|
||||
useTripStore.setState({ assignments: store.assignments, reservations: [], days } as unknown as Partial<TripStoreState>);
|
||||
const legsOf = (dayId: number, segments: RouteSegment[]) =>
|
||||
hotelLegsForDay(days[dayId - 1] as unknown as Day, days as unknown as Day[], stays, segments);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ dayId }: { dayId: number }) => useRouteCalculation(store, dayId, true, 'driving', stays),
|
||||
{ initialProps: { dayId: 1 } },
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(legsOf(1, result.current.routeSegments)).toMatchObject({ top: { name: 'Munich Inn' }, bottom: { name: 'Munich Inn' } });
|
||||
|
||||
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
|
||||
rerender({ dayId: 3 });
|
||||
await act(async () => {});
|
||||
|
||||
// Day 1's legs are still there, and Hamburg shows none of them.
|
||||
expect(result.current.routeSegments.some(s => s.hotelBookend)).toBe(true);
|
||||
expect(legsOf(3, result.current.routeSegments)).toEqual({ top: null, bottom: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMe
|
||||
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
||||
import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore'
|
||||
import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
|
||||
import { buildBudgetItem } from '../../../helpers/factories'
|
||||
import { buildBudgetItem, buildTrip } from '../../../helpers/factories'
|
||||
import { localToday } from '../../../../src/components/Planner/today'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030
|
||||
// The sheet reads its copy from useTranslation(), so assertions are English.
|
||||
@@ -648,6 +648,66 @@ describe('MCostSheet', () => {
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-035: an expense saved without a currency reopens in the trip currency (#2525)', async () => {
|
||||
// No currency on the row means the trip's own. Seeding the display currency
|
||||
// instead labelled 100 euro as 100 dollars, and a plain save stored it so.
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.8 }, ts: Date.now() }))
|
||||
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
||||
const editing = buildBudgetItem({
|
||||
id: 12, name: 'Tram pass', category: 'transport', currency: null, total_price: 100,
|
||||
members: [member(1, null)],
|
||||
payers: [{ user_id: 1, amount: 100 }],
|
||||
})
|
||||
renderSheet({ base: 'USD', editing })
|
||||
|
||||
expect(totalField()).toHaveValue('100,00')
|
||||
expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'USD $' })).toBeNull()
|
||||
|
||||
fireEvent.click(saveBtn())
|
||||
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 12, expect.objectContaining({
|
||||
total_price: 100,
|
||||
currency: 'EUR',
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => {
|
||||
// A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro.
|
||||
// Same currency as the list, so the sheet said nothing, while the list showed the
|
||||
// booked euros at today's rate beside the 801.76 typed here.
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() }))
|
||||
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
||||
const editing = buildBudgetItem({
|
||||
id: 13, name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, total_price: 801.76,
|
||||
members: [member(1, null), member(2, null)],
|
||||
payers: [{ user_id: 1, amount: 801.76 }],
|
||||
})
|
||||
renderSheet({ base: 'USD', editing })
|
||||
|
||||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => {
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() }))
|
||||
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
||||
const editing = buildBudgetItem({
|
||||
id: 14, name: 'Villa', category: 'accommodation', currency: 'USD', exchange_rate: 1.1398, total_price: 12345.67,
|
||||
members: [member(1, null), member(2, null)],
|
||||
payers: [{ user_id: 1, amount: 12345.67 }],
|
||||
})
|
||||
renderSheet({ base: 'USD', editing })
|
||||
|
||||
expect(screen.queryByText(/live rate/)).toBeNull()
|
||||
expect(screen.queryByText(/→/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => {
|
||||
localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() }))
|
||||
renderSheet({ base: 'XTS' })
|
||||
|
||||
@@ -4,6 +4,7 @@ import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { budgetApi } from '../../../../src/api/client'
|
||||
import { localToday } from '../../../../src/components/Planner/today'
|
||||
import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreezeMissingRates'
|
||||
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
@@ -14,7 +15,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-COSTT-001 to FE-MOB-COSTT-044
|
||||
// FE-MOB-COSTT-001 to FE-MOB-COSTT-049
|
||||
|
||||
// The add/edit expense sheet is the shared desktop-sized form; the panel only
|
||||
// owns when it opens and what happens on save, so it is stubbed here.
|
||||
@@ -639,7 +640,8 @@ describe('MCostsTab', () => {
|
||||
expect(mine).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(within(card).getAllByText('+$58.00')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/60\.00/)).not.toBeInTheDocument()
|
||||
expect(within(card).getByText('Museum')).toBeInTheDocument()
|
||||
// Named by what Bob entered, as the list names it (#2525), never re-converted.
|
||||
expect(within(card).getByText('Museum · £30.00')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(bob)
|
||||
expect(bob).toHaveAttribute('aria-expanded', 'false')
|
||||
@@ -722,7 +724,137 @@ describe('MCostsTab', () => {
|
||||
expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
|
||||
expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' })
|
||||
// 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',
|
||||
fallback_fx: { base: 'USD', rates: { GBP: 0.5 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => {
|
||||
// A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the
|
||||
// euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55
|
||||
// today, and that is what settle-up offers too; the row now says where the
|
||||
// 801.76 that was entered went instead of hiding it. Converted with the euro's own
|
||||
// quote, not the dollar's separately rounded one.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() }))
|
||||
const hotel = {
|
||||
id: 21, trip_id: 7, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||||
expense_date: '2026-05-01', note: null,
|
||||
members: [
|
||||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
|
||||
],
|
||||
payers: [{ user_id: 1, amount: 801.76, username: 'Me' }],
|
||||
} as unknown as BudgetItem
|
||||
serveSettlement({
|
||||
...SETTLEMENT,
|
||||
settlements: [
|
||||
{ id: 601, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, settled_at: '2026-05-02', created_at: '2026-05-02T09:00:00Z' },
|
||||
// A transfer from before they carried a currency was entered in the display currency.
|
||||
{ id: 602, from_user_id: 3, to_user_id: 1, amount: 30, currency: null, settled_at: '2026-05-03', created_at: '2026-05-03T09:00:00Z' },
|
||||
],
|
||||
})
|
||||
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] }))
|
||||
|
||||
const card = cardOf('Aparthotel Silver')
|
||||
expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(card).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share is half of the booked euros, the figure the balances net.
|
||||
expect(within(card).getAllByText('$395.77')).toHaveLength(2)
|
||||
expect(within(card).queryByText('$400.88')).toBeNull()
|
||||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55')
|
||||
expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
|
||||
const paid = up(screen.getByText('Ada → costs.you'), 2)
|
||||
expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-046: a bill booked at today\'s rate reads exactly as typed (#2525)', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() }))
|
||||
const villa = {
|
||||
id: 22, trip_id: 7, name: 'Villa', category: 'accommodation', total_price: 12345.67, currency: 'USD', exchange_rate: 1.1398,
|
||||
expense_date: '2026-05-01', note: null,
|
||||
members: [
|
||||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
|
||||
],
|
||||
payers: [{ user_id: 1, amount: 12345.67, username: 'Me' }],
|
||||
} as unknown as BudgetItem
|
||||
serveSettlement(SETTLEMENT)
|
||||
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [villa] }))
|
||||
|
||||
const card = cardOf('Villa')
|
||||
// Through the dollar's own quote it read $12,346.05.
|
||||
expect(within(card).getByText('$12,345.67')).toBeInTheDocument()
|
||||
expect(within(card).queryByText(/^\$12,345\.67 →/)).toBeNull()
|
||||
expect(screen.getByText('costs.spent:$12,345.67')).toBeInTheDocument()
|
||||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67')
|
||||
})
|
||||
|
||||
// The server could not fetch rates, so the museum's pounds never froze one and the
|
||||
// settlement left them out. An editor's phone lends its own rate and reads again.
|
||||
it('FE-MOB-COSTT-047: an editor heals a bill the server could not convert and reads the settlement again', async () => {
|
||||
resetFreezeAttempts()
|
||||
let healed = false
|
||||
server.use(
|
||||
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
|
||||
settlementBases.push(new URL(request.url).searchParams.get('base') ?? '')
|
||||
return HttpResponse.json(healed
|
||||
? { ...SETTLEMENT, unconverted: { item_ids: [], settlement_ids: [], currencies: [] } }
|
||||
: { ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } })
|
||||
}),
|
||||
)
|
||||
const freeze = vi.spyOn(budgetApi, 'freezeRates').mockImplementation(async () => {
|
||||
healed = true
|
||||
return { items: [{ ...MUSEUM, exchange_rate: 0.5 }], settlements: [], unresolved: [] }
|
||||
})
|
||||
await renderTab()
|
||||
|
||||
await waitFor(() => expect(freeze).toHaveBeenCalledWith(7, { fallback_fx: { base: 'USD', rates: { GBP: 0.5 } } }))
|
||||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||||
expect(freeze).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-048: a read-only member\'s phone never freezes a rate', async () => {
|
||||
resetFreezeAttempts()
|
||||
serveSettlement({ ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } })
|
||||
const freeze = vi.spyOn(budgetApi, 'freezeRates')
|
||||
await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(freeze).not.toHaveBeenCalled()
|
||||
expect(settlementBases).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-049: asks the settlement in the display currency with the phone\'s own rate for it', async () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' },
|
||||
})
|
||||
const rates: (string | null)[] = []
|
||||
server.use(
|
||||
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
settlementBases.push(url.searchParams.get('base') ?? '')
|
||||
rates.push(url.searchParams.get('base_rate'))
|
||||
return HttpResponse.json(SETTLEMENT)
|
||||
}),
|
||||
)
|
||||
render(<MCostsTab planner={planner()} shell={buildShell()} />)
|
||||
await screen.findByText('+£12.50')
|
||||
|
||||
// Pounds per dollar from the trip currency's own table, what the server labels with
|
||||
// when it cannot fetch a quote itself.
|
||||
expect(settlementBases).toEqual(['GBP'])
|
||||
expect(rates).toEqual(['0.5'])
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => {
|
||||
|
||||
@@ -826,6 +826,18 @@ describe('MTransportFormSheet', () => {
|
||||
expect((addFile.mock.calls[0][1] as FormData).get('description')).toBe('Rental car')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-029b: a parsed price keeps the currency it was quoted in (#2525)', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const planner = makePlanner({
|
||||
transportPrefill: { type: 'train', title: 'Amtrak', metadata: { price: 117, priceCurrency: 'USD' }, endpoints: [] },
|
||||
handleSaveTransport,
|
||||
})
|
||||
renderSheet(planner)
|
||||
await submit()
|
||||
// Sent without it, the server stored 117 in the trip currency.
|
||||
expect(handleSaveTransport.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 117, category: 'transport', currency: 'USD' })
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-030: a prefill without a price and with the budget addon off skips the cost entry', async () => {
|
||||
seedStore(useAddonStore, { addons: [] })
|
||||
const handleSaveTransport = makeSave()
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
groupByDay,
|
||||
groupLedgerByDay,
|
||||
isUnfinished,
|
||||
lineOf,
|
||||
memberShareOf,
|
||||
myPaidOf,
|
||||
myShareOf,
|
||||
@@ -32,6 +33,7 @@ const ctx: CostsCtx = {
|
||||
me: 1,
|
||||
// lower case on purpose — currencyOf has to normalise it
|
||||
tripCurrency: 'eur',
|
||||
displayCurrency: 'EUR',
|
||||
convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1),
|
||||
};
|
||||
|
||||
@@ -306,6 +308,86 @@ describe('costsModel — list filters and grouping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel: a bill entered in the display currency (#2525)', () => {
|
||||
// A euro trip read in dollars, converted with the euro's own quote the way
|
||||
// useExchangeRates(base, tripCurrency) converts: one euro buys 1.1551 dollars today.
|
||||
const usdCtx: CostsCtx = {
|
||||
me: 1,
|
||||
tripCurrency: 'EUR',
|
||||
displayCurrency: 'usd',
|
||||
convert: (amount, currency) => {
|
||||
const perEuro = ({ USD: 1.1551, EUR: 1, SEK: 10 } as Record<string, number>)[(currency || 'USD').toUpperCase()] ?? 1
|
||||
return (amount / perEuro) * 1.1551
|
||||
},
|
||||
};
|
||||
const hotel = (rate = 1.17) => expense({
|
||||
total_price: 801.76, currency: 'USD', exchange_rate: rate,
|
||||
payers: [payer(1, 801.76)], members: [member(1), member(2)],
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since counts as its euros at today\'s rate', () => {
|
||||
// 685.26 EUR, which the balances net and settle-up offers; the row, the payer's
|
||||
// figure and the share read the same, or a payback of the row's half reopens the trip.
|
||||
expect(baseTotal(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
|
||||
expect(myPaidOf(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
|
||||
expect(myShareOf(hotel(), usdCtx)).toBeCloseTo(395.77, 2);
|
||||
// Under it, what was entered and the euros it was booked at, no third step.
|
||||
const line = lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))!;
|
||||
expect(line.entered).toEqual({ amount: 801.76, currency: 'USD' });
|
||||
expect(line.into.currency).toBe('EUR');
|
||||
expect(line.into.amount).toBeCloseTo(685.26, 2);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => {
|
||||
const big = expense({ total_price: 12345.67, currency: 'USD', exchange_rate: 1.1551, payers: [payer(1, 12345.67)], members: [member(1), member(2)] });
|
||||
const unpaid = expense({ total_price: 250, currency: 'USD', exchange_rate: 1.1551 });
|
||||
expect(Math.round(baseTotal(big, usdCtx) * 100)).toBe(1234567);
|
||||
expect(Math.round(baseTotal(unpaid, usdCtx) * 100)).toBe(25000);
|
||||
expect(lineOf(12345.67, big, usdCtx, baseTotal(big, usdCtx))).toBeNull();
|
||||
const t = computeTotals([big, unpaid], [], usdCtx);
|
||||
expect(Math.round(t.totalSpend * 100)).toBe(1259567);
|
||||
expect(Math.round(t.outstanding * 100)).toBe(25000);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-040: other currencies go through the trip currency at their booked rate', () => {
|
||||
// 1000 SEK booked at 10 per euro is 100 euro, and 100 euro is 115.51 dollars today.
|
||||
const sek = expense({ total_price: 1000, currency: 'SEK', exchange_rate: 10 })
|
||||
expect(baseTotal(sek, usdCtx)).toBeCloseTo(115.51, 2);
|
||||
expect(lineOf(1000, sek, usdCtx, baseTotal(sek, usdCtx))).toEqual({ entered: { amount: 1000, currency: 'SEK' }, into: { amount: 100, currency: 'EUR' } });
|
||||
// A row in the trip currency goes straight to the dollars.
|
||||
const eur = expense({ total_price: 100, currency: null })
|
||||
expect(baseTotal(eur, usdCtx)).toBeCloseTo(115.51, 2);
|
||||
expect(lineOf(100, eur, usdCtx, 115.51)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 115.51, currency: 'USD' } });
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-041: there is nothing to explain when nothing was converted', () => {
|
||||
// An amount of 0, a row written before the freeze, and any row when the viewer
|
||||
// reads the trip currency itself.
|
||||
const zero = expense({ total_price: 0, currency: 'USD', exchange_rate: 1.17 });
|
||||
expect(lineOf(0, zero, usdCtx, 0)).toBeNull();
|
||||
const legacy = expense({ total_price: 50, currency: 'USD', exchange_rate: 1 });
|
||||
expect(baseTotal(legacy, usdCtx)).toBe(50);
|
||||
expect(lineOf(50, legacy, usdCtx, 50)).toBeNull();
|
||||
const inEur = { ...usdCtx, displayCurrency: 'EUR', convert: (a: number) => a };
|
||||
expect(lineOf(801.76, hotel(), inEur, 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } });
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-042: the CSV adds what each row counts as in the trip currency', () => {
|
||||
const t = ((key: string) => key) as unknown as Parameters<typeof buildCostsCsv>[1]['t'];
|
||||
const items = [
|
||||
hotel(),
|
||||
expense({ name: 'Old taxi', total_price: 115.51, currency: 'USD', exchange_rate: 1, expense_date: '2026-07-20' }),
|
||||
].map((e, i) => ({ ...e, name: e.name || 'Hotel', expense_date: e.expense_date || '2026-07-19', id: i + 1 }));
|
||||
const inUsd = buildCostsCsv(items, { base: 'USD', ctx: usdCtx, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
|
||||
expect(inUsd[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note');
|
||||
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/);
|
||||
// Never booked, so counted at today's rate, as the server counts it.
|
||||
expect(inUsd[2]).toMatch(/;115\.51;USD;100\.00;115\.51;$/);
|
||||
const inEur = buildCostsCsv(items, { base: 'EUR', ctx: { ...usdCtx, displayCurrency: 'EUR' }, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
|
||||
expect(inEur[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note');
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel — ledger grouping (expenses + settlement payments)', () => {
|
||||
it('FE-MOB-CMOD-032: merges expenses and payments into shared day groups, newest first, no-date last', () => {
|
||||
const e = expense({ id: 50, expense_date: '2026-07-02' });
|
||||
|
||||
@@ -5,14 +5,14 @@ import {
|
||||
hotelLegsForDay, itemHasTime, parseReservationMeta, transportSubtitle, weatherIconFor,
|
||||
type TransportEntry,
|
||||
} from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
|
||||
import { getDisplayTimeForDay, type MergedItem } from '../../../../src/utils/dayMerge'
|
||||
import { buildDayRouteRuns } from '../../../../src/components/Map/dayRoutePlan'
|
||||
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, type MergedItem } from '../../../../src/utils/dayMerge'
|
||||
import { buildDayRouteRuns, hotelBookendOf, type DayRoutePoint } from '../../../../src/components/Map/dayRoutePlan'
|
||||
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
|
||||
import type {
|
||||
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
|
||||
} from '../../../../src/types'
|
||||
|
||||
// FE-MOB-PTLM-001 to FE-MOB-PTLM-047
|
||||
// FE-MOB-PTLM-001 to FE-MOB-PTLM-060
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
|
||||
@@ -44,6 +44,14 @@ function seg(from: [number, number], to: [number, number], overrides: Partial<Ro
|
||||
}
|
||||
}
|
||||
|
||||
// What useRouteCalculation hands the timeline: one segment per pair of neighbouring
|
||||
// waypoints in each drawn run, the hotel bookends tagged as such.
|
||||
const segmentsOf = (runs: DayRoutePoint[][]): RouteSegment[] =>
|
||||
runs.flatMap(run => run.slice(1).map((p, i) => {
|
||||
const hotelBookend = hotelBookendOf(run[i], p)
|
||||
return seg([run[i].lat, run[i].lng], [p.lat, p.lng], hotelBookend ? { hotelBookend } : {})
|
||||
}))
|
||||
|
||||
const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.order_index, data: a })
|
||||
const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n })
|
||||
const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r })
|
||||
@@ -223,7 +231,9 @@ describe('planTimelineModel — buildPlanRows', () => {
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'note', 'place'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-019: draws no connector when a transport is the hop between the places', () => {
|
||||
it('FE-MOB-PTLM-019: puts the drive past a booking without a location under that booking (#2502)', () => {
|
||||
// The route rides straight past a bus saved without its stops and draws the road
|
||||
// between the two places; the desktop day plan shows that leg under the bus.
|
||||
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2 })
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), transportItem(bus), placeItem(park)],
|
||||
@@ -231,7 +241,9 @@ describe('planTimelineModel — buildPlanRows', () => {
|
||||
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'transport', 'place'])
|
||||
expect(rows.map(r => r.key)).toEqual(['pl-11', 'tr-51', 'conn-tr-51', 'pl-12'])
|
||||
// The leg was routed in the mode of the stop it left, so the menu edits that stop.
|
||||
expect(rows[2]).toMatchObject({ kind: 'conn', assignmentId: 11 })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-020: skips places without coordinates and unmatched segments', () => {
|
||||
@@ -295,30 +307,46 @@ describe('planTimelineModel — hotel chips and legs', () => {
|
||||
|
||||
it('FE-MOB-PTLM-025: picks the hotel bookend legs out of the calculated segments', () => {
|
||||
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
|
||||
const out = seg([48.0, 16.0], [48.1, 16.1])
|
||||
const back = seg([48.2, 16.2], [48.0, 16.0])
|
||||
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, back, seg([48.1, 16.1], [48.2, 16.2])])
|
||||
const out = seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' })
|
||||
const back = seg([48.2, 16.2], [48.0, 16.0], { hotelBookend: 'evening' })
|
||||
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, seg([48.1, 16.1], [48.2, 16.2]), back])
|
||||
expect(legs.top).toEqual({ seg: out, name: 'Hotel Sacher' })
|
||||
expect(legs.bottom).toEqual({ seg: back, name: 'Hotel Sacher' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-026: returns no legs when no segment touches the hotel', () => {
|
||||
it('FE-MOB-PTLM-026: returns no legs when the calculation drew no bookend, whatever touches the hotel (#2501)', () => {
|
||||
// A stop on the hotel's own spot starts and ends legs at its coordinates too.
|
||||
const hotel = accommodation({ id: 8, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
|
||||
expect(hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
|
||||
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.0, 16.0]), seg([48.0, 16.0], [48.2, 16.2])])
|
||||
expect(legs).toEqual({ top: null, bottom: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-027: returns no legs without an accommodation on the day', () => {
|
||||
expect(hotelLegsForDay(DAY2, DAYS, [], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
|
||||
const tagged = [seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' }), seg([48.1, 16.1], [48.0, 16.0], { hotelBookend: 'evening' })]
|
||||
expect(hotelLegsForDay(DAY2, DAYS, [], tagged)).toEqual({ top: null, bottom: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-045: the evening leg is the last drive into the hotel, not the first (#2476)', () => {
|
||||
it('FE-MOB-PTLM-050: another stay\'s bookends are not this day\'s, as right after a day switch (#2501)', () => {
|
||||
// The calc still holds the day before's legs, out of and back to Munich, while
|
||||
// the new day, a night in Hamburg, is being routed.
|
||||
const munich = { lat: 48.137, lng: 11.575 }
|
||||
const hamburg = accommodation({ id: 9, start_day_id: 2, end_day_id: 3, place_name: 'Hamburg Inn', place_lat: 53.551, place_lng: 9.993 })
|
||||
const stale = [
|
||||
seg([munich.lat, munich.lng], [48.14, 11.58], { hotelBookend: 'morning' }),
|
||||
seg([48.14, 11.58], [48.15, 11.59]),
|
||||
seg([48.15, 11.59], [munich.lat, munich.lng], { hotelBookend: 'evening' }),
|
||||
]
|
||||
expect(hotelLegsForDay(DAY2, DAYS, [hamburg], stale)).toEqual({ top: null, bottom: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-045: the evening leg is the drive that closes the day, not an earlier one onto the hotel spot (#2476)', () => {
|
||||
// A stop planned on the hotel's own spot early in the day: the drive there reaches
|
||||
// the hotel's coordinates first, but the day ends with the drive back from the park.
|
||||
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
|
||||
const out = seg([48.0, 16.0], [48.1, 16.1])
|
||||
const out = seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' })
|
||||
const toHotelSpot = seg([48.1, 16.1], [48.0, 16.0])
|
||||
const onward = seg([48.0, 16.0], [48.2, 16.2])
|
||||
const back = seg([48.2, 16.2], [48.0, 16.0])
|
||||
const back = seg([48.2, 16.2], [48.0, 16.0], { hotelBookend: 'evening' })
|
||||
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, toHotelSpot, onward, back])
|
||||
expect(legs.top?.seg).toBe(out)
|
||||
expect(legs.bottom?.seg).toBe(back)
|
||||
@@ -344,14 +372,11 @@ describe('planTimelineModel — hotel chips and legs', () => {
|
||||
]
|
||||
: [],
|
||||
})
|
||||
// What the connector calculation hands the timeline: one segment per pair of
|
||||
// neighbouring waypoints in each drawn run.
|
||||
const legsOf = (reservations: Reservation[]) => {
|
||||
const runs = buildDayRouteRuns(2, {
|
||||
days: DAYS, assignments: {}, reservations, accommodations: stays, optimizeFromAccommodation: true,
|
||||
})
|
||||
const segments = runs.flatMap(run => run.slice(1).map((p, i) => seg([run[i].lat, run[i].lng], [p.lat, p.lng])))
|
||||
return hotelLegsForDay(DAY2, DAYS, stays, segments)
|
||||
return hotelLegsForDay(DAY2, DAYS, stays, segmentsOf(runs))
|
||||
}
|
||||
|
||||
it('FE-MOB-PTLM-046: shows no drive from one hotel to the other, above or below the flight', () => {
|
||||
@@ -375,6 +400,245 @@ describe('planTimelineModel — hotel chips and legs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel: a day that lands and then drives to the hotel (#2501, #2502)', () => {
|
||||
// Day 1 checks into a Hamburg hotel at three. The flight lands at one, and the
|
||||
// traveller put the hotel on the day as a stop of their own, then the town hall.
|
||||
const HOTEL = { lat: 53.5465, lng: 9.9727 }
|
||||
const AMS = { lat: 52.3105, lng: 4.7683 }
|
||||
const HAM = { lat: 53.6304, lng: 9.9882 }
|
||||
const TOWN_HALL = { lat: 53.5503, lng: 9.9937 }
|
||||
const stay = accommodation({
|
||||
id: 30, place_id: 900, place_name: 'Hotel Hafen', place_lat: HOTEL.lat, place_lng: HOTEL.lng,
|
||||
start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00',
|
||||
})
|
||||
const flight = buildReservation({
|
||||
id: 7, type: 'flight', title: 'KL 1783', day_id: 1, end_day_id: 1, day_plan_position: -0.5,
|
||||
reservation_time: '2026-05-01T10:00', reservation_end_time: '2026-05-01T13:00',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'AMS', code: null, ...AMS, timezone: null, local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null },
|
||||
],
|
||||
})
|
||||
const hotelStop = buildAssignment({ id: 21, day_id: 1, order_index: 0, place: place(900, 'Hotel Hafen', HOTEL.lat, HOTEL.lng) })
|
||||
const townHall = buildAssignment({ id: 22, day_id: 1, order_index: 1, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
|
||||
|
||||
// A day as the phone timeline builds it: merged the way useMPlanTimeline merges,
|
||||
// segments the way useRouteCalculation routes the same day.
|
||||
const planOf = (dayId: number, dayAssignments: Assignment[], reservations: Reservation[], stays: Accommodation[]) => {
|
||||
const merged = getMergedItems({
|
||||
dayAssignments,
|
||||
dayNotes: [],
|
||||
dayTransports: getTransportForDay({ reservations, dayId, dayAssignmentIds: dayAssignments.map(a => a.id), days: DAYS }),
|
||||
dayId,
|
||||
})
|
||||
const segments = segmentsOf(buildDayRouteRuns(dayId, {
|
||||
days: DAYS, assignments: { [String(dayId)]: dayAssignments }, reservations, accommodations: stays, optimizeFromAccommodation: true,
|
||||
}))
|
||||
return {
|
||||
rows: buildPlanRows({ merged, reservations, routeSegments: segments, dayId }),
|
||||
legs: hotelLegsForDay(DAYS.find(d => d.id === dayId)!, DAYS, stays, segments),
|
||||
}
|
||||
}
|
||||
const dayOne = () => planOf(1, [hotelStop, townHall], [flight], [stay])
|
||||
const ends = (s: RouteSegment) => [s.from, s.to]
|
||||
|
||||
it('FE-MOB-PTLM-048: opens with the flight, shows the hop from the hotel stop once and ends at the hotel', () => {
|
||||
const { rows, legs } = dayOne()
|
||||
// No drive out of a hotel the traveller has not reached yet.
|
||||
expect(legs.top).toBeNull()
|
||||
const hops = rows.filter(r => r.kind === 'conn').map(r => r.kind === 'conn' && ends(r.seg))
|
||||
expect(hops.filter(h => JSON.stringify(h) === JSON.stringify([[HOTEL.lat, HOTEL.lng], [TOWN_HALL.lat, TOWN_HALL.lng]]))).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ kind: 'transport', key: 'tr-7' })
|
||||
// The day closes with the drive back to the hotel.
|
||||
expect(legs.bottom).toMatchObject({ name: 'Hotel Hafen', seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [HOTEL.lat, HOTEL.lng] } })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-049: a bookend is never read as the hop between two stops that share its ends', () => {
|
||||
// The tag decides, not the coordinates: two stops on the very spots a bookend
|
||||
// joins get no connector out of it.
|
||||
const bookend = seg([HOTEL.lat, HOTEL.lng], [TOWN_HALL.lat, TOWN_HALL.lng], { hotelBookend: 'morning' })
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(hotelStop), placeItem(townHall)], reservations: [], routeSegments: [bookend], dayId: 1,
|
||||
})
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'place'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-051: the drive from the arrival airport to the first stop sits under the flight (#2502)', () => {
|
||||
const { rows } = dayOne()
|
||||
expect(rows.map(r => r.kind)).toEqual(['transport', 'conn', 'place', 'conn', 'place'])
|
||||
const landed = rows[1]
|
||||
expect(landed).toMatchObject({ key: 'conn-tr-7', seg: { from: [HAM.lat, HAM.lng], to: [HOTEL.lat, HOTEL.lng] } })
|
||||
// It leaves no stop, so there is no stop whose outgoing mode it would change.
|
||||
expect(landed.kind === 'conn' && landed.assignmentId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-052: the drive to the departure airport sits under the stop it leaves from', () => {
|
||||
const HARBOUR = { lat: 53.5436, lng: 9.9661 }
|
||||
const homeFlight = buildReservation({
|
||||
id: 8, type: 'flight', title: 'KL 1790', day_id: 3, end_day_id: 3, day_plan_position: 5,
|
||||
reservation_time: '2026-05-03T18:00', reservation_end_time: '2026-05-03T19:10',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'AMS', code: null, ...AMS, timezone: null, local_date: null, local_time: null },
|
||||
],
|
||||
})
|
||||
const harbour = buildAssignment({ id: 23, day_id: 3, order_index: 0, place: place(902, 'Harbour', HARBOUR.lat, HARBOUR.lng) })
|
||||
const { rows } = planOf(3, [harbour], [homeFlight], [])
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'transport'])
|
||||
// Its mode is the stop's own, so the row keeps the stop's menu.
|
||||
expect(rows[1]).toMatchObject({ key: 'conn-pl-23', assignmentId: 23, seg: { from: [HARBOUR.lat, HARBOUR.lng], to: [HAM.lat, HAM.lng] } })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-053: two flights back to back get no drive between the airports (#1394)', () => {
|
||||
const endpoint = (role: 'from' | 'to', at: { lat: number; lng: number }) =>
|
||||
({ role, sequence: role === 'from' ? 0 : 1, name: role, code: null, ...at, timezone: null, local_date: null, local_time: null })
|
||||
const IST = { lat: 41.2753, lng: 28.7519 }
|
||||
const SAW = { lat: 40.8986, lng: 29.3092 }
|
||||
const inbound = buildReservation({ id: 9, type: 'flight', day_id: 2, endpoints: [endpoint('from', AMS), endpoint('to', IST)] })
|
||||
const onward = buildReservation({ id: 10, type: 'flight', day_id: 2, endpoints: [endpoint('from', SAW), endpoint('to', HAM)] })
|
||||
const rows = buildPlanRows({
|
||||
merged: [transportItem(inbound), transportItem(onward)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([IST.lat, IST.lng], [SAW.lat, SAW.lng])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.kind)).toEqual(['transport', 'transport'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-054: a located transit ride gets the walk to its first stop and from its last one', () => {
|
||||
const S1 = { lat: 48.11, lng: 16.11 }
|
||||
const S2 = { lat: 48.19, lng: 16.19 }
|
||||
const ride = buildReservation({
|
||||
id: 64, type: 'transit', day_id: 2,
|
||||
metadata: JSON.stringify({ transit: { legs: [{ mode: 'subway', line: 'U2' }], transfers: 0 } }),
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'S1', code: null, ...S1, timezone: null, local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'S2', code: null, ...S2, timezone: null, local_date: null, local_time: null },
|
||||
],
|
||||
})
|
||||
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
|
||||
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), transportItem(ride), placeItem(park)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([48.1, 16.1], [S1.lat, S1.lng]), seg([S2.lat, S2.lng], [48.2, 16.2])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.key)).toEqual(['pl-11', 'conn-pl-11', 'tr-64', 'conn-tr-64', 'pl-12'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-055: a transit ride saved on the stops themselves gets no connector that goes nowhere', () => {
|
||||
// The transit planner saves a journey between two of the day's places at their
|
||||
// own coordinates, so the route has a leg of no length on either side of it.
|
||||
const LANDING = { lat: 53.5457, lng: 9.9666 }
|
||||
const ride = buildReservation({
|
||||
id: 65, type: 'transit', title: 'Town Hall → Landungsbruecken', day_id: 2, end_day_id: 2, day_plan_position: 0.5,
|
||||
reservation_time: '2026-05-02T10:00', reservation_end_time: '2026-05-02T10:12',
|
||||
metadata: JSON.stringify({ transit: { legs: [{ mode: 'SUBWAY', line: 'U3' }], transfers: 0 } }),
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Town Hall', code: null, ...TOWN_HALL, timezone: null, local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'Landungsbruecken', code: null, ...LANDING, timezone: null, local_date: null, local_time: null },
|
||||
],
|
||||
})
|
||||
const hall = buildAssignment({ id: 24, day_id: 2, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
|
||||
const landing = buildAssignment({ id: 25, day_id: 2, order_index: 1, place: place(903, 'Landungsbruecken', LANDING.lat, LANDING.lng) })
|
||||
const drawn = segmentsOf(buildDayRouteRuns(2, {
|
||||
days: DAYS, assignments: { '2': [hall, landing] }, reservations: [ride], accommodations: [stay], optimizeFromAccommodation: true,
|
||||
}))
|
||||
expect(drawn.filter(s => JSON.stringify(s.from) === JSON.stringify(s.to))).toHaveLength(2)
|
||||
|
||||
const { rows, legs } = planOf(2, [hall, landing], [ride], [stay])
|
||||
expect(rows.map(r => r.key)).toEqual(['pl-24', 'tr-65', 'pl-25'])
|
||||
// The drives out of and back to the hotel are real and stay.
|
||||
expect(legs.top).toMatchObject({ seg: { from: [HOTEL.lat, HOTEL.lng], to: [TOWN_HALL.lat, TOWN_HALL.lng] } })
|
||||
expect(legs.bottom).toMatchObject({ seg: { from: [LANDING.lat, LANDING.lng], to: [HOTEL.lat, HOTEL.lng] } })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-056: two stops on the very same spot keep their connector', () => {
|
||||
// Only a booking's station on the stop is no leg; the stop's own menu stays.
|
||||
const inn = assignment(13, 0, place(103, 'Inn', 48.1, 16.1))
|
||||
const bar = assignment(14, 1, place(104, 'Bar in the inn', 48.1, 16.1))
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(inn), placeItem(bar)], reservations: [], routeSegments: [seg([48.1, 16.1], [48.1, 16.1])], dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.key)).toEqual(['pl-13', 'conn-pl-13', 'pl-14'])
|
||||
})
|
||||
|
||||
describe('a booking the route has no location for', () => {
|
||||
// The route rides straight past it, so the drive through it is drawn on the map,
|
||||
// and the desktop day plan shows that leg under the booking.
|
||||
const station = (role: 'from' | 'to', p: { lat: number; lng: number }, name: string) =>
|
||||
({ role, sequence: role === 'from' ? 0 : 1, name, code: null, ...p, timezone: null, local_date: null, local_time: null })
|
||||
const LANDING = { lat: 53.5457, lng: 9.9666 }
|
||||
|
||||
it('FE-MOB-PTLM-057: the drive to the departure airport past a taxi without its stops goes under the taxi (#2502)', () => {
|
||||
const hall = buildAssignment({ id: 26, day_id: 3, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
|
||||
const taxi = buildReservation({ id: 70, type: 'taxi', title: 'Taxi to the airport', day_id: 3, end_day_id: 3, day_plan_position: 0.5 })
|
||||
const homeFlight = buildReservation({
|
||||
id: 71, type: 'flight', title: 'KL 1790', day_id: 3, end_day_id: 3, day_plan_position: 0.8,
|
||||
reservation_time: '2026-05-03T18:00', reservation_end_time: '2026-05-03T19:10',
|
||||
endpoints: [station('from', HAM, 'HAM'), station('to', AMS, 'AMS')],
|
||||
})
|
||||
const { rows } = planOf(3, [hall], [taxi, homeFlight], [])
|
||||
expect(rows.map(r => r.key)).toEqual(['pl-26', 'tr-70', 'conn-tr-70', 'tr-71'])
|
||||
// Routed in the mode of the stop it left, so the menu edits that stop.
|
||||
expect(rows[2]).toMatchObject({ assignmentId: 26, seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [HAM.lat, HAM.lng] } })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-058: an event between two stops carries the drive between them, stations or not (#2502)', () => {
|
||||
const hall = buildAssignment({ id: 27, day_id: 2, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
|
||||
const landing = buildAssignment({ id: 28, day_id: 2, order_index: 1, place: place(903, 'Landungsbruecken', LANDING.lat, LANDING.lng) })
|
||||
const concert = buildReservation({ id: 72, type: 'event', title: 'Concert', day_id: 2, end_day_id: 2, day_plan_position: 0.5 })
|
||||
// A transit booking turned into an event in the booking form keeps its stations,
|
||||
// and the route still only rides transport bookings.
|
||||
const retyped = { ...concert, endpoints: [station('from', TOWN_HALL, 'Town Hall'), station('to', HAM, 'HAM')] }
|
||||
for (const booking of [concert, retyped]) {
|
||||
const { rows } = planOf(2, [hall, landing], [booking], [])
|
||||
expect(rows.map(r => r.key)).toEqual(['pl-27', 'tr-72', 'conn-tr-72', 'pl-28'])
|
||||
expect(rows[2]).toMatchObject({ assignmentId: 27, seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [LANDING.lat, LANDING.lng] } })
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-059: an arrival carries past it into the next drive, and two flights around it stay apart (#1394)', () => {
|
||||
const taxi = buildReservation({ id: 73, type: 'taxi', title: 'Taxi', day_id: 1, end_day_id: 1, day_plan_position: -0.2 })
|
||||
const { rows } = planOf(1, [hotelStop, townHall], [flight, taxi], [stay])
|
||||
expect(rows.map(r => r.key)).toEqual(['tr-7', 'tr-73', 'conn-tr-73', 'pl-21', 'conn-pl-21', 'pl-22'])
|
||||
expect(rows[2]).toMatchObject({ seg: { from: [HAM.lat, HAM.lng], to: [HOTEL.lat, HOTEL.lng] } })
|
||||
// It leaves the airport, not a stop, so there is no stop's mode to change.
|
||||
expect(rows[2].kind === 'conn' && rows[2].assignmentId).toBeUndefined()
|
||||
|
||||
const IST = { lat: 41.2753, lng: 28.7519 }
|
||||
const SAW = { lat: 40.8986, lng: 29.3092 }
|
||||
const inbound = buildReservation({ id: 9, type: 'flight', day_id: 2, endpoints: [station('from', AMS, 'AMS'), station('to', IST, 'IST')] })
|
||||
const shuttle = buildReservation({ id: 74, type: 'bus', day_id: 2 })
|
||||
const onward = buildReservation({ id: 10, type: 'flight', day_id: 2, endpoints: [station('from', SAW, 'SAW'), station('to', HAM, 'HAM')] })
|
||||
const transfer = buildPlanRows({
|
||||
merged: [transportItem(inbound), transportItem(shuttle), transportItem(onward)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([IST.lat, IST.lng], [SAW.lat, SAW.lng])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(transfer.map(r => r.kind)).toEqual(['transport', 'transport', 'transport'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-060: the drive goes under the last of several such bookings, and none of them opens the day', () => {
|
||||
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
|
||||
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
|
||||
const early = buildReservation({ id: 75, type: 'taxi', day_id: 2 })
|
||||
const taxi = buildReservation({ id: 76, type: 'taxi', day_id: 2 })
|
||||
const note = buildDayNote({ id: 42, day_id: 2 })
|
||||
const concert = buildReservation({ id: 77, type: 'event', day_id: 2 })
|
||||
const rows = buildPlanRows({
|
||||
merged: [transportItem(early), placeItem(museum), transportItem(taxi), noteItem(note), transportItem(concert), placeItem(park)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.key)).toEqual(['tr-75', 'pl-11', 'tr-76', 'note-42', 'tr-77', 'conn-tr-77', 'pl-12'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — cityPillsForDay', () => {
|
||||
const t = ((key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key) as unknown as TranslationFn
|
||||
|
||||
@@ -126,8 +126,9 @@ describe('useMPlanTimeline', () => {
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-004: exposes the hotel bookend legs and header chips', async () => {
|
||||
const out = seg([48, 16.05], [48, 16.1])
|
||||
const back = seg([48, 16.2], [48, 16.05])
|
||||
// The route calculation tags the two bookends it drew (#2501).
|
||||
const out: RouteSegment = { ...seg([48, 16.05], [48, 16.1]), hotelBookend: 'morning' }
|
||||
const back: RouteSegment = { ...seg([48, 16.2], [48, 16.05]), hotelBookend: 'evening' }
|
||||
routeCalc.segments = [out, back]
|
||||
const { result } = await renderTimeline(makePlanner({ tripAccommodations: [HOTEL] }))
|
||||
expect(result.current.hotelLegs.top?.seg).toBe(out)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* public/shell-guard.js (#2524): the classic boot script that recovers a shell
|
||||
* whose entry module cannot load. It is not a module, so it is run here from its
|
||||
* source, the way the browser runs it from index.html.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const SOURCE = readFileSync(resolve(process.cwd(), 'public/shell-guard.js'), 'utf8');
|
||||
const ENTRY = '/assets/index-BZcJ9-lk.js';
|
||||
const MARKER = 'trek:shell-reload';
|
||||
|
||||
type Listener = [string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined];
|
||||
let listeners: Listener[] = [];
|
||||
let scripts: HTMLElement[] = [];
|
||||
|
||||
/** Runs the guard and keeps its listeners, so every test starts with exactly one set. */
|
||||
function runGuard(): void {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
new Function(SOURCE)();
|
||||
listeners = add.mock.calls.map(call => call as unknown as Listener);
|
||||
add.mockRestore();
|
||||
}
|
||||
|
||||
function moduleScript(src: string, type = 'module'): HTMLScriptElement {
|
||||
const el = document.createElement('script');
|
||||
el.type = type;
|
||||
el.src = src;
|
||||
document.head.appendChild(el);
|
||||
scripts.push(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function fail(el: Element): void {
|
||||
el.dispatchEvent(new Event('error'));
|
||||
}
|
||||
|
||||
/** Lets the guard's promise chain run to its end. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function healthAnswer(ok: boolean, type: string) {
|
||||
return { ok, headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? type : null) } };
|
||||
}
|
||||
|
||||
interface Env {
|
||||
replace: ReturnType<typeof vi.fn>;
|
||||
fetch: ReturnType<typeof vi.fn>;
|
||||
unregister: ReturnType<typeof vi.fn>;
|
||||
deleted: string[];
|
||||
}
|
||||
|
||||
/** A page a service worker served, unless `controlled` is false. */
|
||||
function install(href = 'http://localhost/trips/4?tab=plan#day-2', controlled = true): Env {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, 'location', { value: { ...window.location, href, replace }, writable: true, configurable: true });
|
||||
const fetch = vi.fn(async () => healthAnswer(true, 'application/json; charset=utf-8'));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const unregister = vi.fn(async () => true);
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
value: { controller: controlled ? { scriptURL: 'http://localhost/sw.js' } : null, getRegistration: vi.fn(async () => ({ unregister })) },
|
||||
configurable: true,
|
||||
});
|
||||
const deleted: string[] = [];
|
||||
Object.defineProperty(globalThis, 'caches', {
|
||||
value: {
|
||||
keys: vi.fn(async () => ['workbox-precache-v2-http://localhost/', 'map-tiles', 'gl-map-offline', 'user-uploads']),
|
||||
delete: vi.fn(async (name: string) => { deleted.push(name); return true; }),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return { replace, fetch, unregister, deleted };
|
||||
}
|
||||
|
||||
const originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [type, fn, options] of listeners) document.removeEventListener(type, fn, options);
|
||||
listeners = [];
|
||||
for (const el of scripts) el.remove();
|
||||
scripts = [];
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
Reflect.deleteProperty(globalThis, 'caches');
|
||||
if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
|
||||
});
|
||||
|
||||
describe('shell-guard.js', () => {
|
||||
it('FE-SHELLGUARD-001: strips the fresh-reload parameter and keeps the rest of the URL', () => {
|
||||
window.history.replaceState({ usr: 1 }, '', '/trips/4?tab=plan&trek-reload=mfz3x1&x=2#day-2');
|
||||
runGuard();
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe('/trips/4?tab=plan&x=2#day-2');
|
||||
expect(window.history.state).toEqual({ usr: 1 });
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-002: leaves a URL without the parameter alone', () => {
|
||||
window.history.replaceState(null, '', '/dashboard?tab=trips');
|
||||
const replaceState = vi.spyOn(window.history, 'replaceState');
|
||||
runGuard();
|
||||
expect(replaceState).not.toHaveBeenCalled();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-003: an entry that cannot load drops the app shell and loads the page afresh', async () => {
|
||||
const env = install();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
|
||||
runGuard();
|
||||
fail(moduleScript(ENTRY));
|
||||
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(env.fetch).toHaveBeenCalledWith('/api/health', expect.objectContaining({ cache: 'no-store', redirect: 'manual' }));
|
||||
// Only the precache: offline map regions and cached uploads were downloaded on purpose.
|
||||
expect(env.deleted).toEqual(['workbox-precache-v2-http://localhost/']);
|
||||
expect(env.unregister).toHaveBeenCalledTimes(1);
|
||||
const target = new URL(env.replace.mock.calls[0][0] as string);
|
||||
expect(target.pathname + target.hash).toBe('/trips/4#day-2');
|
||||
expect(target.searchParams.get('tab')).toBe('plan');
|
||||
expect(target.searchParams.get('trek-reload')).toBe((1790358416100).toString(36));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['offline', () => Promise.reject(new TypeError('Failed to fetch'))],
|
||||
['a proxy login page', async () => healthAnswer(true, 'text/html')],
|
||||
['a server error', async () => healthAnswer(false, 'application/json')],
|
||||
])('FE-SHELLGUARD-004: keeps the shell when the server does not answer (%s)', async (_case, answer) => {
|
||||
const env = install();
|
||||
env.fetch.mockImplementation(answer);
|
||||
runGuard();
|
||||
fail(moduleScript(ENTRY));
|
||||
await settle();
|
||||
// Offline, the precache is the only way the app starts at all.
|
||||
expect(env.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(env.deleted).toEqual([]);
|
||||
expect(env.unregister).not.toHaveBeenCalled();
|
||||
expect(env.replace).not.toHaveBeenCalled();
|
||||
// Nothing was tried, so a later load may still recover.
|
||||
expect(sessionStorage.getItem(MARKER)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-005: recovers once per session, so a broken build cannot loop', async () => {
|
||||
const env = install();
|
||||
runGuard();
|
||||
fail(moduleScript(ENTRY));
|
||||
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The reloaded page fails the same way: the marker is still set.
|
||||
fail(moduleScript(ENTRY));
|
||||
await settle();
|
||||
expect(env.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(env.replace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-006: an entry that loaded clears the marker, so a later update may recover too', async () => {
|
||||
const env = install();
|
||||
sessionStorage.setItem(MARKER, '1790358416100');
|
||||
runGuard();
|
||||
moduleScript(ENTRY).dispatchEvent(new Event('load'));
|
||||
expect(sessionStorage.getItem(MARKER)).toBeNull();
|
||||
|
||||
fail(moduleScript(ENTRY));
|
||||
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-007: does nothing without session storage, where it could not stop a loop', async () => {
|
||||
const env = install();
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('storage blocked');
|
||||
});
|
||||
runGuard();
|
||||
fail(moduleScript(ENTRY));
|
||||
await settle();
|
||||
expect(env.fetch).not.toHaveBeenCalled();
|
||||
expect(env.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-008: still reloads when dropping the shell fails', async () => {
|
||||
const env = install();
|
||||
env.unregister.mockRejectedValue(new Error('InvalidStateError'));
|
||||
runGuard();
|
||||
fail(moduleScript(ENTRY));
|
||||
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-011: a page that came from the network is reloaded afresh and its worker kept', async () => {
|
||||
// Nothing the worker keeps answered this load, so throwing it away would only
|
||||
// cost the offline shell.
|
||||
const env = install('http://localhost/dashboard', false);
|
||||
runGuard();
|
||||
fail(moduleScript(ENTRY));
|
||||
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
|
||||
expect(env.deleted).toEqual([]);
|
||||
expect(env.unregister).not.toHaveBeenCalled();
|
||||
expect(new URL(env.replace.mock.calls[0][0] as string).searchParams.has('trek-reload')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-009: works where there is no service worker and no Cache Storage', async () => {
|
||||
const env = install();
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
Reflect.deleteProperty(globalThis, 'caches');
|
||||
runGuard();
|
||||
fail(moduleScript(ENTRY));
|
||||
// A proxy that kept a stale index.html is bypassed by the fresh URL alone.
|
||||
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-SHELLGUARD-010: ignores every other failure', async () => {
|
||||
const env = install();
|
||||
runGuard();
|
||||
// Vite's dev entry, a classic script, a lazy chunk's stylesheet, an image.
|
||||
fail(moduleScript('/src/main.tsx'));
|
||||
fail(moduleScript('/registerSW.js', 'text/javascript'));
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/assets/DashboardPage-v4ODOTOr.css';
|
||||
document.head.appendChild(link);
|
||||
scripts.push(link);
|
||||
fail(link);
|
||||
const img = document.createElement('img');
|
||||
img.src = '/uploads/covers/missing.jpg';
|
||||
document.body.appendChild(img);
|
||||
scripts.push(img);
|
||||
fail(img);
|
||||
// A runtime error of the app is an ErrorEvent on window, not a failed script.
|
||||
window.dispatchEvent(new Event('error'));
|
||||
await settle();
|
||||
expect(env.fetch).not.toHaveBeenCalled();
|
||||
expect(env.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,10 @@ const db = new Database(dbPath);
|
||||
// journal mode below waits for a busy server instead of failing on the spot.
|
||||
// Five seconds, same as src/db/database.ts.
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
// Temp files in memory, like every connection src/db/connection.ts opens: a
|
||||
// container with a read-only root and no tmpfs on /tmp has nowhere to put them
|
||||
// (#2518).
|
||||
db.exec('PRAGMA temp_store = MEMORY');
|
||||
db.exec(`PRAGMA journal_mode = ${journalMode}`);
|
||||
db.exec(`PRAGMA synchronous = ${synchronous}`);
|
||||
|
||||
|
||||
@@ -184,6 +184,10 @@ async function main() {
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('busy_timeout = 5000');
|
||||
// Temp files in memory, like every connection src/db/connection.ts opens: a
|
||||
// container with a read-only root and no tmpfs on /tmp has nowhere to put
|
||||
// them, and the rotation below runs as one long transaction (#2518).
|
||||
db.pragma('temp_store = MEMORY');
|
||||
|
||||
// The rollback copy has to be a consistent snapshot, not a plain file copy:
|
||||
// this script is documented as running against a live server, so in WAL mode
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Opens a SQLite connection. Every handle the server opens comes from here (the
|
||||
* main database, each plugin's own database, a backup being checked before a
|
||||
* restore), so what has to hold for all of them is set in one place.
|
||||
*
|
||||
* Temporary storage stays in memory. Statement journals, sort runs and transient
|
||||
* indices move to a temp file once they outgrow a small in-memory buffer, and
|
||||
* SQLite looks for a directory to hold that file in SQLITE_TMPDIR, TMPDIR,
|
||||
* /var/tmp, /usr/tmp, /tmp and the working directory. A container with a
|
||||
* read-only root filesystem and no tmpfs on /tmp has none of them writable, so
|
||||
* the first statement that needs a temp file fails with SQLITE_IOERR_GETTEMPPATH.
|
||||
* Deleting a trip is where that showed: the cascade keeps a statement journal
|
||||
* larger than the 64 KiB SQLite holds before it spills to disk (#2518).
|
||||
*
|
||||
* The data directory is the only place TREK can count on being able to write,
|
||||
* and SQLite has no supported per-connection way to put temp files there, so
|
||||
* they stay in RAM. The shipped compose file already had them there, its /tmp
|
||||
* being a tmpfs. VACUUM INTO, the only VACUUM TREK runs, writes straight into
|
||||
* its target file and builds no temporary database.
|
||||
*/
|
||||
export function openDatabase(filename: string, options?: Database.Options): Database.Database {
|
||||
const db = new Database(filename, options);
|
||||
db.pragma('temp_store = MEMORY');
|
||||
return db;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import type Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { readEnv } from '../app-config';
|
||||
import { openDatabase } from './connection';
|
||||
import { applyDurabilityPragmas } from './durability';
|
||||
import { createTables } from './schema';
|
||||
import { runMigrations } from './migrations';
|
||||
@@ -39,7 +40,7 @@ function initDb(): void {
|
||||
_db = null;
|
||||
}
|
||||
|
||||
_db = new Database(dbPath);
|
||||
_db = openDatabase(dbPath);
|
||||
// Ahead of the journal switch now: changing journal_mode needs an exclusive
|
||||
// lock, which a sibling process (reset-admin, the rotation script) may hold.
|
||||
_db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
@@ -5257,6 +5257,34 @@ function runMigrations(db: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* A place that moves takes its Atlas country with it (#2527).
|
||||
*
|
||||
* place_regions caches the country and region Atlas resolved from a
|
||||
* place's coordinates and address, and nothing re-derives a row that is
|
||||
* already there. Correcting a place's location left the old row in charge,
|
||||
* so Atlas, the dashboard stats and the journey stats kept counting the
|
||||
* country the place had just left.
|
||||
*
|
||||
* A trigger rather than a delete in the places code, because the row is
|
||||
* derived from the place and every writer of lat, lng or address has to
|
||||
* let go of it: the place editor, update_place, the plugin RPC, the import
|
||||
* enrichment and its address backfill, and whatever comes next. The WHEN
|
||||
* clause matters because every place edit writes lat, lng and address
|
||||
* back whether they changed or not, and renaming a place must not throw
|
||||
* away a good row. The next Atlas load resolves the place where it is now.
|
||||
*/
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS trg_place_regions_follow_place
|
||||
AFTER UPDATE OF lat, lng, address ON places
|
||||
WHEN OLD.lat IS NOT NEW.lat OR OLD.lng IS NOT NEW.lng OR OLD.address IS NOT NEW.address
|
||||
BEGIN
|
||||
DELETE FROM place_regions WHERE place_id = NEW.id;
|
||||
END
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -781,7 +781,7 @@ async function resolveCountryCode(place: Place): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveCountryCodeSync(place: Place): string | null {
|
||||
export function resolveCountryCodeSync(place: Pick<Place, 'lat' | 'lng' | 'address'>): string | null {
|
||||
const hasCoords = !!(place.lat && place.lng);
|
||||
if (hasCoords) {
|
||||
const fromCoords = getCountryFromCoords(place.lat!, place.lng!);
|
||||
@@ -937,10 +937,13 @@ function buildRegionInfo(address: Record<string, string>, preferFinest: boolean)
|
||||
};
|
||||
}
|
||||
|
||||
export async function reverseGeocodeRegion(lat: number, lng: number, placeAddress?: string | null): Promise<RegionInfo | null> {
|
||||
const key = cacheKeyFor(lat, lng, 'region');
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
|
||||
/**
|
||||
* The offline half of reverseGeocodeRegion: the bundled admin1 polygons, then the
|
||||
* box-gated address fallback. Null when the bundle has no answer for the point, which
|
||||
* is where reverseGeocodeRegion goes on to ask Nominatim. Deterministic and uncached,
|
||||
* so it is also what the one time place_regions repair re-derives rows with (#2527).
|
||||
*/
|
||||
export async function resolveRegionFromBundle(lat: number, lng: number, placeAddress?: string | null): Promise<RegionInfo | null> {
|
||||
// Prefer resolving directly against the bundled polygons: offline, deterministic, and —
|
||||
// unlike Nominatim's address levels — guaranteed to match a feature the client can
|
||||
// actually highlight. Falls through to reverse geocoding when the country has no admin1
|
||||
@@ -948,10 +951,7 @@ export async function reverseGeocodeRegion(lat: number, lng: number, placeAddres
|
||||
const coordCountry = getCountryFromCoords(lat, lng);
|
||||
if (coordCountry) {
|
||||
const fromBundle = await getRegionFromCoords(coordCountry, lat, lng);
|
||||
if (fromBundle) {
|
||||
regionCache.set(key, fromBundle);
|
||||
return fromBundle;
|
||||
}
|
||||
if (fromBundle) return fromBundle;
|
||||
}
|
||||
// The coordinate-only lookup found no matching region — either no country polygon contains
|
||||
// the point, or a simplified admin0 border put it in the WRONG country (a place on the
|
||||
@@ -966,11 +966,22 @@ export async function reverseGeocodeRegion(lat: number, lng: number, placeAddres
|
||||
// trusting a region match in it.
|
||||
const addressCountry = getCountryFromAddress(placeAddress ?? null);
|
||||
if (addressCountry && addressCountry !== coordCountry && isPointInCountryBox(addressCountry, lat, lng)) {
|
||||
const fromAddress = await getRegionFromCoords(addressCountry, lat, lng);
|
||||
if (fromAddress) {
|
||||
regionCache.set(key, fromAddress);
|
||||
return fromAddress;
|
||||
return getRegionFromCoords(addressCountry, lat, lng);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function reverseGeocodeRegion(lat: number, lng: number, placeAddress?: string | null): Promise<RegionInfo | null> {
|
||||
// The address takes part in the answer only through the country it names, so that
|
||||
// country is part of the key. Keyed by coordinates alone, a place whose address was
|
||||
// corrected on a border point got the old answer back from memory (#2527).
|
||||
const key = `${cacheKeyFor(lat, lng, 'region')}|${getCountryFromAddress(placeAddress ?? null) ?? ''}`;
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
|
||||
const fromBundle = await resolveRegionFromBundle(lat, lng, placeAddress);
|
||||
if (fromBundle) {
|
||||
regionCache.set(key, fromBundle);
|
||||
return fromBundle;
|
||||
}
|
||||
|
||||
// Only reached when the bundle's own polygons for this country don't cover the point at
|
||||
|
||||
@@ -11,6 +11,8 @@ import { PublicStatsController } from './public-stats.controller';
|
||||
import { ApiTokenGuard } from '../public-api/api-token.guard';
|
||||
import { TokensModule } from '../tokens/tokens.module';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { PlaceRegionsRepairJob } from './place-regions-repair.job';
|
||||
|
||||
/**
|
||||
* Atlas addon domain (L7 leaf module). Registered in AppModule. Exports
|
||||
@@ -29,11 +31,13 @@ import { RateLimitModule } from '../common/rate-limit.module';
|
||||
* than pulled in with PublicApiModule: @UseGuards instantiates a guard in the
|
||||
* declaring controller's module, so exporting it from over there would still leave
|
||||
* its TokenService unresolvable here. TokensModule is a leaf, so the edge is free.
|
||||
*
|
||||
* PlaceRegionsRepairJob puts right, once, the place_regions rows cached before #2527.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuthModule, PluginGuardsModule, AddonsModule, TokensModule, RateLimitModule],
|
||||
imports: [AuthModule, PluginGuardsModule, AddonsModule, TokensModule, RateLimitModule, SchedulingModule],
|
||||
controllers: [AtlasController, TravelStatsController, PublicStatsController],
|
||||
providers: [AtlasService, AtlasMcp, AtlasRpc, ApiTokenGuard],
|
||||
providers: [AtlasService, AtlasMcp, AtlasRpc, ApiTokenGuard, PlaceRegionsRepairJob],
|
||||
exports: [AtlasService],
|
||||
})
|
||||
export class AtlasModule {}
|
||||
|
||||
@@ -4,14 +4,17 @@ import type { AtlasLocateResponse } from '@trek/shared';
|
||||
import { Trip, Place } from '../../types';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import {
|
||||
getCountryFromAddress,
|
||||
getCountryFromCoords,
|
||||
getCountryGeoGz,
|
||||
getRegionFromCoords,
|
||||
getRegionGeo,
|
||||
geocodingInFlight,
|
||||
resolveCountryCodeSync,
|
||||
resolveRegionFromBundle,
|
||||
reverseGeocodeRegion,
|
||||
} from './atlas-geo';
|
||||
import type { RegionInfo } from './atlas-geo';
|
||||
import { KNOWN_COUNTRIES } from './known-countries';
|
||||
import { cityFromAddress } from './city-from-address';
|
||||
import { transferEndpointIds } from './transfer-endpoints';
|
||||
@@ -19,6 +22,15 @@ import type { FlightEndpointRow } from './transfer-endpoints';
|
||||
import { countryVisitDates } from './visit-dates';
|
||||
import { haversineKm } from '../common/geo';
|
||||
|
||||
/** The part of a place that its cached region is derived from. */
|
||||
type LocatedPlace = Pick<Place, 'id' | 'lat' | 'lng' | 'address'>;
|
||||
|
||||
/** A place_regions row next to the location of its place. */
|
||||
type CachedRegionRow = LocatedPlace & { country_code: string; region_code: string };
|
||||
|
||||
/** How many cached rows the #2527 repair checks before it lets other work run. */
|
||||
const REPAIR_YIELD_EVERY = 200;
|
||||
|
||||
/**
|
||||
* A reservation endpoint plus the two booking columns that decide whether it may
|
||||
* take part in layover pairing at all (see transfer-endpoints.ts).
|
||||
@@ -175,30 +187,129 @@ export class AtlasService {
|
||||
}
|
||||
}
|
||||
|
||||
if (uncachedForGeocode.length > 0) {
|
||||
const insertStmt = this.db.prepare(
|
||||
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.add(p.id);
|
||||
this.cacheRegionsInBackground(uncachedForGeocode);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each place's region in the background and cache it in place_regions.
|
||||
*/
|
||||
private cacheRegionsInBackground(places: Place[]): void {
|
||||
if (places.length === 0) return;
|
||||
for (const p of places) geocodingInFlight.add(p.id);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const place of uncachedForGeocode) {
|
||||
for (const place of places) {
|
||||
try {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!, place.address);
|
||||
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
if (info) this.cacheRegionWhileUnmoved(place, info);
|
||||
} catch {
|
||||
/* continue */
|
||||
// individual failure, continue with the remaining places
|
||||
} finally {
|
||||
geocodingInFlight.delete(place.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.delete(p.id);
|
||||
for (const p of places) geocodingInFlight.delete(p.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return out;
|
||||
/**
|
||||
* Cache a resolved region, but only while the place still sits where it was resolved
|
||||
* from. A place moved while its lookup was running has already had its row dropped by
|
||||
* the place_regions trigger (#2527), and writing the old answer back would pin it to
|
||||
* the country it just left. A place deleted in the meantime is skipped the same way.
|
||||
*/
|
||||
private cacheRegionWhileUnmoved(place: LocatedPlace, info: RegionInfo): boolean {
|
||||
const written = this.db.run(
|
||||
`INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name)
|
||||
SELECT id, ?, ?, ? FROM places WHERE id = ? AND lat = ? AND lng = ? AND address IS ?`,
|
||||
info.country_code,
|
||||
info.region_code,
|
||||
info.region_name,
|
||||
place.id,
|
||||
place.lat,
|
||||
place.lng,
|
||||
place.address ?? null,
|
||||
);
|
||||
return written.changes > 0;
|
||||
}
|
||||
|
||||
// ── One time repair of the rows cached before #2527 ───────────────────────
|
||||
|
||||
/**
|
||||
* Before #2527 nothing dropped a place_regions row when its place moved, so an
|
||||
* install that upgrades still holds the country every corrected place left. This
|
||||
* re-derives each row with the bundled resolver alone, never Nominatim, and puts
|
||||
* right the ones that no longer match where their place is now.
|
||||
*
|
||||
* Where the bundle answers for the place's current location with another country
|
||||
* or region, the row gets that answer, which is what a fresh lookup would cache. A
|
||||
* good row matches it, the address fallback case included.
|
||||
*
|
||||
* Where the bundle has no answer (a coastal point outside the simplified polygons,
|
||||
* a country the bundle has no regions for), the row came from Nominatim and only
|
||||
* Nominatim could judge it, so it stays, unless the coordinates put the place in
|
||||
* another country and the address does not name the cached one either. Then it is
|
||||
* dropped and the next Atlas load looks the place up again.
|
||||
*
|
||||
* A place without coordinates keeps no row, the same as the trigger does.
|
||||
*
|
||||
* Every write only lands while the place still holds the location it was read with,
|
||||
* so a place edited while this runs keeps what the trigger and the next lookup give it.
|
||||
*/
|
||||
async repairStaleRegionCache(): Promise<{ replaced: number; dropped: number }> {
|
||||
const rows = this.db.all<CachedRegionRow>(`
|
||||
SELECT pr.place_id AS id, pr.country_code, pr.region_code, p.lat, p.lng, p.address
|
||||
FROM place_regions pr
|
||||
JOIN places p ON p.id = pr.place_id
|
||||
ORDER BY pr.place_id
|
||||
`);
|
||||
let replaced = 0;
|
||||
let dropped = 0;
|
||||
for (const [i, row] of rows.entries()) {
|
||||
// A big install holds many rows, so requests get a turn every so often.
|
||||
if (i > 0 && i % REPAIR_YIELD_EVERY === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
const fix = await this.staleRegionFix(row);
|
||||
if (fix === 'drop') {
|
||||
if (this.dropRegionWhileUnmoved(row)) dropped++;
|
||||
} else if (fix && this.cacheRegionWhileUnmoved(row, fix)) {
|
||||
replaced++;
|
||||
}
|
||||
}
|
||||
return { replaced, dropped };
|
||||
}
|
||||
|
||||
/** What a cached row needs: a new region, 'drop', or null when it is right. */
|
||||
private async staleRegionFix(row: CachedRegionRow): Promise<RegionInfo | 'drop' | null> {
|
||||
if (row.lat == null || row.lng == null) return 'drop';
|
||||
const cachedCountry = row.country_code.toUpperCase();
|
||||
const fresh = await resolveRegionFromBundle(row.lat, row.lng, row.address);
|
||||
if (fresh) {
|
||||
const same = fresh.country_code.toUpperCase() === cachedCountry && fresh.region_code === row.region_code;
|
||||
return same ? null : fresh;
|
||||
}
|
||||
const countryNow = resolveCountryCodeSync(row);
|
||||
const contradicted = !!countryNow && countryNow !== cachedCountry && getCountryFromAddress(row.address) !== cachedCountry;
|
||||
return contradicted ? 'drop' : null;
|
||||
}
|
||||
|
||||
private dropRegionWhileUnmoved(row: CachedRegionRow): boolean {
|
||||
const removed = this.db.run(
|
||||
`DELETE FROM place_regions
|
||||
WHERE place_id = ? AND country_code = ? AND region_code = ?
|
||||
AND EXISTS (SELECT 1 FROM places WHERE id = ? AND lat IS ? AND lng IS ? AND address IS ?)`,
|
||||
row.id,
|
||||
row.country_code,
|
||||
row.region_code,
|
||||
row.id,
|
||||
row.lat ?? null,
|
||||
row.lng ?? null,
|
||||
row.address ?? null,
|
||||
);
|
||||
return removed.changes > 0;
|
||||
}
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
@@ -731,29 +842,9 @@ export class AtlasService {
|
||||
const cachedMap = new Map(cached.map((c) => [c.place_id, c]));
|
||||
|
||||
// Kick off background geocoding for uncached places; return cached data immediately.
|
||||
const uncached = places.filter((p) => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id));
|
||||
if (uncached.length > 0) {
|
||||
const insertStmt = this.db.prepare(
|
||||
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)',
|
||||
this.cacheRegionsInBackground(
|
||||
places.filter((p) => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id)),
|
||||
);
|
||||
for (const p of uncached) geocodingInFlight.add(p.id);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const place of uncached) {
|
||||
try {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!, place.address);
|
||||
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
} catch {
|
||||
// individual failure — continue with remaining places
|
||||
} finally {
|
||||
geocodingInFlight.delete(place.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const p of uncached) geocodingInFlight.delete(p.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Group by country → regions with place counts
|
||||
const regionMap: Record<
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { logInfo, logError } from '../audit/audit-log.logger';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { AtlasService } from './atlas.service';
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
/** The app_settings row that says the pass below has been through every cached region. */
|
||||
export const PLACE_REGIONS_REPAIR_DONE_KEY = 'place_regions_repair_2527';
|
||||
|
||||
/**
|
||||
* One pass over place_regions on the first start after #2527 (see
|
||||
* AtlasService.repairStaleRegionCache).
|
||||
*
|
||||
* Not a cron: from this version on the trigger on places drops a row as soon as its
|
||||
* place moves, so once the rows cached before it are put right there is nothing left
|
||||
* to repair. app_settings remembers that the pass ran. A pass cut short by a restart
|
||||
* or an error simply runs again on the next start; a second run changes nothing the
|
||||
* first one wrote.
|
||||
*
|
||||
* In the background, like the other boot sweeps: the server does not wait for it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlaceRegionsRepairJob implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly atlas: AtlasService,
|
||||
private readonly registrar: CronRegistrarService,
|
||||
private readonly db: DatabaseService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
void this.runOnce();
|
||||
}
|
||||
|
||||
async runOnce(): Promise<void> {
|
||||
try {
|
||||
if (this.db.get('SELECT 1 FROM app_settings WHERE key = ?', PLACE_REGIONS_REPAIR_DONE_KEY)) return;
|
||||
const { replaced, dropped } = await this.atlas.repairStaleRegionCache();
|
||||
this.db.run(
|
||||
'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
|
||||
PLACE_REGIONS_REPAIR_DONE_KEY,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
if (replaced + dropped > 0) {
|
||||
logInfo(
|
||||
`Atlas region repair: ${replaced} place region(s) re-derived where the place is now, ${dropped} left for the next Atlas load`,
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logError(`Atlas region repair: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import path from 'path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { readEnv } from '../../app-config';
|
||||
import fs from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { openDatabase } from '../../db/connection';
|
||||
import { db, closeDb, reinitialize } from '../../db/database';
|
||||
import { VALID_INTERVALS } from './auto-backup.settings';
|
||||
import { invalidatePermissionsCache } from '../permissions/permissions-cache';
|
||||
@@ -436,7 +437,7 @@ export async function restoreFromZip(storage: StorageService, zipPath: string):
|
||||
|
||||
let uploadedDb: InstanceType<typeof Database> | null = null;
|
||||
try {
|
||||
uploadedDb = new Database(extractedDb, { readonly: true });
|
||||
uploadedDb = openDatabase(extractedDb, { readonly: true });
|
||||
|
||||
const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
|
||||
if (integrityResult.integrity_check !== 'ok') {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
BudgetReorderCategoriesDto,
|
||||
BudgetCreateSettlementDto,
|
||||
BudgetUpdateSettlementDto,
|
||||
BudgetFreezeRatesDto,
|
||||
BudgetSettlementQueryDto,
|
||||
} from './budget.dto';
|
||||
|
||||
/**
|
||||
@@ -37,8 +40,9 @@ import {
|
||||
* every handler verifies trip access (404); mutations check 'budget_edit' (403);
|
||||
* create is 201, the rest 200; bespoke 404 bodies reproduced; mutations
|
||||
* broadcast over WebSocket with the forwarded X-Socket-Id. Static sub-routes
|
||||
* (summary, settlement, reorder/*) are declared before /:id so they win over the
|
||||
* param. Updating total_price on a reservation-linked item syncs the price back.
|
||||
* (summary, settlement, freeze-rates, reorder/*) are declared before /:id so they
|
||||
* win over the param. Updating total_price on a reservation-linked item syncs the
|
||||
* price back.
|
||||
*
|
||||
* Bodies are validated against the @trek/shared budget schemas via budget.dto.ts
|
||||
* (global ZodValidationPipe). This replaced the legacy bespoke 400s ('Name is
|
||||
@@ -62,18 +66,20 @@ export class BudgetController {
|
||||
}
|
||||
|
||||
@Get('summary/per-person')
|
||||
perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { summary: this.budget.perPersonSummary(tripId) };
|
||||
async perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { summary: await this.budget.perPersonSummary(tripId) };
|
||||
}
|
||||
|
||||
// `base_rate` is the caller's own quote for the display currency (units per 1 trip
|
||||
// currency), used only when the server has none; a malformed one is a 400.
|
||||
@Get('settlement')
|
||||
settlement(
|
||||
@CurrentUser() user: User,
|
||||
@Trip() trip: TripAccess,
|
||||
@Param('tripId') tripId: string,
|
||||
@Query('base') base?: string,
|
||||
@Query() query: BudgetSettlementQueryDto,
|
||||
) {
|
||||
return this.budget.settlement(tripId, base, trip.currency || 'EUR');
|
||||
return this.budget.settlement(tripId, query.base, trip.currency || 'EUR', query.base_rate);
|
||||
}
|
||||
|
||||
@Get('settlements')
|
||||
@@ -91,7 +97,10 @@ export class BudgetController {
|
||||
) {
|
||||
const settlement = await this.budget.createSettlement(
|
||||
tripId,
|
||||
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency, settled_at: body.settled_at },
|
||||
{
|
||||
from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount,
|
||||
currency: body.currency, settled_at: body.settled_at, fallback_fx: body.fallback_fx,
|
||||
},
|
||||
user.id,
|
||||
);
|
||||
// A party who is not on this trip gets the same answer as a settlement that
|
||||
@@ -118,6 +127,7 @@ export class BudgetController {
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
settled_at: body.settled_at,
|
||||
fallback_fx: body.fallback_fx,
|
||||
});
|
||||
if (!settlement) {
|
||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||
@@ -141,6 +151,33 @@ export class BudgetController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze a rate onto every foreign row that has none frozen yet, the ones the
|
||||
* settlement lists as `unconverted` and the ones it still converts at today's rate:
|
||||
* the server's own rate first, `fallback_fx` (the browser's) for a currency it cannot
|
||||
* quote. A frozen row, a row in the trip currency and a row without a currency are
|
||||
* never touched. The same service call as the freeze_budget_rates MCP tool, which
|
||||
* lends no rates. 409 when the trip currency changed while the rates were fetched;
|
||||
* nothing is written then.
|
||||
*/
|
||||
@RequirePermission('budget_edit')
|
||||
@Post('freeze-rates')
|
||||
@HttpCode(200)
|
||||
async freezeRates(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: BudgetFreezeRatesDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const healed = await this.budget.freezeMissingRates(tripId, body.fallback_fx);
|
||||
if (!healed) {
|
||||
throw new HttpException({ error: 'The trip currency changed. Reload and try again.' }, 409);
|
||||
}
|
||||
for (const item of healed.items) this.budget.broadcast(tripId, 'budget:updated', { item }, socketId);
|
||||
for (const settlement of healed.settlements) this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId);
|
||||
return healed;
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Post()
|
||||
async create(
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
budgetReorderCategoriesRequestSchema,
|
||||
budgetCreateSettlementRequestSchema,
|
||||
budgetUpdateSettlementRequestSchema,
|
||||
budgetFreezeRatesRequestSchema,
|
||||
budgetSettlementQuerySchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -26,3 +28,6 @@ export class BudgetReorderItemsDto extends createZodDto(budgetReorderItemsReques
|
||||
export class BudgetReorderCategoriesDto extends createZodDto(budgetReorderCategoriesRequestSchema) {}
|
||||
export class BudgetCreateSettlementDto extends createZodDto(budgetCreateSettlementRequestSchema) {}
|
||||
export class BudgetUpdateSettlementDto extends createZodDto(budgetUpdateSettlementRequestSchema) {}
|
||||
export class BudgetFreezeRatesDto extends createZodDto(budgetFreezeRatesRequestSchema) {}
|
||||
/** GET …/settlement query (`base`, and `base_rate` coerced from the query string). */
|
||||
export class BudgetSettlementQueryDto extends createZodDto(budgetSettlementQuerySchema) {}
|
||||
|
||||
@@ -72,7 +72,8 @@ function formatCents(cents: number): string {
|
||||
* through the freeze-then-write composites so REST and MCP can't diverge on
|
||||
* the #1445 FX freeze, and the settlement resource resolves the trip currency
|
||||
* and live rates like get_settlement_summary instead of silently defaulting
|
||||
* to an unconverted EUR base.
|
||||
* to an unconverted EUR base. freeze_budget_rates has no legacy counterpart; it
|
||||
* is the REST freeze-rates route without the rate table a browser may lend.
|
||||
*/
|
||||
@McpController()
|
||||
export class BudgetMcp {
|
||||
@@ -177,7 +178,7 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_budget_item',
|
||||
description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split); for an uneven split, give `members` the amount each participant owes instead. Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid (resolve user IDs with list_trip_members) rather than guessing.',
|
||||
description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split); for an uneven split, give `members` the amount each participant owes instead. Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid (resolve user IDs with list_trip_members) rather than guessing. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -249,7 +250,7 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_budget_item',
|
||||
description: 'Update an existing budget/expense item in a trip. You can also re-split it (equally via member_ids, unevenly via members), change the currency it was entered in, move it to another date, and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members.',
|
||||
description: 'Update an existing budget/expense item in a trip. You can also re-split it (equally via member_ids, unevenly via members), change the currency it was entered in, move it to another date, and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
@@ -390,7 +391,7 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'get_settlement_summary',
|
||||
description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
|
||||
description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in `summary.currency`: the `base` asked for when the server can quote it, otherwise the trip's base currency. An expense or payment in a foreign currency with no exchange rate is left out of every figure and listed under `summary.unconverted` (item_ids, settlement_ids, currencies); freeze_budget_rates pins today's rate on those. Call this before recording a settlement so you know who should pay whom and how much.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
|
||||
@@ -402,10 +403,9 @@ export class BudgetMcp {
|
||||
async getSettlementSummary({ tripId, base }: { tripId: number; base?: string }, ctx: McpContext) {
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCurrency = trip?.currency || 'EUR';
|
||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
const summary = this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
// The same call the REST route makes, so both convert with the trip currency's own
|
||||
// quote and an amount entered in `base` reads back to the cent (#2525).
|
||||
const summary = await this.budget.settlement(tripId, base, trip?.currency || 'EUR');
|
||||
return ok({ summary });
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_settlement',
|
||||
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much.",
|
||||
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
||||
@@ -456,7 +456,7 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_settlement',
|
||||
description: 'Update a recorded settle-up payment (who paid, who received, the amount and the currency it was made in). Every field is a full replace, so restate the ones that stay the same.',
|
||||
description: 'Update a recorded settle-up payment (who paid, who received, the amount and the currency it was made in). Every field is a full replace, so restate the ones that stay the same. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
@@ -506,6 +506,31 @@ export class BudgetMcp {
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
// --- EXCHANGE RATES ---
|
||||
|
||||
@Tool({
|
||||
name: 'freeze_budget_rates',
|
||||
description: "Pin today's server exchange rate on every expense and settle-up payment of a trip that is in a foreign currency and has no rate frozen yet. That takes in the rows get_settlement_summary lists under `unconverted`, which count in no balance or total until then, and also foreign rows it still converts at today's live rate. Rows that already carry a frozen rate, rows in the trip currency and rows without a currency are never touched, and no rate is taken from the caller. Returns the rows it froze (`items`, `settlements`) and the currencies the server could not quote (`unresolved`), whose rows stay unfrozen.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async freezeBudgetRates({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
// The same service call as POST …/budget/freeze-rates, without a lent rate table:
|
||||
// a rate from an MCP caller is never frozen, like everywhere else on this surface.
|
||||
const healed = await this.budget.freezeMissingRates(tripId);
|
||||
if (!healed) return errorResult('The trip currency changed. Reload and try again.');
|
||||
for (const item of healed.items) this.guards.safeBroadcast(tripId, 'budget:updated', { item });
|
||||
for (const settlement of healed.settlements) this.guards.safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
||||
return ok(healed);
|
||||
}
|
||||
|
||||
// --- RESOURCES ---
|
||||
|
||||
@ResourceTemplate({
|
||||
@@ -556,7 +581,8 @@ export class BudgetMcp {
|
||||
}],
|
||||
};
|
||||
}
|
||||
const summary = this.budget.getPerPersonSummary(id);
|
||||
// In the trip currency, each share at the rate its expense was booked at (#2525).
|
||||
const summary = await this.budget.perPersonSummary(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import type { BudgetParticipantFinal, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { byCodeUnit } from '../common/compare';
|
||||
import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer, BudgetItemReceipt } from '../../types';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
|
||||
@@ -88,6 +89,61 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `exchange_rate` holds on a row whose rate was never frozen. The column is
|
||||
* `REAL NOT NULL DEFAULT 1`, so without a migration 1 is the only way to store "no
|
||||
* rate". It is read in exactly two places, isFrozenRate and UNFROZEN_SQL; nothing
|
||||
* else in this file compares a rate with 1.
|
||||
*/
|
||||
const NOT_FROZEN_RATE = 1;
|
||||
|
||||
/** Whether a stored rate is one that was frozen, rather than the "not frozen" sentinel. */
|
||||
function isFrozenRate(rate: number | null | undefined): rate is number {
|
||||
return rate != null && Number.isFinite(rate) && rate > 0 && rate !== NOT_FROZEN_RATE;
|
||||
}
|
||||
|
||||
/** The SQL twin of `!isFrozenRate(exchange_rate)`, for the row tables' own column. */
|
||||
const UNFROZEN_SQL = '(exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)';
|
||||
|
||||
/**
|
||||
* How an amount of a row, in the row's own currency, becomes trip currency, or null when
|
||||
* nothing can convert it. The rate frozen when the row was entered wins (#1335), so a
|
||||
* booked expense keeps the value it was booked at. A row written before the freeze
|
||||
* existed (no frozen rate) converts through today's `rates`, units of each currency per
|
||||
* 1 of their base. Pre-rework rows store currency = NULL, which means the trip's own.
|
||||
*
|
||||
* A foreign row with neither is left out by every caller and reported, rather than read
|
||||
* as if it were already in the trip currency: 8,920,000 VND counted as that many dollars
|
||||
* swamps every balance on the trip.
|
||||
*
|
||||
* The settlement nets with it and every trip total adds up with it, so the two can never
|
||||
* read the same rows differently.
|
||||
*/
|
||||
function tripConverter(
|
||||
itemCurrency: string | null | undefined,
|
||||
itemRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
rates: Record<string, number> | null,
|
||||
): ((amount: number) => number) | null {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount => amount;
|
||||
if (isFrozenRate(itemRate)) return amount => amount / itemRate;
|
||||
const rCur = rates?.[cur];
|
||||
const rTrip = rates?.[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return amount => (amount / rCur) * rTrip;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is
|
||||
* units of that currency per 1 base). Null when either quote is missing.
|
||||
*/
|
||||
function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number | null {
|
||||
const rTo = rates?.[to];
|
||||
const rFrom = rates?.[from];
|
||||
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget domain service — owns the budget SQL (moved from the legacy
|
||||
* services/budgetService.ts: identical statements, the `||` falsy-coercion
|
||||
@@ -337,18 +393,25 @@ export class BudgetService {
|
||||
* "units of the item/display currency per 1 trip currency" — the settlement
|
||||
* converts with it via `amount / rate`.
|
||||
*
|
||||
* Only freezes for a foreign currency with no explicit rate; degrades to live
|
||||
* rates if the fetch fails. On update it (re)freezes only when the currency
|
||||
* changes (checked against `budget_items`), so an unrelated edit never moves
|
||||
* money. Callers must invoke this *before* the (synchronous) DB write — the raw
|
||||
* create/update stay sync because better-sqlite3 transactions can't await.
|
||||
* Only freezes for a foreign currency with no explicit rate. The server's own rate
|
||||
* comes first; `fallback_fx`, rates the caller lent for this write, only fills a
|
||||
* currency the server cannot quote (resolveRate). With neither, a new row is
|
||||
* stored unfrozen and left out of every figure until a rate turns up. On update it
|
||||
* (re)freezes only when the currency changes (checked against `budget_items`), so
|
||||
* an unrelated edit never moves money, and a change with no rate at all resets the
|
||||
* row to unfrozen instead of keeping the old currency's rate. `fallback_fx` is taken
|
||||
* off `data` either way, so it never reaches the write. Callers must invoke this
|
||||
* *before* the (synchronous) DB write: the raw create/update stay sync because
|
||||
* better-sqlite3 transactions can't await.
|
||||
*/
|
||||
async freezeForeignRate(
|
||||
tripId: string | number,
|
||||
data: { currency?: string | null; exchange_rate?: number },
|
||||
data: { currency?: string | null; exchange_rate?: number; fallback_fx?: BudgetFallbackFx },
|
||||
existingItemId?: string | number,
|
||||
existingCurrency?: string | null,
|
||||
): Promise<void> {
|
||||
const fallback = data.fallback_fx;
|
||||
delete data.fallback_fx;
|
||||
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
|
||||
const cur = (data.currency || '').toUpperCase();
|
||||
if (!cur) return; // currency not being set in this request
|
||||
@@ -359,16 +422,98 @@ export class BudgetService {
|
||||
if (existingCurrency !== undefined) {
|
||||
prior = (existingCurrency || '').toUpperCase();
|
||||
} else if (existingItemId != null) {
|
||||
const existing = this.db.get<{ currency?: string }>('SELECT currency FROM budget_items WHERE id = ?', existingItemId);
|
||||
const existing = this.db.get<{ currency?: string }>('SELECT currency FROM budget_items WHERE id = ? AND trip_id = ?', existingItemId, tripId);
|
||||
if (existing) prior = (existing.currency || '').toUpperCase();
|
||||
}
|
||||
if (prior !== undefined && prior === cur) return; // currency unchanged
|
||||
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCur = (trip?.currency || 'EUR').toUpperCase();
|
||||
const tripCur = this.tripCurrency(tripId);
|
||||
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
|
||||
const rates = await this.exchangeRates.getRates(tripCur);
|
||||
const r = rates?.[cur];
|
||||
if (r && r > 0) data.exchange_rate = r;
|
||||
const rate = this.resolveRate(tripCur, cur, await this.exchangeRates.getRates(tripCur), fallback);
|
||||
if (rate !== null) data.exchange_rate = rate;
|
||||
// The update SQL keeps the stored rate when none is sent, which left the old
|
||||
// currency's rate on the row (a USD 1.08 on a VND bill) and counted it as frozen.
|
||||
else if (prior !== undefined) data.exchange_rate = NOT_FROZEN_RATE;
|
||||
}
|
||||
|
||||
/** The trip's currency, upper case, EUR when it has none (the default the app reads). */
|
||||
private tripCurrency(tripId: string | number): string {
|
||||
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
return (trip?.currency || 'EUR').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate to freeze `cur` at on a `tripCur` trip: the server's own quote, else the one
|
||||
* the caller lent, but only while the lent table is quoted against the trip currency
|
||||
* (a table against the old currency after a switch would freeze the wrong figure).
|
||||
* A result that is not a usable frozen rate, exactly 1 among them, counts as none.
|
||||
*/
|
||||
private resolveRate(
|
||||
tripCur: string,
|
||||
cur: string,
|
||||
serverRates: Record<string, number> | null,
|
||||
fallback: BudgetFallbackFx | undefined,
|
||||
): number | null {
|
||||
const own = serverRates?.[cur];
|
||||
if (isFrozenRate(own)) return own;
|
||||
const lent = fallback && fallback.base.toUpperCase() === tripCur ? fallback.rates[cur] : undefined;
|
||||
return isFrozenRate(lent) ? lent : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze a rate onto every expense and transfer in a foreign currency that never
|
||||
* froze one: the rows the settlement lists as `unconverted`, and also those it still
|
||||
* converts at today's live rate. Today's server rate first, `fallback` (rates the
|
||||
* caller holds) for what the server cannot quote. A row that is frozen, in the trip
|
||||
* currency or without a currency is never touched, so a second call heals nothing.
|
||||
*
|
||||
* The write is a compare-and-set in one transaction: only rows that are still
|
||||
* unfrozen are updated, and nothing is written when the trip currency changed while
|
||||
* the rates were fetched (null, so the caller can say so). A healthy trip with
|
||||
* nothing pending returns before any fetch.
|
||||
*/
|
||||
async freezeMissingRates(
|
||||
tripId: string | number,
|
||||
fallback?: BudgetFallbackFx,
|
||||
): Promise<{ items: BudgetItem[]; settlements: NonNullable<ReturnType<BudgetService['getSettlement']>>[]; unresolved: string[] } | null> {
|
||||
const tripCur = this.tripCurrency(tripId);
|
||||
const pendingWhere = `trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? AND ${UNFROZEN_SQL}`;
|
||||
const pending = this.db.all<{ cur: string }>(`
|
||||
SELECT UPPER(currency) AS cur FROM budget_items WHERE ${pendingWhere}
|
||||
UNION
|
||||
SELECT UPPER(currency) AS cur FROM budget_settlements WHERE ${pendingWhere}
|
||||
ORDER BY cur
|
||||
`, tripId, tripCur, tripId, tripCur).map(r => r.cur);
|
||||
if (pending.length === 0) return { items: [], settlements: [], unresolved: [] };
|
||||
|
||||
const serverRates = await this.exchangeRates.getRates(tripCur);
|
||||
const resolved: [string, number][] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const cur of pending) {
|
||||
const rate = this.resolveRate(tripCur, cur, serverRates, fallback);
|
||||
if (rate === null) unresolved.push(cur);
|
||||
else resolved.push([cur, rate]);
|
||||
}
|
||||
if (resolved.length === 0) return { items: [], settlements: [], unresolved };
|
||||
|
||||
const written = this.db.transaction(() => {
|
||||
if (this.tripCurrency(tripId) !== tripCur) return null;
|
||||
const ids = { budget_items: [] as number[], budget_settlements: [] as number[] };
|
||||
for (const table of ['budget_items', 'budget_settlements'] as const) {
|
||||
const where = `trip_id = ? AND UPPER(currency) = ? AND ${UNFROZEN_SQL}`;
|
||||
for (const [cur, rate] of resolved) {
|
||||
ids[table].push(...this.db.all<{ id: number }>(`SELECT id FROM ${table} WHERE ${where}`, tripId, cur).map(r => r.id));
|
||||
this.db.run(`UPDATE ${table} SET exchange_rate = ? WHERE ${where}`, rate, tripId, cur);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
if (!written) return null;
|
||||
|
||||
return {
|
||||
items: written.budget_items.sort((a, b) => a - b).map(id => this.getBudgetItem(id, tripId)).filter(i => i !== null),
|
||||
settlements: written.budget_settlements.sort((a, b) => a - b).map(id => this.getSettlement(id, tripId)).filter(s => s !== null),
|
||||
unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,7 +686,7 @@ export class BudgetService {
|
||||
linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: { name: string; category?: string; total_price: number },
|
||||
data: { name: string; category?: string; total_price: number; currency?: string | null; exchange_rate?: number },
|
||||
) {
|
||||
// createBudgetItem accepts reservation_id directly — the legacy separate
|
||||
// UPDATE after the insert was redundant (and non-atomic).
|
||||
@@ -814,20 +959,110 @@ export class BudgetService {
|
||||
// Per-person summary
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getPerPersonSummary(tripId: string | number) {
|
||||
const summary = this.db.all<{ user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }>(`
|
||||
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar,
|
||||
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
|
||||
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
|
||||
COUNT(bi.id) as items_count
|
||||
/**
|
||||
* What each member is down for across the trip (`total_assigned`), and how much of
|
||||
* that is marked paid (`total_paid`), in the trip currency.
|
||||
*
|
||||
* Each share is converted to trip cents the way the settlement converts it, at the
|
||||
* rate frozen when the expense was entered, and an equal split is a largest-remainder
|
||||
* split of those cents (#2525). The SQL this replaces summed raw amounts across
|
||||
* currencies and divided them in floats, so a bill of 801.76 USD counted as 801.76 of
|
||||
* the trip's euros and a third of 100 came out as 33.333…
|
||||
*/
|
||||
getPerPersonSummary(tripId: string | number, rates: Record<string, number> | null = null) {
|
||||
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCurrency = (trip?.currency || 'EUR').toUpperCase();
|
||||
const items = this.db.all<{ id: number; total_price: number | null; currency: string | null; exchange_rate: number | null }>(
|
||||
'SELECT id, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ?', tripId,
|
||||
);
|
||||
const members = this.db.all<{ budget_item_id: number; user_id: number; amount: number | null; paid: number | null; username: string; avatar: string | null }>(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.amount, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bi.trip_id = ?
|
||||
GROUP BY bm.user_id
|
||||
`, tripId);
|
||||
const people = new Map<number, { user_id: number; username: string; avatar: string | null; assigned: number; paid: number; items_count: number }>();
|
||||
for (const item of items) {
|
||||
const own = members.filter(m => m.budget_item_id === item.id);
|
||||
if (own.length === 0) continue;
|
||||
// A foreign row nothing can convert counts for nobody, as in the settlement.
|
||||
const convert = tripConverter(item.currency, item.exchange_rate, tripCurrency, rates);
|
||||
if (!convert) continue;
|
||||
const toTripCents = (amount: number) => Math.round(convert(amount) * 100);
|
||||
// A member with an amount of their own owes exactly that; the rest split the
|
||||
// total evenly, as the query this replaces did.
|
||||
const equal = this.splitEqualShares(toTripCents(item.total_price || 0), own, item.id);
|
||||
for (const m of own) {
|
||||
const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount) : (equal[m.user_id] || 0);
|
||||
let p = people.get(m.user_id);
|
||||
if (!p) {
|
||||
p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 };
|
||||
people.set(m.user_id, p);
|
||||
}
|
||||
p.assigned += share;
|
||||
if (m.paid === 1) p.paid += share;
|
||||
p.items_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
|
||||
return [...people.values()]
|
||||
.sort((a, b) => a.user_id - b.user_id)
|
||||
.map(p => ({
|
||||
user_id: p.user_id, username: p.username, avatar: p.avatar,
|
||||
total_assigned: p.assigned / 100, total_paid: p.paid / 100, items_count: p.items_count,
|
||||
currency: tripCurrency,
|
||||
avatar_url: avatarUrl(p),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the trip's expenses add up to in the trip currency, overall and per category
|
||||
* (#2525). Every row is converted once, the way the settlement converts it, and
|
||||
* rounded to a whole cent before anything is added. Summing total_price as stored adds
|
||||
* dollars to euros and labels the result with the trip currency. A foreign row nothing
|
||||
* can convert is left out and its id listed under `unconverted`.
|
||||
*/
|
||||
tripTotals(tripId: string | number, tripCurrency: string, rates: Record<string, number> | null = null): { total: number; byCategory: Record<string, number>; unconverted: number[] } {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const rows = this.db.all<{ id: number; category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>(
|
||||
'SELECT id, category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId,
|
||||
);
|
||||
let total = 0;
|
||||
const byCategory: Record<string, number> = {};
|
||||
const unconverted: number[] = [];
|
||||
for (const r of rows) {
|
||||
const convert = tripConverter(r.currency, r.exchange_rate, trip, rates);
|
||||
if (!convert) {
|
||||
unconverted.push(r.id);
|
||||
continue;
|
||||
}
|
||||
const cents = Math.round(convert(r.total_price || 0) * 100);
|
||||
total += cents;
|
||||
const cat = r.category || '';
|
||||
byCategory[cat] = (byCategory[cat] || 0) + cents;
|
||||
}
|
||||
return {
|
||||
total: total / 100,
|
||||
byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])),
|
||||
unconverted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's rates against the trip currency, for the totals above. Fetched only when a
|
||||
* row in another currency never froze a rate of its own: every other row converts
|
||||
* without them, so a trip whose rows were all booked never waits on the network.
|
||||
*/
|
||||
async ratesForTripTotals(tripId: string | number, tripCurrency: string): Promise<Record<string, number> | null> {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const unbooked = this.db.get(`
|
||||
SELECT 1 FROM budget_items
|
||||
WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ?
|
||||
AND ${UNFROZEN_SQL}
|
||||
LIMIT 1
|
||||
`, tripId, trip);
|
||||
return unbooked ? this.exchangeRates.getRates(trip) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -879,13 +1114,19 @@ export class BudgetService {
|
||||
* behind each figure (`sources`) travel with it, in the same display cents: an
|
||||
* expense list converted again on the client with today's rate would not add up
|
||||
* to a figure that was booked at the rate frozen on entry.
|
||||
*
|
||||
* A row nothing can convert (a foreign currency with no frozen rate and no live
|
||||
* one) is left out whole and listed under `unconverted`, so Σ balances stays 0.
|
||||
* `currency` says what the amounts are in: the display currency when there is a
|
||||
* quote for it (the rates, else `baseRate`, the caller's own units of display
|
||||
* currency per 1 trip currency), otherwise the trip currency.
|
||||
*/
|
||||
calculateSettlement(
|
||||
tripId: string | number,
|
||||
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
|
||||
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string; baseRate?: number } = {},
|
||||
) {
|
||||
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
||||
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
|
||||
const requested = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
||||
const tripCurrency = (opts.tripCurrency || requested).toUpperCase();
|
||||
const rates = opts.rates ?? null;
|
||||
// Net the whole settlement in the trip's canonical currency and convert the final
|
||||
// totals to the display currency once, instead of netting in the (moving) display
|
||||
@@ -895,47 +1136,48 @@ export class BudgetService {
|
||||
// is the identity, so behaviour is unchanged.
|
||||
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
|
||||
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
|
||||
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
// Legacy rows without a frozen rate: convert via base with live rates.
|
||||
if (!rates) return amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
};
|
||||
// Legacy rows without a frozen rate convert via base with live rates.
|
||||
const converterFor = (itemCurrency: string | null | undefined, itemRate?: number | null) =>
|
||||
tripConverter(itemCurrency, itemRate, tripCurrency, rates);
|
||||
// trip-currency → display currency, applied once to the final netted totals.
|
||||
// Held as a plain factor so it is exactly linear: the balances are converted as
|
||||
// one set (allocateDisplayCents) rather than one at a time, which is what keeps
|
||||
// them adding up to zero in whatever currency the viewer picked (#1382).
|
||||
const displayFactor = base === tripCurrency
|
||||
? 1
|
||||
: (rates && rates[tripCurrency] > 0 ? 1 / rates[tripCurrency] : 1);
|
||||
//
|
||||
// `rates` may be quoted against any base; the ratio of the two quotes is the
|
||||
// factor either way. Callers pass the trip currency's own quote (see settlement()),
|
||||
// the one freezeForeignRate froze every entry rate from, so a bill entered today in
|
||||
// the display currency converts back to exactly what was typed (#2525). Taken the
|
||||
// other way round, from the display currency's quote, the two quotes are not exact
|
||||
// inverses and 12,345.67 USD came back as 12,346.06.
|
||||
//
|
||||
// Without a quote, the caller's own figure for the pair (`baseRate`) stands in for
|
||||
// the missing display quote, and for nothing else: a row with a currency of its own
|
||||
// is never converted with it. Like a live quote, it also reads a legacy transfer
|
||||
// without a currency, which is taken to be in the display currency. With neither,
|
||||
// the answer stays in the trip currency and says so, rather than printing trip cents
|
||||
// as the display currency.
|
||||
const baseRate = opts.baseRate != null && Number.isFinite(opts.baseRate) && opts.baseRate > 0 ? opts.baseRate : null;
|
||||
const quoted = requested === tripCurrency ? 1 : (quoteRatio(rates, requested, tripCurrency) ?? baseRate);
|
||||
const currency = quoted === null ? tripCurrency : requested;
|
||||
const displayFactor = quoted ?? 1;
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer
|
||||
// was viewing. New rows capture that currency and the rate frozen at settle time
|
||||
// (#1445), so a settled position stays balanced when live rates drift — mirroring
|
||||
// toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall
|
||||
// back to the old behaviour: assume they were entered in the current display base
|
||||
// and convert with live rates.
|
||||
const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => {
|
||||
if (sCurrency) {
|
||||
const cur = sCurrency.toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
if (sRate != null && sRate > 0 && sRate !== 1) return amount / sRate;
|
||||
// Frozen currency but no usable rate (fetch failed at settle time): live fallback.
|
||||
if (rates) {
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount);
|
||||
};
|
||||
// the expenses; a frozen currency without a usable rate falls back to live rates,
|
||||
// and without those the transfer is left out like an expense. Legacy rows
|
||||
// (currency = NULL) have no frozen rate, so fall back to the old behaviour: assume
|
||||
// they were entered in the current display base and convert with live rates.
|
||||
const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) =>
|
||||
sCurrency
|
||||
? tripConverter(sCurrency, sRate, tripCurrency, rates)
|
||||
// The inverse of the display factor, so such a transfer reads back as entered.
|
||||
: (amount: number) => amount / displayFactor;
|
||||
const unconvertedItems: number[] = [];
|
||||
const unconvertedSettlements: number[] = [];
|
||||
const unconvertedCurrencies = new Set<string>();
|
||||
|
||||
const items = this.db.all<BudgetItem>('SELECT * FROM budget_items WHERE trip_id = ?', tripId);
|
||||
const allMembers = this.db.all<BudgetItemMember & { budget_item_id: number }>(`
|
||||
@@ -956,8 +1198,6 @@ export class BudgetService {
|
||||
// and rounded to a cent once, at the boundary — from there on the ledger is
|
||||
// integer arithmetic, so Σ(balances) is exactly 0 and no sub-cent residual can
|
||||
// build up behind the two-decimal figures the user sees (#1382).
|
||||
const toTripCents = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number =>
|
||||
Math.round(toTrip(amount, itemCurrency, itemRate) * 100);
|
||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; cents: number }> = {};
|
||||
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0 };
|
||||
@@ -974,8 +1214,23 @@ export class BudgetService {
|
||||
// cents rather than converting the expense list a second time on the client.
|
||||
const frontedRows: Record<number, { item_id: number; cents: number }[]> = {};
|
||||
const movedRows: Record<number, { settlement_id: number; from_user_id: number; to_user_id: number; cents: number }[]> = {};
|
||||
// Read in the trip currency, those figures are the ledger's own trip cents. Read in
|
||||
// another one, they are built from each row's unrounded trip amount instead: a trip
|
||||
// cent is worth more than a dollar cent on a euro trip, so a bill of 12,345.67 USD
|
||||
// rounded to 10,831.44 EUR first came back as 12,345.68 (#2525). The balances, and
|
||||
// with them settle-up, still net whole trip cents.
|
||||
const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents);
|
||||
|
||||
for (const item of items) {
|
||||
// Before anything else, planning-only and unpaid rows included: a row no rate can
|
||||
// convert is reported whole and moves nothing, credits and shares alike.
|
||||
const convert = converterFor(item.currency, item.exchange_rate);
|
||||
if (!convert) {
|
||||
unconvertedItems.push(item.id);
|
||||
unconvertedCurrencies.add((item.currency || '').toUpperCase());
|
||||
continue;
|
||||
}
|
||||
const toTripCents = (amount: number): number => Math.round(convert(amount) * 100);
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||
@@ -1003,10 +1258,11 @@ export class BudgetService {
|
||||
// refund (#2176) — is debited by the same arithmetic: their credit is negative.
|
||||
let creditCents = 0;
|
||||
for (const p of payers) {
|
||||
const paid = toTripCents(p.amount, item.currency, item.exchange_rate);
|
||||
const exact = convert(p.amount) * 100;
|
||||
const paid = Math.round(exact);
|
||||
ensure(p.user_id, p).cents += paid;
|
||||
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + paid;
|
||||
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: paid });
|
||||
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact);
|
||||
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: finalUnit(exact) });
|
||||
creditCents += paid;
|
||||
}
|
||||
// …and each split participant owes their share — a custom per-member amount
|
||||
@@ -1024,7 +1280,7 @@ export class BudgetService {
|
||||
const equalShares = !hasCustomSplit ? this.splitEqualShares(creditCents, members, item.id) : {};
|
||||
for (const m of members) {
|
||||
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
|
||||
? toTripCents(m.amount, item.currency, item.exchange_rate)
|
||||
? toTripCents(m.amount)
|
||||
: (equalShares[m.user_id] || 0);
|
||||
ensure(m.user_id, m).cents -= memberShare;
|
||||
}
|
||||
@@ -1041,19 +1297,27 @@ export class BudgetService {
|
||||
return balances[id];
|
||||
};
|
||||
for (const s of settlements) {
|
||||
const convert = settleConverterFor(s.currency, s.exchange_rate);
|
||||
if (!convert) {
|
||||
unconvertedSettlements.push(s.id);
|
||||
unconvertedCurrencies.add((s.currency || '').toUpperCase());
|
||||
continue;
|
||||
}
|
||||
// Rounded to a trip cent per transfer, so recording one in a display currency
|
||||
// can't leave a sliver of a cent behind to accumulate over a trip's lifetime.
|
||||
const inTrip = Math.round(settleToTrip(s.amount, s.currency, s.exchange_rate) * 100);
|
||||
const exact = convert(s.amount) * 100;
|
||||
const inTrip = Math.round(exact);
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip;
|
||||
// Net of the transfers in both directions: sending one back is a reimbursement
|
||||
// received in reverse, and netting them is what keeps the final budget's
|
||||
// subtraction equal to the balance it is taken from.
|
||||
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + inTrip;
|
||||
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - inTrip;
|
||||
const shown = finalUnit(exact);
|
||||
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + shown;
|
||||
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - shown;
|
||||
const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id };
|
||||
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: inTrip });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -inTrip });
|
||||
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown });
|
||||
}
|
||||
|
||||
// Into the display currency as one set, then simplify — balances and flows are
|
||||
@@ -1133,6 +1397,12 @@ export class BudgetService {
|
||||
},
|
||||
};
|
||||
}) satisfies BudgetParticipantFinal[],
|
||||
currency,
|
||||
unconverted: {
|
||||
item_ids: unconvertedItems,
|
||||
settlement_ids: unconvertedSettlements,
|
||||
currencies: [...unconvertedCurrencies].sort(byCodeUnit),
|
||||
} satisfies BudgetUnconverted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1237,22 +1507,35 @@ export class BudgetService {
|
||||
return this.listBudgetItems(tripId);
|
||||
}
|
||||
|
||||
perPersonSummary(tripId: string) {
|
||||
return this.getPerPersonSummary(tripId);
|
||||
async perPersonSummary(tripId: string | number) {
|
||||
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
return this.getPerPersonSummary(tripId, await this.ratesForTripTotals(tripId, trip?.currency || 'EUR'));
|
||||
}
|
||||
|
||||
async settlement(tripId: string, base: string | undefined, tripCurrency: string) {
|
||||
const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
/**
|
||||
* The settlement in the viewer's currency, for REST and MCP alike. Converted with the
|
||||
* trip currency's own quote, the one every entry rate was frozen from, so a same-day
|
||||
* amount in the display currency round-trips to the cent and paying what settle-up
|
||||
* offers closes the balance (#2525). The display currency's quote only stands in when
|
||||
* the trip's cannot be fetched, and `baseRate` (the caller's own units of display
|
||||
* currency per 1 trip currency) only when neither can. It only stands in for that
|
||||
* missing display quote, which also reads a legacy transfer without a currency (taken
|
||||
* to be in the display currency, as with a live quote).
|
||||
*/
|
||||
async settlement(tripId: string | number, base: string | undefined, tripCurrency: string, baseRate?: number) {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const effectiveBase = (base || trip).toUpperCase();
|
||||
const rates = (await this.exchangeRates.getRates(trip))
|
||||
?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase));
|
||||
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip, baseRate });
|
||||
}
|
||||
|
||||
async create(tripId: string, data: Parameters<BudgetService['createBudgetItem']>[1]) {
|
||||
async create(tripId: string, data: Parameters<BudgetService['createBudgetItem']>[1] & { fallback_fx?: BudgetFallbackFx }) {
|
||||
await this.freezeForeignRate(tripId, data);
|
||||
return this.createBudgetItem(tripId, data);
|
||||
}
|
||||
|
||||
async update(id: string | number, tripId: string | number, data: Parameters<BudgetService['updateBudgetItem']>[2]) {
|
||||
async update(id: string | number, tripId: string | number, data: Parameters<BudgetService['updateBudgetItem']>[2] & { fallback_fx?: BudgetFallbackFx }) {
|
||||
await this.freezeForeignRate(tripId, data, id);
|
||||
return this.updateBudgetItem(id, tripId, data);
|
||||
}
|
||||
@@ -1278,7 +1561,7 @@ export class BudgetService {
|
||||
return roster.has(data.from_user_id) && roster.has(data.to_user_id);
|
||||
}
|
||||
|
||||
async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, userId: number) {
|
||||
async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }, userId: number) {
|
||||
if (!this.settlementPartiesOnTrip(tripId, data)) return null;
|
||||
// Freeze the FX rate for the display currency the amount was entered in so the
|
||||
// transfer keeps cancelling its expense when live rates drift (#1445).
|
||||
@@ -1286,7 +1569,7 @@ export class BudgetService {
|
||||
return this.insertSettlement(tripId, data, userId);
|
||||
}
|
||||
|
||||
async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }) {
|
||||
async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }) {
|
||||
// Pass the settlement's stored currency so an edit that doesn't change it keeps
|
||||
// the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a
|
||||
// settled position on an unrelated edit.
|
||||
|
||||
@@ -8,14 +8,16 @@ import { Injectable } from '@nestjs/common';
|
||||
* settlement request never hammers the upstream. Rates are "units of X per 1
|
||||
* base", so an amount in currency C converts to base as `amount / rates[C]`.
|
||||
*
|
||||
* Everything degrades gracefully: if the fetch fails (offline, upstream down),
|
||||
* callers get `null`/identity conversion and amounts are treated as already in
|
||||
* the base currency rather than throwing.
|
||||
* Everything degrades without throwing: if the fetch fails (offline, upstream
|
||||
* down), callers get `null`. Budget rows that already froze a rate still convert;
|
||||
* a foreign row that did not is left out of the figures and reported rather than
|
||||
* read as if it were already in the base currency. A failed fetch is not
|
||||
* remembered: the next request for that base asks the upstream again.
|
||||
*
|
||||
* Moved from the legacy `services/exchangeRateService.ts` (the budget-domain
|
||||
* fold): identical cache/coalescing behavior, `||` falsy-coercion defaults and
|
||||
* null degradation. The cache and inflight maps are deliberately MODULE-scoped,
|
||||
* not instance fields — the DI singleton and the out-of-container instances
|
||||
* not instance fields: the DI singleton and the out-of-container instances
|
||||
* (the trips/airtrail/auth bridges each construct their own) wrap the same
|
||||
* upstream feed, so they must share one cache (same reasoning as the
|
||||
* permissions-cache.ts cache).
|
||||
|
||||
@@ -189,6 +189,79 @@ export function dayStartEpochSeconds(day: string, tzOffsetMinutes = 0): number {
|
||||
}
|
||||
|
||||
|
||||
// 'YYYY:MM:DD HH:MM:SS' as EXIF writes it. The dashed date is what some
|
||||
// converters produce and what exifr's own reviver accepted, so it keeps working,
|
||||
// and so does a field written with one digit ('2026:5:30 9:05:00'), which that
|
||||
// reviver read as well. Whatever follows the time is checked on its own below.
|
||||
// No end anchor on purpose: a greedy tail in front of '$' makes a long run of
|
||||
// digits cost quadratic time, and the stamp is text any upload can set.
|
||||
const EXIF_STAMP = /^(\d{4})[:-](\d{1,2})[:-](\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?/;
|
||||
// A stamp with nanoseconds and a zone is 36 characters. A longer one is no
|
||||
// stamp, and is not worth reading.
|
||||
const EXIF_STAMP_MAX = 64;
|
||||
// '+02:00' is what the spec asks for; '+0200', 'Z', 'UTC' and 'GMT' say the same thing.
|
||||
const EXIF_ZONE = /^(?:(Z|UTC|GMT)|([+-])(\d{2}):?(\d{2}))$/i;
|
||||
|
||||
/** An EXIF zone ('+02:00', an OffsetTime* tag or a stamp's own) in minutes east of UTC, or null when it is not one. */
|
||||
function exifZoneMinutes(value: unknown): number | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const m = EXIF_ZONE.exec(value.trim());
|
||||
if (!m) return null;
|
||||
if (m[1]) return 0;
|
||||
const hours = Number(m[3]);
|
||||
const minutes = Number(m[4]);
|
||||
// Real zones run from -12:00 to +14:00; anything past that is a broken tag.
|
||||
if (hours > 14 || minutes > 59) return null;
|
||||
return (m[2] === '-' ? -1 : 1) * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The instant an EXIF capture stamp names, as an ISO string, or null.
|
||||
*
|
||||
* EXIF stores the photographer's wall clock ('2026:05:30 15:52:19') and, in a
|
||||
* separate tag, the zone it was read in ('+02:00'). Handing the file to exifr
|
||||
* with its defaults turns the stamp into a Date in the server's zone and never
|
||||
* looks at the offset, so a photo taken in France and uploaded to a server in
|
||||
* Chicago was stored seven hours late (#2512). This takes the raw stamp and
|
||||
* applies the first well-formed offset in `offsets`. A stamp that carries its
|
||||
* own zone ('... UTC', 'Z', '+02:00', the form exifr's reviver comment names)
|
||||
* needs none of them: that zone is the most specific answer there is.
|
||||
*
|
||||
* Without one, the server's zone is all there is to go on. That is what every
|
||||
* upload got before, and it stays right for a server in the photographer's zone.
|
||||
*
|
||||
* A stamp that is not a real moment (blank, the zeros a camera without a clock
|
||||
* writes, 30 February, 25 o'clock) is null, not whatever Date rolls it over to.
|
||||
*/
|
||||
export function exifCaptureInstant(stamp: unknown, offsets: unknown[]): string | null {
|
||||
if (typeof stamp !== 'string') return null;
|
||||
const text = stamp.trim();
|
||||
if (text.length > EXIF_STAMP_MAX) return null;
|
||||
const m = EXIF_STAMP.exec(text);
|
||||
if (!m) return null;
|
||||
const suffix = text.slice(m[0].length).trim();
|
||||
// After the time there is either nothing or a zone; anything else ('CEST',
|
||||
// '+2', 'later') is not a stamp. A zone that is out of range leaves the
|
||||
// decision to the tags.
|
||||
if (suffix && !EXIF_ZONE.test(suffix)) return null;
|
||||
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
||||
const wall = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
||||
// Date.UTC quietly rolls an impossible part into the next one (and reads
|
||||
// years below 100 as 19xx), so the parts have to survive the round trip. It
|
||||
// is checked on the UTC reading so a local DST gap does not count as invalid.
|
||||
if (wall.getUTCFullYear() !== year || wall.getUTCMonth() !== month - 1 || wall.getUTCDate() !== day
|
||||
|| wall.getUTCHours() !== hour || wall.getUTCMinutes() !== minute || wall.getUTCSeconds() !== second) {
|
||||
return null;
|
||||
}
|
||||
const offset = exifZoneMinutes(suffix) ?? offsets.map(exifZoneMinutes).find(o => o !== null);
|
||||
const instant = offset == null
|
||||
? new Date(year, month - 1, day, hour, minute, second)
|
||||
: new Date(wall.getTime() - offset * 60000);
|
||||
return instant.toISOString();
|
||||
}
|
||||
|
||||
|
||||
export type AssetInfo = {
|
||||
id: string;
|
||||
takenAt: string | null;
|
||||
|
||||
@@ -3,6 +3,60 @@ import exifr from 'exifr';
|
||||
import { PhotoResolverService } from './photo-resolver.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
import { exifCaptureInstant } from './memories.helpers';
|
||||
|
||||
/**
|
||||
* What readCapture asks exifr for.
|
||||
*
|
||||
* `latitude` and `longitude` are not tags. exifr works them out from the four
|
||||
* GPS tags below, and only when it has read those. A pick of just the two
|
||||
* derived names reads no GPS at all, which is how every upload used to lose
|
||||
* its location (#2512).
|
||||
*/
|
||||
const CAPTURE_TAGS = [
|
||||
'DateTimeOriginal',
|
||||
'CreateDate',
|
||||
'OffsetTimeOriginal',
|
||||
'OffsetTimeDigitized',
|
||||
'OffsetTime',
|
||||
'GPSLatitude',
|
||||
'GPSLatitudeRef',
|
||||
'GPSLongitude',
|
||||
'GPSLongitudeRef',
|
||||
] as const;
|
||||
|
||||
/** When and where an uploaded file says it was taken. */
|
||||
type LocalCapture = { takenAt: string | null; lat: number | null; lng: number | null };
|
||||
|
||||
type Exif = Partial<Record<(typeof CAPTURE_TAGS)[number] | 'latitude' | 'longitude', unknown>>;
|
||||
|
||||
function coordinate(value: unknown, limit: number): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture time and place in a file's EXIF, or null when it names neither.
|
||||
* Throws for a file exifr cannot read at all.
|
||||
*/
|
||||
async function readCapture(abs: string): Promise<LocalCapture | null> {
|
||||
// Raw values on purpose: see exifCaptureInstant for why the stamps are not
|
||||
// left to exifr's reviver.
|
||||
const parsed = (await exifr.parse(abs, { pick: [...CAPTURE_TAGS], reviveValues: false })) as Exif | undefined;
|
||||
if (!parsed) return null;
|
||||
|
||||
// Each stamp has an offset tag of its own. The others stand in for a missing
|
||||
// one: a camera writes all three from the same clock.
|
||||
const { OffsetTimeOriginal: original, OffsetTimeDigitized: digitized, OffsetTime: modified } = parsed;
|
||||
const takenAt = exifCaptureInstant(parsed.DateTimeOriginal, [original, digitized, modified])
|
||||
?? exifCaptureInstant(parsed.CreateDate, [digitized, original, modified]);
|
||||
const lat = coordinate(parsed.latitude, 90);
|
||||
const lng = coordinate(parsed.longitude, 180);
|
||||
// A receiver without a fix can write zeros into the GPS tags instead of
|
||||
// leaving them out. Taken at its word, 0,0 pins the photo to open sea in the
|
||||
// Gulf of Guinea on the journey map, so it counts as no location.
|
||||
const hasPair = lat != null && lng != null && !(lat === 0 && lng === 0);
|
||||
return takenAt || hasPair ? { takenAt, lat: hasPair ? lat : null, lng: hasPair ? lng : null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the provider when and where a photo was taken, and record it (#1614).
|
||||
@@ -70,9 +124,7 @@ export class PhotoCaptureBackfillService {
|
||||
* here already stripped. Nothing to read is the expected outcome far more often
|
||||
* than not — hence no logging on the empty case.
|
||||
*/
|
||||
private async readLocalExif(
|
||||
filePath?: string | null,
|
||||
): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> {
|
||||
private async readLocalExif(filePath?: string | null): Promise<LocalCapture | null> {
|
||||
if (!filePath) return null;
|
||||
// photos.file_path is uploads-relative 'journey/<name>' by every writer;
|
||||
// anything else reads as a miss (same rule as photo-resolver). Central key
|
||||
@@ -81,25 +133,12 @@ export class PhotoCaptureBackfillService {
|
||||
if (!filePath.startsWith('journey/')) return null;
|
||||
const name = filePath.slice('journey/'.length);
|
||||
|
||||
type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number };
|
||||
let parsed: Exif | null;
|
||||
try {
|
||||
parsed = await this.storage.withLocalFile('journey', name, async abs =>
|
||||
(await exifr.parse(abs, {
|
||||
pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'],
|
||||
})) as Exif | null,
|
||||
);
|
||||
return await this.storage.withLocalFile('journey', name, readCapture);
|
||||
} catch {
|
||||
// A vanished object, an invalid key, not an image, a truncated upload,
|
||||
// a video — none of it is an error here.
|
||||
return null;
|
||||
}
|
||||
if (!parsed) return null;
|
||||
|
||||
const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null;
|
||||
const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null;
|
||||
const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null;
|
||||
const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null;
|
||||
return takenAt || lat != null ? { takenAt, lat, lng } : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ import { EMAIL_I18N as I18N, EVENT_TEXTS } from '@trek/shared/i18n/externalNotif
|
||||
import type { EventText, PasswordResetStrings } from '@trek/shared/i18n/externalNotifications';
|
||||
import { getAppUrl } from '../../../app-config';
|
||||
import type { NotifEventType } from '../notification-events';
|
||||
import { EMAIL_LOGO_CID } from './email-logo';
|
||||
|
||||
/**
|
||||
* Pure rendering for outgoing mail: the shell, the CTA and the password-reset
|
||||
* body. No database, no transport, no DI - which is why it stays a plain module
|
||||
* and not a provider. `getAppUrl()` is the canonical app URL from app-config,
|
||||
* never a request header.
|
||||
*
|
||||
* The header logo is not in the HTML itself: the shell points at it by cid:, so
|
||||
* every mail carrying this HTML has to attach `emailLogoAttachment()`.
|
||||
*/
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
@@ -49,7 +53,7 @@ export function buildEmailHtml(
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
|
||||
<!-- Header -->
|
||||
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||
<img src="cid:${EMAIL_LOGO_CID}" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
|
||||
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource & Exploration Kit</div>
|
||||
</td></tr>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type Mail from 'nodemailer/lib/mailer';
|
||||
|
||||
/**
|
||||
* The TREK mark in the mail header, sent as a PNG part of the message and
|
||||
* addressed from the HTML by Content-ID (#2507).
|
||||
*
|
||||
* It used to be an SVG inlined as a data: URI. Gmail strips data: URIs, Outlook
|
||||
* blocks them and neither of them renders SVG, so most people saw a broken image
|
||||
* with "TREK" as its alt text. An inline part referenced through cid: is what
|
||||
* Gmail, Outlook and Apple Mail all display, and unlike a hosted image it does
|
||||
* not depend on the instance being reachable from the recipient's mail provider.
|
||||
*
|
||||
* Rasterized from client/public/icons/icon.svg at 96x96, twice the size the
|
||||
* header shows it at, with the corner radius baked into the image because
|
||||
* Outlook ignores border-radius on an img.
|
||||
*/
|
||||
export const EMAIL_LOGO_CID = 'logo@trek';
|
||||
|
||||
const EMAIL_LOGO_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAACXBIWXMAAFxGAABcRgEUlENBAAALT0lEQVR42u2deXRU1R3H5xy7WhUhA7NnI5nEzACZzCSZkEBYAohNIMGERRo9BLAsUghLQE3RhM2jCGUpW1BaqHhcwFYrrafYIC4UPOwiUCEhIihkIwuQgPD6+71hXuZlZpKZeffO3MLcc74n/+S8w/t8w333/n6/e38yGemRnv4TTXRysjYmZY4mJqVME2PdCz8rNNHWOk10yg2t3so5SuNK0XYlO0ndXlF2JbmVCtWzvRJdShmZ2KqMtNSBKhSRlr3ws0wZYZ6jCDcn47vJWBxxcXE/00ZbcwD0ToDaCPA5kfR2sQ2fVyRvgoMsbYowNygizDtUPc3Z+M4BB6+MSusOYBeDapyg323w2wmMqFFEWBap9Ga538F3j0t/QBtjfUmnT2l2C/4uhq+MaFOPCEtzj3DzUoWi96/8Ah+AZutiUr7VdQT+HoGv4GXmBSZUgUZSAx8Wlv4LALgKwQfhi+GLlbBVpTLfTxS+JipJq4uxHgnC7ww+KByVcChEZ1ITga+L6tsT5vszQfiewrepR5j5nDrcEiNt2tGnxMJf/uUgfO/gO5hwSR7RW+/bxzbaqgGglTTgRxr7cxmZ47nhOU+J9Gh7Zdv1pFsNQ41sr3z3GpHPDQXFJgymCp83IDwBTEg4r+5p0Xn9waU156cMHMX9cKmaC/T48cdb3KyiErrwBZkORkVF/dyLpaZ1I61pZ13ZNo6VcfL0GT/Av6Mw01pPp54cmnP+KXhpVsbezw/4Cb5NCp0pq0P4KrP5fvu8TwO+pV8Wd/v2bWYMKF22ym/wUd1DTVUd7pj58ALF1c7c55ZwLI2Bw8f6DX6bCQmLXcJXxyaG6PTWJppLzb//42Nm4ONCwBZ+9h983oAwUzPslOWuYjxLaMIPjU3h6q80MGPA9rf/5nf48CFGA1AlzvF8yiHl7DGTmZp+np6xIFDwOXlYfLXMMZ+giU0eRXuHu3Lta8zAxz1ArGlQQODzCjVxECsa0WYAZrIohxcOHzvBjAEHDh4NKPw7ersthxuT0kATvjFpGHfr1i1mDHh55YZAw+e660z1MlnefTD9pFppB9amz17I1Pw/HOJLAYUfKkxDFhlWL9COar7z3ofMwK+ru8JHSwMNHyXXmQpx/t9MEz7+7qXL1cwYsPP9fzIB36b4jWjApzTj+Rj6ZWnMmPuCCDru/JPSR3BJ/V0rsX+Ws/qJZTBn+AIf1GePTAOxH5rJlGXL1zEDH+NQvZKGCvAxB1FfT2Zz+O9PvoCNndkL+PEwBcVXyKBarZZmJuuL/QeZMeD4idOiv/6Pdn9C9PmZuRM8ho8KCe1TjQa00oIfEz+Qu3nzJjMGrF6/RTT1XL16jejz0zIe9xi+HA3QxrfIaOZwJ04rYmr+zx47WTAgb/xUos++cPEHe8jZI/gw/fCS0Uygb31zJzPwm5qv8u9oX+38cdNWos/fBu/qLXyRATSqF6rOX2DGgF0flYuWmicJZ+YKpszzGr5gAA34/YeOZmr6KSpeKsCPtw4nmpnD4J6+9wCv4fMG0KrbeWHxCqYMwDW93YDC+aVEn73/y8M+wXdtAKGiKVwXszK+OVMp2tm+/+G/iD7/Jdjr+ALf2QBC8CMMady1a9eZMWDja28I8NWQhiS1+bKPoVm/8Qm+XNfHwQCC5YLjC2YyNf2Me2qGYEBWbgHRZ9fW1vPP9QV+mwGEazU3bXmTGfgtLa1c+COpggGvri4j+vx3/7rLZ/g2AygUyuKcy8rAb5FjVPPw0a+IPn964e99hu9gADn45rQspqafhYteFeBjHphkZg6XskZLhs/w5VreALIl4kXFy5gyIC0jV4jhT5n5HNFnHz3+tST4Ia4MkFqfjztOVgYfn3FIory14wOiz1+5ZrMk+E4GSIUPpya5hsYmZgzYun2HAB/DEKTL4kfkFUiCLzKAxMmUAY+OZWr6mTBlrmCAqe9jRJ/d2NQMJ20skuALBpA6FoTx/4aGRibg19TWcRFxaYIBoRD/v1xdSzT6KRU+bwDpM1npw0bza+21G/4s0hqR/uRe66UJky4r1pRxqYNHOSXQrenZfE3Q6nVbfNDrgopLl3NaYCUVvrMB/8cH4ihXL0he7XRuQBC+3+GHaHvfMSAIPyDwbQYE4QcMfohGMEA6fCzAevqZBbwme6rpbZoEyp9UyOn7DHCCDwcH+QjrpGnz26nIK0101NSONE9QAQrSjfkTZ0HWK50o/DsGSIc/c96LxJZ356q+48LjUkV/+eV79zGxtL34/SWup6EfMfgdGuDNtHP46AnCO8yJAvy+A3OY2tyNeXI6MfhuDfAGvsEyhHiEsXfyMGGOLy5ZzpQBlrRMYvC7uTLA2w8uxsNJjmPHT4o+sh+Xf84M/LMVVUThOxngy2rnnZ1ka///AGfJ7PAxfMBSbrns9e1E4YsM8AU+/g7pCOPI0ZMFA8bkT2cst/wMUfiCAb6u84dk5ROPMGphWWw3YH3ZX5iB39rayoXBWWeS8HkDpGyySNf+40l6x/n/9H/PMmMALoVJw++m6QUGSNjh7tt/iOhLzn12iah8kLXcMmn4LgzwHH407ApvEK79N6dmCuGFOc8uZiu3PGgUcfjd1CIDvIvt4Bad5MDpxjG2w9LFHphbxs0XafgOBngfWNtGuPZ/w+Y3BPhYPniFkcyaPbdMA/4dA3yLap7/7iLxLb7dAAxFMJVb/u1cKvA7NqAD+P2G5hF9wevXW/jrbOwGYLkHx9DFHlFwyyMN+O4N6CSev5Bw7f/u8s9E8fwjx75mxoB9Bw5Rg9/VpQEeJFPKCdf+P1/yigD/EdNgpi72WPryWmrwnQ3wAD7G6nHKIDn6DsoRMlmkg3tSx+DHxlGDLzbAwzRiKpyFJTm+hYN8jmlE0sE9SbVFNXW2W00owecNwJ4p3uRw4SZ17vhXp4i9JNbY2OFjXri6ppYZA959bxdV+F1VxhYwwFLrbQIdK87GTfgd5HHnu9Y0dxLnaPEuZ8ek+RC4T5qlMXXm8/Tgq42oajSgkpXqBdYu9ohLGEQTPvwPMFTIFJGJn7JSOvKfA4eZMeAI3HFHFz5vwB4Z3yeLAfhYbXCDoYs9VkB9K134Ru5hlWGDDHpjzWahaKoASslZGpmjJlCFj+qiMsySYYc4FirWMODFymhsbOaU8O+iCd9mQC8zf20ldogLdLng+QvfM2PAB7t2U4ffVWmwXVuJA9vzBRJ+2pBcpqafwqJSuvBt8/9bbX0DIhNzAlkoy9rFHvFQGEYTPm+A2pAlurwbeyMGqkp5DyO1nzhOnT5LHT4sPy/LZOafim5Qx8aUgYAPPQuIB/ekjHVwkxZd+Pzq50XnBg7qxBBFuKXJ3/X5eJEGSyP3iSm04Tc/oNK77sDKdwX18+GITVDux8rA/4mYj6YF/2GbAaXum/hAE8oeEeZKf55M2fvZfmYMwMwcVfhKY1WnbW+xJas/jwUVQzaMleDbExDhpQUf1VVh/LVHvcSgJet6f57Jynx8AjdnwSJBs1HzO1Kpkwo9UZFrzZpXwndUogm/i9Kw2qtWhtiS9W47EEc7tuMOPmy6vpR508oQB/bDhemoMghfMvwKuTxW5VNHVeyHiy1Zg/B9hn/pQXWsXlJP4R6hlkgA+00QvtfwKx9U94oh0lXbNh2ZDgbhez7n+zztuBvYDxcgrwrC7xT+Rq8/uN4MbMkKDSnPBeG3h2+o8HidL3Xgjhm7ggL8pnsdfheVsRHDC1qt9Zcyfw8M4AH4UuyNeO/BN1zGqOZD2rhusoAPyCdgb0Rsz4cd4u5a+EpDHWaybMmUdvF8dkbefdAbJRGblGGfLGzVBODPgmrl2vhW5uGrjK2gWvgLPws/y/GjitULDymNFiGHS3D8D9feydUotaREAAAAAElFTkSuQmCC',
|
||||
'base64',
|
||||
);
|
||||
|
||||
/**
|
||||
* A fresh attachment object per message, so nodemailer never shares state
|
||||
* between two sends. Every HTML mail from buildEmailHtml needs it.
|
||||
*/
|
||||
export function emailLogoAttachment(): Mail.Attachment {
|
||||
return {
|
||||
filename: 'trek-logo.png',
|
||||
content: EMAIL_LOGO_PNG,
|
||||
contentType: 'image/png',
|
||||
contentDisposition: 'inline',
|
||||
cid: EMAIL_LOGO_CID,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { logError, logInfo, logDebug, logWarn } from '../../audit/audit-log.logg
|
||||
import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { buildEmailHtml, buildPasswordResetHtml } from './email-html';
|
||||
import { emailLogoAttachment } from './email-logo';
|
||||
import { describeSmtpFailure, describeSmtpGap, parseSmtpPort, type SmtpTarget } from './smtp-diagnostics';
|
||||
|
||||
interface SmtpConfig {
|
||||
@@ -188,6 +189,7 @@ export class MailerService {
|
||||
subject: `TREK — ${strings.subject}`,
|
||||
text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`,
|
||||
html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang),
|
||||
attachments: [emailLogoAttachment()],
|
||||
});
|
||||
logInfo(`Password reset email sent to=${to}`);
|
||||
return { delivered: 'email' };
|
||||
@@ -216,6 +218,7 @@ export class MailerService {
|
||||
subject: `TREK — ${subject}`,
|
||||
text: body,
|
||||
html: buildEmailHtml(subject, body, lang, navigateTarget),
|
||||
attachments: [emailLogoAttachment()],
|
||||
});
|
||||
logInfo(`Email sent to=${to} subject="${subject}"`);
|
||||
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
||||
|
||||
@@ -20,6 +20,43 @@ import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } fr
|
||||
|
||||
export const PUBLIC_DIR = path.join(__dirname, '../../../public');
|
||||
|
||||
// File types the client build emits. No page of the app ends in one of these.
|
||||
const BUILD_FILE_RE = /\.(?:m?js|css|map|wasm|webmanifest|json|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$/i;
|
||||
|
||||
/**
|
||||
* Whether a request asks for a file of the built client rather than a page of it.
|
||||
*
|
||||
* Such a request that the static handler could not answer is a file that does not
|
||||
* exist, and it must end in a 404. It used to get index.html like a page (#2524).
|
||||
* A tab still running the previous build asks for a chunk the update removed and
|
||||
* gets HTML with a 200, which the module loader rejects. Worse, a 200 is a success
|
||||
* to everything that caches: a service worker's precache stores the HTML under the
|
||||
* chunk's name and keeps reusing it in every later version that keeps the name,
|
||||
* and a reverse proxy that caches static files by extension hands it out as the
|
||||
* chunk. A 404 is refused by the precache and never stands in for the file.
|
||||
*/
|
||||
export function isBuildFilePath(pathname: string): boolean {
|
||||
return pathname.startsWith('/assets/') || BUILD_FILE_RE.test(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* The production answer to a GET that nothing else matched: index.html for a page
|
||||
* of the app, a plain 404 in the TREK envelope for a build file that is not there.
|
||||
* Shared by both fallback forms (applyPlatformSpa and SpaFallbackFilter).
|
||||
*/
|
||||
export function answerUnmatchedGet(req: Request, res: Response, message = 'Not Found'): void {
|
||||
if (isBuildFilePath(req.path)) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.status(404).json({ error: message });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
// Relative to root, so that send() checks only 'index.html' for dot segments. Given
|
||||
// the absolute path it refused an install living under a dot directory (a
|
||||
// ~/.local checkout, say) and every deep link came back as a 404.
|
||||
res.sendFile('index.html', { root: PUBLIC_DIR });
|
||||
}
|
||||
|
||||
/**
|
||||
* express.static replacement for the four public /uploads mounts (storage
|
||||
* slice 3). Parity contract (spec §Serving): identical ETag / Last-Modified /
|
||||
@@ -172,17 +209,14 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
|
||||
* NOT use this (its router terminates unmatched requests with a 404 before any
|
||||
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
|
||||
* the SPA via the SpaFallbackFilter instead. Both produce the identical result:
|
||||
* unmatched GET → index.html in production.
|
||||
* unmatched GET → index.html in production, or a 404 for a missing build file.
|
||||
*/
|
||||
export function applyPlatformSpa(app: express.Application): void {
|
||||
applyPlatformStatic(app);
|
||||
// Case-sensitive on purpose (legacy parity).
|
||||
if (readEnv().app.nodeEnv !== 'production') return;
|
||||
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
|
||||
app.get(/.*/, (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||
});
|
||||
app.get(/.*/, (req: Request, res: Response) => answerUnmatchedGet(req, res));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'node:path';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { PUBLIC_DIR } from './platform.routes';
|
||||
import { answerUnmatchedGet } from './platform.routes';
|
||||
|
||||
/**
|
||||
* Serves the built SPA (index.html) for any request the NestJS router did not
|
||||
@@ -17,6 +16,9 @@ import { PUBLIC_DIR } from './platform.routes';
|
||||
* client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)`
|
||||
* is more specific than the global TrekExceptionFilter, so Nest routes 404s here
|
||||
* while every other error still flows through TrekExceptionFilter.
|
||||
*
|
||||
* A GET for a build file that is not on disk (a chunk of the previous release,
|
||||
* say) is not a page and gets the 404 envelope too, never index.html (#2524).
|
||||
*/
|
||||
@Catch(NotFoundException)
|
||||
export class SpaFallbackFilter implements ExceptionFilter {
|
||||
@@ -27,8 +29,7 @@ export class SpaFallbackFilter implements ExceptionFilter {
|
||||
|
||||
// Case-sensitive on purpose (legacy parity).
|
||||
if (readEnv().app.nodeEnv === 'production' && req.method === 'GET') {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||
answerUnmatchedGet(req, res, exception.message || 'Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { Database as Db } from 'better-sqlite3';
|
||||
import { openDatabase } from '../../../db/connection';
|
||||
import { pluginDataDir, pluginDbFile, pluginsDataRoot } from '../paths';
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,7 @@ export class PluginDataDb {
|
||||
constructor(pluginId: string) {
|
||||
this.pluginId = pluginId;
|
||||
fs.mkdirSync(pluginDataDir(pluginId), { recursive: true });
|
||||
this.db = new Database(pluginDbFile(pluginId));
|
||||
this.db = openDatabase(pluginDbFile(pluginId));
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
openDbs.add(this);
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type ReservationBody = Record<string, unknown> & {
|
||||
title?: string;
|
||||
type?: string;
|
||||
create_budget_entry?: { total_price?: number; category?: string };
|
||||
create_budget_entry?: { total_price?: number; category?: string; currency?: string | null };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,7 @@ export class ReservationsController {
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Post()
|
||||
create(
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() rawBody: ReservationCreateDto,
|
||||
@@ -69,11 +69,14 @@ export class ReservationsController {
|
||||
) {
|
||||
const body = rawBody as ReservationBody & { title: string };
|
||||
this.rejectForeignReferences(tripId, body);
|
||||
// Before the synchronous writes: the price keeps the currency it was quoted in,
|
||||
// at a rate frozen now (#2525).
|
||||
const budgetEntry = await this.reservations.withFrozenRate(tripId, body.create_budget_entry);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never);
|
||||
if (accommodationCreated) {
|
||||
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
|
||||
}
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId);
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, budgetEntry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
|
||||
return { reservation };
|
||||
|
||||
@@ -30,7 +30,7 @@ export class ReservationsRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('reservations.create', { permission: 'db:write:reservations' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'reservation');
|
||||
const parsed = reservationCreateRequestSchema.safeParse(params.input);
|
||||
@@ -39,10 +39,12 @@ export class ReservationsRpc {
|
||||
this.requireValidEndpoints(input.endpoints);
|
||||
this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
|
||||
this.requireOwnReferences(tripId, input);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
// Same as the REST route: the price keeps its currency, at a rate frozen now (#2525).
|
||||
const budgetEntry = await this.reservations.withFrozenRate(tripId, i.create_budget_entry as never);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never);
|
||||
if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined);
|
||||
this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, budgetEntry, undefined);
|
||||
this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined);
|
||||
this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? '');
|
||||
return reservation;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AccommodationsService, noStayMirror, type AccommodationMirror } from '../accommodations/accommodations.service';
|
||||
|
||||
type Trip = TripAccess;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined;
|
||||
|
||||
export interface ReservationEndpoint {
|
||||
id?: number;
|
||||
@@ -1028,6 +1028,25 @@ export class ReservationsService {
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked cost a new booking's price becomes, in the currency the price was quoted
|
||||
* in and at the rate frozen for it now (#2525). An imported booking previewed its
|
||||
* $801.76 in dollars, then stored 801.76 in the trip's own currency, because only the
|
||||
* amount travelled. The rate is resolved here, before the synchronous writes, the way
|
||||
* the direct booking import and the Costs routes resolve it. A currency that is not a
|
||||
* three-letter code is dropped, which leaves the price in the trip currency as before,
|
||||
* and a rate is never taken from the caller.
|
||||
*/
|
||||
async withFrozenRate(tripId: string | number, entry: BudgetEntry): Promise<BudgetEntry> {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
const { currency: rawCurrency, exchange_rate: _callerRate, ...rest } = entry;
|
||||
const currency = typeof rawCurrency === 'string' ? rawCurrency.trim().toUpperCase() : '';
|
||||
if (!/^[A-Z]{3}$/.test(currency)) return rest;
|
||||
const priced: { currency?: string | null; exchange_rate?: number } = { currency };
|
||||
await this.budget.freezeForeignRate(tripId, priced);
|
||||
return { ...rest, currency, ...(priced.exchange_rate != null ? { exchange_rate: priced.exchange_rate } : {}) };
|
||||
}
|
||||
|
||||
/** POST side effect: auto-create a linked budget item when a price is provided. */
|
||||
syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
if (!entry || !(Number(entry.total_price) > 0)) return;
|
||||
@@ -1036,6 +1055,8 @@ export class ReservationsService {
|
||||
name: title,
|
||||
category: entry.category || type || 'Other',
|
||||
total_price: entry.total_price!,
|
||||
...(entry.currency ? { currency: entry.currency } : {}),
|
||||
...(entry.exchange_rate != null ? { exchange_rate: entry.exchange_rate } : {}),
|
||||
});
|
||||
this.realtime.broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -253,9 +253,10 @@ export class StorageService {
|
||||
}
|
||||
|
||||
const tmp = path.join(this.tempDir(), randomUUID());
|
||||
const { stream } = await driver.getStream(key);
|
||||
await pipeline(stream, fs.createWriteStream(tmp));
|
||||
try {
|
||||
const { stream } = await driver.getStream(key);
|
||||
// Inside the try, so a download cut short does not leave its part behind.
|
||||
await pipeline(stream, fs.createWriteStream(tmp));
|
||||
return await fn(tmp);
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
|
||||
@@ -47,7 +47,7 @@ export class TripReadModelService {
|
||||
|
||||
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
|
||||
|
||||
getTripSummary(tripId: number, viewerUserId?: number) {
|
||||
async getTripSummary(tripId: number, viewerUserId?: number) {
|
||||
const trip = withoutFeedToken(
|
||||
this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined,
|
||||
);
|
||||
@@ -63,11 +63,18 @@ export class TripReadModelService {
|
||||
const accommodations = this.accommodations.list(tripId);
|
||||
|
||||
const budgetItems = this.budget.listBudgetItems(tripId);
|
||||
// In the trip currency, each row at the rate it was booked at (#2525). A raw sum of
|
||||
// total_price added a dollar bill to the euros and called the result euros.
|
||||
const tripCurrency = String(trip.currency || 'EUR');
|
||||
const totals = this.budget.tripTotals(tripId, tripCurrency, await this.budget.ratesForTripTotals(tripId, tripCurrency));
|
||||
const budget = {
|
||||
items: budgetItems,
|
||||
item_count: budgetItems.length,
|
||||
total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
total: totals.total,
|
||||
by_category: totals.byCategory,
|
||||
currency: trip.currency,
|
||||
// Rows in a foreign currency with no rate to convert them: in no total above.
|
||||
unconverted_item_ids: totals.unconverted,
|
||||
};
|
||||
|
||||
// Thread the viewer so another member's private/personal packing items (#858)
|
||||
|
||||
@@ -43,27 +43,33 @@ export class TripPromptsMcp {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
|
||||
}
|
||||
const { trip, budget } = summary;
|
||||
const currency = trip?.currency || 'EUR';
|
||||
const byCategory = (budget?.items || []).reduce((acc: Record<string, number>, item: { category?: string; total_price?: number }) => {
|
||||
const cat = item.category || 'Uncategorized';
|
||||
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0);
|
||||
// The summary's own totals, each row in the trip currency at the rate it was booked
|
||||
// at (#2525). Adding total_price up here printed a dollar bill as that many euros.
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const [cat, amount] of Object.entries(budget?.by_category || {})) {
|
||||
const key = cat || 'Uncategorized';
|
||||
byCategory[key] = Math.round(((byCategory[key] || 0) + amount) * 100) / 100;
|
||||
}
|
||||
const total = budget?.total || 0;
|
||||
const lines = Object.entries(byCategory)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
|
||||
.join('\n');
|
||||
const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length);
|
||||
const perPerson = (total / memberCount).toFixed(2);
|
||||
// Rows no rate could convert are in none of the figures above; say so rather than
|
||||
// let the total pass for the whole trip.
|
||||
const uncounted = budget?.unconverted_item_ids?.length || 0;
|
||||
const uncountedLine = uncounted > 0 ? `\n\n${uncounted} expense(s) not counted yet: no exchange rate.` : '';
|
||||
return {
|
||||
description: `Budget overview for "${trip?.title || tripId}"`,
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}${uncountedLine}` } }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +100,9 @@ export class TripPromptsMcp {
|
||||
const lines = Object.entries(grouped).map(([cat, catItems]) =>
|
||||
`## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
|
||||
).join('\n\n');
|
||||
const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {};
|
||||
// Only the title is needed. The whole summary also adds up the budget, which can
|
||||
// wait on a rates fetch for a row that never froze its rate.
|
||||
const trip = this.trips.getRaw(tripId);
|
||||
return {
|
||||
description: `Packing list for "${trip?.title || tripId}"`,
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
|
||||
|
||||
@@ -265,7 +265,7 @@ export class TripsMcp {
|
||||
})
|
||||
async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) return noAccess();
|
||||
const R = canReadTrips(ctx.scopes);
|
||||
// Addon availability gates
|
||||
@@ -599,7 +599,7 @@ export class TripsMcp {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
titleKey: 'system_notice.release_notes.headline',
|
||||
bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '4.3.2',
|
||||
version: '4.3.3',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
@@ -84,7 +84,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
footnoteKey: 'system_notice.release_notes.footnote',
|
||||
notes: {
|
||||
labelKey: 'system_notice.release_notes.notes_label',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.2',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.3',
|
||||
},
|
||||
note: {
|
||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||
|
||||
@@ -14,7 +14,11 @@ export interface SsrfResult {
|
||||
allowed: boolean;
|
||||
/** The first of `resolvedIps`, kept for the callers that name one address. */
|
||||
resolvedIp?: string;
|
||||
/** Every address the name resolves to, each one checked, IPv4 first. */
|
||||
/**
|
||||
* The addresses a connection to the name may use, IPv4 first: every address
|
||||
* it resolves to, less the ones the guard never connects to. On a refusal,
|
||||
* every address it resolved to.
|
||||
*/
|
||||
resolvedIps?: string[];
|
||||
isPrivate: boolean;
|
||||
error?: string;
|
||||
@@ -27,11 +31,11 @@ export interface SsrfResult {
|
||||
* the resolver lists first, on Node 22 in the resolver's own order, and a
|
||||
* dual-stack name whose AAAA comes first then pinned the connection to an IPv6
|
||||
* that many hosts cannot reach on port 443 while the A record would have
|
||||
* answered at once. The whole list goes to the socket, where Node tries the
|
||||
* addresses in turn (autoSelectFamily, the default since Node 20), so an
|
||||
* unreachable family costs a quarter of a second instead of the caller's whole
|
||||
* timeout. IPv4 leads because that is the family that is reachable from the
|
||||
* containers and LXCs TREK usually runs in.
|
||||
* answered at once. Every address the guard allows goes to the socket, where
|
||||
* Node tries the addresses in turn (autoSelectFamily, the default since Node
|
||||
* 20), so an unreachable family costs a quarter of a second instead of the
|
||||
* caller's whole timeout. IPv4 leads because that is the family that is
|
||||
* reachable from the containers and LXCs TREK usually runs in.
|
||||
*/
|
||||
async function resolveAll(hostname: string): Promise<{ address: string; family: number }[]> {
|
||||
const result = await dns.lookup(hostname, { all: true });
|
||||
@@ -49,16 +53,23 @@ async function resolveAll(hostname: string): Promise<{ address: string; family:
|
||||
}
|
||||
|
||||
/**
|
||||
* The IPv4 an IPv4-mapped address (`::ffff:a.b.c.d`) stands for, or null.
|
||||
* The IPv4 an IPv4-mapped (`::ffff:a.b.c.d`) or IPv4-compatible (`::a.b.c.d`)
|
||||
* address stands for, or null.
|
||||
*
|
||||
* Taken from the hextets rather than from the text, because `::ffff:127.0.0.1`
|
||||
* and `::ffff:7f00:1` are one address in two spellings and `dns.lookup` picks
|
||||
* which one comes back. The prefix regexes this replaces covered the dotted
|
||||
* spelling of two ranges; every other mapped address walked past them.
|
||||
* spelling of two ranges; every other mapped address walked past them. The
|
||||
* compatible form is deprecated but still parsed, and Node prints an AAAA
|
||||
* record of `::a9fe:a9fe` as `::169.254.169.254`. `::` and `::1` are left to
|
||||
* the IPv6 checks, being the unspecified and loopback addresses themselves.
|
||||
*/
|
||||
function mappedIpv4(hextets: number[]): string | null {
|
||||
function carriedIpv4(hextets: number[]): string | null {
|
||||
const g = hextets;
|
||||
if (g[0] || g[1] || g[2] || g[3] || g[4] || g[5] !== 0xffff) return null;
|
||||
if (g[0] || g[1] || g[2] || g[3] || g[4]) return null;
|
||||
const mapped = g[5] === 0xffff;
|
||||
const compatible = g[5] === 0 && (g[6] !== 0 || g[7] > 1);
|
||||
if (!mapped && !compatible) return null;
|
||||
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
||||
}
|
||||
|
||||
@@ -72,9 +83,12 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
if (addr.startsWith('127.') || addr === '::1') return true;
|
||||
// Unspecified
|
||||
if (addr.startsWith('0.')) return true;
|
||||
// Link-local / cloud metadata, apart from an address listed in ALLOW_LINK_LOCAL_IPS,
|
||||
// which isPrivateNetwork then treats as the internal network it is.
|
||||
if (addr.startsWith('169.254.') && !ALLOWED_LINK_LOCAL.has(addr)) return true;
|
||||
// Link-local and cloud metadata: everything the admin lane refuses, apart from
|
||||
// an address listed in ALLOW_LINK_LOCAL_IPS, which isPrivateNetwork then treats
|
||||
// as the internal network it is. The AWS IMDSv6 and Alibaba addresses sit in
|
||||
// ULA and CGNAT space, so without this ALLOW_INTERNAL_NETWORK would open them
|
||||
// to a URL any user can type.
|
||||
if (isLinkLocal(addr)) return true;
|
||||
|
||||
const hextets = expandIpv6(addr);
|
||||
if (hextets) {
|
||||
@@ -83,11 +97,13 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
// (`::`, `::0`, `0:0:0:0:0:0:0:0`) that only the expanded hextets settle it.
|
||||
// The `0.` check above never saw any of them: they begin with a colon.
|
||||
if (hextets.every(h => h === 0)) return true;
|
||||
// Loopback in any spelling, not only the `::1` a resolver prints.
|
||||
if (hextets.slice(0, 7).every(h => h === 0) && hextets[7] === 1) return true;
|
||||
// fe80::/10 spans fe80: through febf:, not just the four characters 'fe80'.
|
||||
if ((hextets[0] & 0xffc0) === 0xfe80) return true;
|
||||
// A mapped address inherits the verdict of the IPv4 it carries.
|
||||
const mapped = mappedIpv4(hextets);
|
||||
if (mapped) return isAlwaysBlocked(mapped);
|
||||
// A mapped or compatible address inherits the verdict of the IPv4 it carries.
|
||||
const carried = carriedIpv4(hextets);
|
||||
if (carried) return isAlwaysBlocked(carried);
|
||||
}
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
@@ -111,12 +127,12 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
if (/^f[cd]/i.test(addr)) return true;
|
||||
// A mapped address inherits the verdict of the IPv4 it carries. Deciding on the
|
||||
// hextets rather than on three `::ffff:`-prefix regexes also covers the hex
|
||||
// spelling and the CGNAT range those regexes never listed.
|
||||
// A mapped or compatible address inherits the verdict of the IPv4 it carries.
|
||||
// Deciding on the hextets rather than on three `::ffff:`-prefix regexes also
|
||||
// covers the hex spelling and the CGNAT range those regexes never listed.
|
||||
const hextets = expandIpv6(addr);
|
||||
const mapped = hextets && mappedIpv4(hextets);
|
||||
if (mapped) return isPrivateNetwork(mapped);
|
||||
const carried = hextets && carriedIpv4(hextets);
|
||||
if (carried) return isPrivateNetwork(carried);
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isPrivateNetwork(embedded);
|
||||
@@ -143,10 +159,7 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
|
||||
// Resolve the name to every address it has. Each one is judged on its own,
|
||||
// and a single blocked address blocks the name: the socket may end up on any
|
||||
// of them, so a list that mixes a public and a private address is a private
|
||||
// target with a public alibi.
|
||||
// Resolve the name to every address it has and judge each one on its own.
|
||||
let resolvedIps: string[];
|
||||
try {
|
||||
resolvedIps = (await resolveAll(hostname)).map((entry) => entry.address);
|
||||
@@ -154,20 +167,27 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
|
||||
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
|
||||
}
|
||||
const resolvedIp = resolvedIps[0];
|
||||
|
||||
const blocked = resolvedIps.find((ip) => isAlwaysBlocked(ip));
|
||||
if (blocked) {
|
||||
// An address the guard never connects to is left out of the list the socket
|
||||
// gets, rather than refusing the name. A LAN DNS server commonly hands out a
|
||||
// host's fe80:: address next to its IPv4, and refusing the name for it locked
|
||||
// out every such host (#2506). Only a name with nothing else left is refused.
|
||||
const usableIps = resolvedIps.filter((ip) => !isAlwaysBlocked(ip));
|
||||
if (usableIps.length === 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
resolvedIp: blocked,
|
||||
resolvedIp: resolvedIps[0],
|
||||
resolvedIps,
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
};
|
||||
}
|
||||
const resolvedIp = usableIps[0];
|
||||
|
||||
const privateIp = resolvedIps.find((ip) => isPrivateNetwork(ip));
|
||||
// A private address among the rest decides for the whole name: the socket
|
||||
// may end up on any address it is handed, so a list that mixes a public and a
|
||||
// private address is a private target with a public alibi.
|
||||
const privateIp = usableIps.find((ip) => isPrivateNetwork(ip));
|
||||
if (privateIp || isInternalHostname(hostname)) {
|
||||
if (!ALLOW_INTERNAL_NETWORK || bypassInternalIpAllowed) {
|
||||
return {
|
||||
@@ -179,10 +199,10 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
};
|
||||
}
|
||||
return { allowed: true, isPrivate: true, resolvedIp: privateIp ?? resolvedIp, resolvedIps };
|
||||
return { allowed: true, isPrivate: true, resolvedIp: privateIp ?? resolvedIp, resolvedIps: usableIps };
|
||||
}
|
||||
|
||||
return { allowed: true, isPrivate: false, resolvedIp, resolvedIps };
|
||||
return { allowed: true, isPrivate: false, resolvedIp, resolvedIps: usableIps };
|
||||
}
|
||||
|
||||
/** Link-local / cloud-metadata addresses — never a legitimate model host. */
|
||||
@@ -191,17 +211,23 @@ function isLinkLocal(ip: string): boolean {
|
||||
// IPv4 link-local — AWS, GCP, Azure and OpenStack all serve credentials from 169.254.169.254.
|
||||
// An address listed in ALLOW_LINK_LOCAL_IPS is the exception; those never include it.
|
||||
if (addr.startsWith('169.254.')) return !ALLOWED_LINK_LOCAL.has(addr);
|
||||
// IPv4-mapped (::ffff:169.254.x) and IPv4-compatible (::a9fe:xxxx = ::169.254.x) spellings.
|
||||
if (/^::ffff:169\.254\./.test(addr) || /^::(ffff:)?a9fe:/.test(addr)) return true;
|
||||
// IPv6 link-local fe80::/10 — the whole range (fe80: … febf:), not just the fe80: prefix.
|
||||
if (/^fe[89ab][0-9a-f]:/.test(addr)) return true;
|
||||
// AWS IMDSv6 sits in a fixed ULA slot; block it specifically while leaving the rest of
|
||||
// fc00::/7 reachable (a self-hosted model server may legitimately sit on a ULA address).
|
||||
if (addr === 'fd00:ec2::254' || addr.startsWith('fd00:ec2:')) return true;
|
||||
// AWS IMDSv6 sits in a fixed ULA slot (fd00:ec2::/32); block it specifically while
|
||||
// leaving the rest of fc00::/7 reachable (a self-hosted model server may
|
||||
// legitimately sit on a ULA address). Read off the hextets, so every spelling counts.
|
||||
const hextets = expandIpv6(addr);
|
||||
if (hextets && hextets[0] === 0xfd00 && hextets[1] === 0x0ec2) return true;
|
||||
// Alibaba Cloud ECS metadata (100.100.100.200, and .100) lives in CGNAT space
|
||||
// (100.64.0.0/10), which safeFetchLlm otherwise allows for a LAN model server —
|
||||
// so block the specific metadata IPs directly rather than the whole range.
|
||||
if (addr === '100.100.100.200' || addr === '100.100.100.100') return true;
|
||||
// An IPv4-mapped or IPv4-compatible address, dotted or hex, is the IPv4 it
|
||||
// carries. So 169.254.169.254 cannot come back as ::ffff:a9fe:a9fe, or as the
|
||||
// ::169.254.169.254 Node prints for a compatible AAAA record, and an address
|
||||
// listed in ALLOW_LINK_LOCAL_IPS keeps its exception in every spelling.
|
||||
const carried = hextets && carriedIpv4(hextets);
|
||||
if (carried) return isLinkLocal(carried);
|
||||
// NAT64/6to4/Teredo embedding one of the above link-local/metadata IPs — extract
|
||||
// the embedded IPv4 and re-check. A transition address to a public or LAN IPv4
|
||||
// stays allowed (safeFetchLlm deliberately permits private/loopback targets).
|
||||
@@ -257,11 +283,15 @@ export async function safeFetchAdminConfigured(
|
||||
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
|
||||
throw new SsrfBlockedError(`Could not resolve hostname (${code})`);
|
||||
}
|
||||
if (resolvedIps.some((ip) => isLinkLocal(ip))) {
|
||||
// As in checkSsrf: a link-local or metadata address is left out of the list
|
||||
// the socket gets, and only a name with nothing else left is refused. An
|
||||
// fe80:: record beside an IdP's LAN address refused every OIDC login (#2506).
|
||||
const usableIps = resolvedIps.filter((ip) => !isLinkLocal(ip));
|
||||
if (usableIps.length === 0) {
|
||||
throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed');
|
||||
}
|
||||
|
||||
const dispatcher = createPinnedDispatcher(resolvedIps, true, responseTimeoutMs);
|
||||
const dispatcher = createPinnedDispatcher(usableIps, true, responseTimeoutMs);
|
||||
const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual', dispatcher } as any);
|
||||
|
||||
// Only a 3xx WITH a Location header is a redirect we follow; anything else
|
||||
@@ -414,8 +444,8 @@ export interface SafeFetchFollowOptions extends SafeFetchOptions {
|
||||
keepCredentialsOnRedirect?: boolean;
|
||||
/**
|
||||
* When true, private/internal IPs that ALLOW_INTERNAL_NETWORK would normally
|
||||
* permit are still blocked (matches `checkSsrf(url, true)`). Loopback and
|
||||
* link-local are always blocked regardless. Defaults to false.
|
||||
* permit are still blocked (matches `checkSsrf(url, true)`). Loopback,
|
||||
* link-local and cloud metadata are always blocked regardless. Defaults to false.
|
||||
*/
|
||||
bypassInternalIpAllowed?: boolean;
|
||||
}
|
||||
@@ -495,9 +525,10 @@ export async function safeFetchFollow(
|
||||
* addresses. This prevents DNS rebinding (TOCTOU) by ensuring the outbound
|
||||
* connection goes to an address we checked, not a re-resolved one.
|
||||
*
|
||||
* Given the whole checked list, in the order `resolveAll` put it, the socket
|
||||
* gets every address at once: Node asks for `all: true` and walks the list
|
||||
* itself, moving on after its attempt timeout when one family does not answer.
|
||||
* Given the vetted list, in the order `resolveAll` put it, the socket gets
|
||||
* every address on it at once and never one the guard left out: Node asks for
|
||||
* `all: true` and walks the list itself, moving on after its attempt timeout
|
||||
* when one family does not answer.
|
||||
* A caller that still names one address gets exactly the old behaviour.
|
||||
*/
|
||||
export function createPinnedDispatcher(
|
||||
|
||||
@@ -228,6 +228,27 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /summary/per-person reads a dollar bill on a euro trip at its booked rate (#2525)', async () => {
|
||||
const hotel = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Aparthotel Silver', currency: 'USD', exchange_rate: 1.17, payers: [{ user_id: 1, amount: 801.76 }], member_ids: [1, 2] });
|
||||
expect(hotel.status).toBe(201);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/api/trips/${tripId}/budget/summary/per-person`)
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
// 801.76 USD at 1.17 is 685.26 EUR, half of it each. The summary used to put
|
||||
// 400.88 on both and leave the reader to guess the currency.
|
||||
const row = (uid: number) => res.body.summary.find((r: { user_id: number }) => r.user_id === uid);
|
||||
expect(row(1)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
||||
expect(row(2)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
||||
|
||||
const del = await request(server).delete(`/api/trips/${tripId}/budget/${hotel.body.item.id}`).set('Cookie', sessionCookie(1));
|
||||
expect(del.status).toBe(200);
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission, persisting the new amount and day', async () => {
|
||||
const created = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget/settlements`)
|
||||
@@ -273,4 +294,160 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Settlement not found' });
|
||||
});
|
||||
|
||||
// The VND/AUD report. The rates override above answers null for every base, which is
|
||||
// exactly that server: it never reached the rates provider, so the only rates there
|
||||
// are the ones the browser lends.
|
||||
describe('rows no rate can convert', () => {
|
||||
/** A fresh AUD trip the owner shares with user 2, so these cases never meet the ledger above. */
|
||||
function audTrip(): number {
|
||||
const id = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'AUD Trip', 'AUD')").run().lastInsertRowid);
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, 2)').run(id);
|
||||
canAccessTrip.mockReturnValue({ id, user_id: 1, currency: 'AUD' });
|
||||
return id;
|
||||
}
|
||||
const balanceOf = (body: { balances: { user_id: number; balance: number }[] }, uid: number) =>
|
||||
body.balances.find(b => b.user_id === uid)!.balance;
|
||||
|
||||
it('VND/AUD regression: unfrozen VND bill is listed as unconverted, freeze-rates heals it from the browser quote, settlement nets 489.00, second freeze heals nothing', async () => {
|
||||
const trip = audTrip();
|
||||
const bill = await request(server)
|
||||
.post(`/api/trips/${trip}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Pho', currency: 'VND', payers: [{ user_id: 1, amount: 8920000 }], member_ids: [1, 2] });
|
||||
expect(bill.status).toBe(201);
|
||||
expect(bill.body.item.exchange_rate).toBe(1);
|
||||
|
||||
// Left out whole before anything is written: no balance in VND's order of magnitude.
|
||||
const before = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1));
|
||||
expect(before.status).toBe(200);
|
||||
expect(before.body.currency).toBe('AUD');
|
||||
expect(before.body.unconverted).toEqual({ item_ids: [bill.body.item.id], settlement_ids: [], currencies: ['VND'] });
|
||||
expect(before.body.balances).toEqual([]);
|
||||
|
||||
const fallback_fx = { base: 'AUD', rates: { VND: 18241.3, EUR: 0.61 } };
|
||||
const healed = await request(server)
|
||||
.post(`/api/trips/${trip}/budget/freeze-rates`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ fallback_fx });
|
||||
expect(healed.status).toBe(200);
|
||||
expect(healed.body.items.map((i: { id: number; exchange_rate: number }) => [i.id, i.exchange_rate])).toEqual([[bill.body.item.id, 18241.3]]);
|
||||
expect(healed.body).toMatchObject({ settlements: [], unresolved: [] });
|
||||
|
||||
const after = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1));
|
||||
expect(after.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
|
||||
// 8,920,000 VND at 18,241.3 per dollar is 489.00 AUD, half of it user 2's.
|
||||
expect(after.body.finalBudgets.find((f: { user_id: number }) => f.user_id === 1).expenses).toBe(489);
|
||||
expect(balanceOf(after.body, 1)).toBe(244.5);
|
||||
expect(balanceOf(after.body, 2)).toBe(-244.5);
|
||||
|
||||
const again = await request(server)
|
||||
.post(`/api/trips/${trip}/budget/freeze-rates`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ fallback_fx: { base: 'AUD', rates: { VND: 25000 } } });
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body).toEqual({ items: [], settlements: [], unresolved: [] });
|
||||
expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(18241.3);
|
||||
});
|
||||
|
||||
it('POST with fallback_fx freezes at entry', async () => {
|
||||
const trip = audTrip();
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${trip}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item.exchange_rate).toBe(18241.3);
|
||||
expect(res.body.item).not.toHaveProperty('fallback_fx');
|
||||
});
|
||||
|
||||
it('POST /settlements in EUR with fallback_fx freezes the transfer', async () => {
|
||||
const trip = audTrip();
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${trip}/budget/settlements`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ from_user_id: 2, to_user_id: 1, amount: 30.5, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.settlement).toMatchObject({ currency: 'EUR', exchange_rate: 0.61 });
|
||||
|
||||
// Read back in euros at the same browser quote, the transfer is what was typed.
|
||||
const s = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1));
|
||||
expect(s.body.currency).toBe('EUR');
|
||||
expect(s.body.unconverted.settlement_ids).toEqual([]);
|
||||
expect(balanceOf(s.body, 2)).toBe(30.5);
|
||||
expect(balanceOf(s.body, 1)).toBe(-30.5);
|
||||
});
|
||||
|
||||
it('GET /settlement?base=EUR answers in AUD without base_rate and in EUR with it', async () => {
|
||||
const trip = audTrip();
|
||||
const dinner = await request(server)
|
||||
.post(`/api/trips/${trip}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Dinner', payers: [{ user_id: 1, amount: 100 }], member_ids: [1, 2] });
|
||||
expect(dinner.status).toBe(201);
|
||||
|
||||
const plain = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR`).set('Cookie', sessionCookie(1));
|
||||
expect(plain.status).toBe(200);
|
||||
// Trip dollars, labelled as such rather than printed as euros.
|
||||
expect(plain.body.currency).toBe('AUD');
|
||||
expect(balanceOf(plain.body, 2)).toBe(-50);
|
||||
|
||||
const quoted = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1));
|
||||
expect(quoted.status).toBe(200);
|
||||
expect(quoted.body.currency).toBe('EUR');
|
||||
expect(balanceOf(quoted.body, 2)).toBe(-30.5);
|
||||
});
|
||||
|
||||
it('PUT currency change to VND without a rate stores 1 instead of the USD rate', async () => {
|
||||
const trip = audTrip();
|
||||
const created = await request(server)
|
||||
.post(`/api/trips/${trip}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 });
|
||||
expect(created.body.item.exchange_rate).toBe(0.65);
|
||||
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/${trip}/budget/${created.body.item.id}`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ currency: 'VND' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item).toMatchObject({ currency: 'VND', exchange_rate: 1 });
|
||||
expect(db.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(created.body.item.id))
|
||||
.toEqual({ currency: 'VND', exchange_rate: 1 });
|
||||
});
|
||||
|
||||
it('freeze-rates 403 without budget_edit, 400 on malformed fallback_fx, other base heals nothing (unresolved VND)', async () => {
|
||||
const trip = audTrip();
|
||||
const bill = await request(server)
|
||||
.post(`/api/trips/${trip}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Pho', currency: 'VND', total_price: 100000 });
|
||||
expect(bill.status).toBe(201);
|
||||
const freeze = (body: object) => request(server).post(`/api/trips/${trip}/budget/freeze-rates`).set('Cookie', sessionCookie(1)).send(body);
|
||||
|
||||
checkPermission.mockReturnValue(false);
|
||||
const denied = await freeze({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } });
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body).toEqual({ error: 'No permission' });
|
||||
checkPermission.mockReturnValue(true);
|
||||
|
||||
for (const fallback_fx of [{ base: 'aud', rates: { VND: 18241.3 } }, { base: 'AUD', rates: { VND: 0 } }, { base: 'AUD', rates: {} }]) {
|
||||
const malformed = await freeze({ fallback_fx });
|
||||
expect(malformed.status).toBe(400);
|
||||
expect(malformed.body.error).toContain('fallback_fx');
|
||||
}
|
||||
|
||||
// A table against euros would freeze the bill against the wrong currency.
|
||||
const otherBase = await freeze({ fallback_fx: { base: 'EUR', rates: { VND: 27000 } } });
|
||||
expect(otherBase.status).toBe(200);
|
||||
expect(otherBase.body).toEqual({ items: [], settlements: [], unresolved: ['VND'] });
|
||||
expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(1);
|
||||
});
|
||||
|
||||
it('400 on base_rate=abc', async () => {
|
||||
const res = await request(server).get(`/api/trips/${tripId}/budget/settlement?base=EUR&base_rate=abc`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('base_rate');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Mailer e2e (#2507): the message a real POST /api/auth/forgot-password puts on
|
||||
* the wire, built by the real AuthModule, the real MailerService and nodemailer's
|
||||
* real MIME composer against a temp SQLite db. Only the socket is gone:
|
||||
* createTransport hands back nodemailer's stream transport, which produces the
|
||||
* exact bytes an SMTP relay would receive.
|
||||
*
|
||||
* The header logo used to be an SVG data: URI inside the HTML. Gmail strips
|
||||
* those, Outlook blocks them, and neither renders SVG, so the header showed a
|
||||
* broken image. These cases pin the replacement: a PNG part inside the message,
|
||||
* next to the HTML in multipart/related, that the HTML addresses by Content-ID.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec('PRAGMA foreign_keys = ON');
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => undefined,
|
||||
isOwner: () => false,
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
||||
vi.mock('../../src/app-config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/app-config')>();
|
||||
return { ...actual, getAppUrl: () => 'https://trek.example' };
|
||||
});
|
||||
|
||||
const { wire } = vi.hoisted(() => ({ wire: [] as string[] }));
|
||||
vi.mock('nodemailer', async (importOriginal) => {
|
||||
const real = (await importOriginal<{ default: typeof import('nodemailer') }>()).default;
|
||||
return {
|
||||
default: {
|
||||
...real,
|
||||
createTransport: () => {
|
||||
const stream = real.createTransport({ streamTransport: true, buffer: true, newline: 'windows' });
|
||||
return {
|
||||
async sendMail(mail: Parameters<typeof stream.sendMail>[0]) {
|
||||
const info = await stream.sendMail(mail);
|
||||
wire.push(info.message.toString());
|
||||
return info;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { AuthModule } from '../../src/nest/auth/auth.module';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
interface MimePart {
|
||||
headers: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** The leaf parts of a raw message, in order, with their header block and raw body. */
|
||||
function leafParts(raw: string): MimePart[] {
|
||||
const split = raw.indexOf('\r\n\r\n');
|
||||
const headers = raw.slice(0, split);
|
||||
const body = raw.slice(split + 4);
|
||||
const boundary = /boundary="([^"]+)"/i.exec(headers.replace(/\r\n\s+/g, ' '))?.[1];
|
||||
if (!/^content-type:\s*multipart\//im.test(headers) || !boundary) return [{ headers, body }];
|
||||
return body
|
||||
.split(`--${boundary}`)
|
||||
.slice(1, -1)
|
||||
.flatMap(chunk => leafParts(chunk.replace(/^\r\n/, '')));
|
||||
}
|
||||
|
||||
/** The decoded text of a text/* part, honouring its transfer encoding. */
|
||||
function partText(part: MimePart): string {
|
||||
if (!/^Content-Transfer-Encoding: quoted-printable/im.test(part.headers)) return part.body;
|
||||
const bytes = part.body
|
||||
.replace(/=\r\n/g, '')
|
||||
.replace(/=([0-9A-F]{2})/g, (_m, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
return Buffer.from(bytes, 'latin1').toString('utf8');
|
||||
}
|
||||
|
||||
describe('Mailer e2e: the header logo in a mail on the wire (#2507)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let email: string;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(db as never);
|
||||
runMigrations(db as never);
|
||||
email = createUser(db as never, { username: 'mail-e2e', email: 'mail-e2e@example.test' }).user.email;
|
||||
const setting = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
setting.run('smtp_host', 'mail.internal.example');
|
||||
setting.run('smtp_port', '587');
|
||||
setting.run('smtp_from', 'trek@example.test');
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
|
||||
const res = await request(server).post('/api/auth/forgot-password').send({ email });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('sends exactly one message, the HTML next to the logo in multipart/related', () => {
|
||||
expect(wire).toHaveLength(1);
|
||||
const raw = wire[0];
|
||||
expect(raw).toMatch(/^Content-Type: multipart\/alternative;/m);
|
||||
expect(raw).toMatch(/^Content-Type: multipart\/related; type="text\/html";/m);
|
||||
});
|
||||
|
||||
it('the HTML addresses the logo by Content-ID and carries no data: URI', () => {
|
||||
const parts = leafParts(wire[0]);
|
||||
const htmlPart = parts.find(p => /^Content-Type: text\/html/im.test(p.headers))!;
|
||||
const html = partText(htmlPart);
|
||||
expect(html).not.toContain('data:');
|
||||
|
||||
const cid = /<img src="cid:([^"]+)"/.exec(html)?.[1];
|
||||
expect(cid).toBeTruthy();
|
||||
|
||||
const logo = parts.find(p => p.headers.includes(`Content-ID: <${cid}>`));
|
||||
expect(logo, `no MIME part with Content-ID <${cid}>`).toBeDefined();
|
||||
expect(logo!.headers).toMatch(/^Content-Type: image\/png/im);
|
||||
expect(logo!.headers).toMatch(/^Content-Disposition: inline/im);
|
||||
expect(logo!.headers).toMatch(/^Content-Transfer-Encoding: base64/im);
|
||||
const png = Buffer.from(logo!.body.replace(/\s+/g, ''), 'base64');
|
||||
expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
|
||||
});
|
||||
|
||||
it('the plain-text alternative stays first and untouched', () => {
|
||||
const parts = leafParts(wire[0]);
|
||||
expect(parts[0].headers).toMatch(/^Content-Type: text\/plain/im);
|
||||
expect(partText(parts[0])).toContain('https://trek.example/reset-password?token=');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ let checkPermission: MockInstance;
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
|
||||
|
||||
describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => {
|
||||
let server: Server;
|
||||
@@ -67,6 +68,10 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
|
||||
.overrideProvider(NotificationsService)
|
||||
.useValue({ send: notificationSend })
|
||||
// A price quoted in a foreign currency freezes the rate of the day; this is that
|
||||
// day's table, so no case ever reaches the network.
|
||||
.overrideProvider(ExchangeRatesService)
|
||||
.useValue({ getRates: async (base: string) => (base.toUpperCase() === 'EUR' ? { EUR: 1, USD: 1.17 } : null) })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
@@ -133,6 +138,31 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 create keeps an imported price in the currency it was quoted in, at a frozen rate (#2525)', async () => {
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({
|
||||
title: 'Aparthotel Silver', type: 'hotel',
|
||||
metadata: { price: '801.76', priceCurrency: 'USD' },
|
||||
create_budget_entry: { total_price: 801.76, category: 'accommodation', currency: 'USD' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
// It used to land as 801.76 with no currency, which is 801.76 of the trip's euros.
|
||||
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
||||
expect(item).toEqual({ total_price: 801.76, currency: 'USD', exchange_rate: 1.17 });
|
||||
});
|
||||
|
||||
it('201 create leaves a price without a currency in the trip currency, as before', async () => {
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ title: 'Museum', type: 'event', create_budget_entry: { total_price: 20, currency: 'not a code' } });
|
||||
expect(res.status).toBe(201);
|
||||
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
||||
expect(item).toEqual({ total_price: 20, currency: null, exchange_rate: 1 });
|
||||
});
|
||||
|
||||
it('201 create reservation (real insert + booking notification), 400 without title', async () => {
|
||||
const ok = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* SPA fallback e2e (#2524). Boots a Nest app with the production static layer and
|
||||
* the two global filters wired the way AppModule and bootstrap wire them, then asks
|
||||
* over HTTP for a page, for a build file that exists, and for build files an
|
||||
* update removed.
|
||||
*
|
||||
* The last case is the bug: a tab or service worker still on the previous release
|
||||
* asks for its chunks, and those requests used to be answered with index.html and a
|
||||
* 200, which a precache then kept as the chunk. They must be a plain 404.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import type { Server } from 'http';
|
||||
import { Module, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { SpaFallbackFilter } from '../../src/nest/platform/spa-fallback.filter';
|
||||
import { applyPlatformStatic, PUBLIC_DIR } from '../../src/nest/platform/platform.routes';
|
||||
|
||||
@Module({})
|
||||
class NoRoutesModule {}
|
||||
|
||||
const PRESENT_CHUNK = 'assets/e2e-2524-present-Q1w2E3r4.js';
|
||||
const created: string[] = [];
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
/** Writes a file below the served public dir unless the build already put one there. */
|
||||
function ensurePublicFile(rel: string, content: string): void {
|
||||
const file = path.join(PUBLIC_DIR, rel);
|
||||
if (fs.existsSync(file)) return;
|
||||
const dir = path.dirname(file);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
createdDirs.push(dir);
|
||||
}
|
||||
fs.writeFileSync(file, content);
|
||||
created.push(file);
|
||||
}
|
||||
|
||||
describe('SPA fallback e2e: pages get index.html, missing build files a 404 (#2524)', () => {
|
||||
const previousEnv = process.env.NODE_ENV;
|
||||
let app: INestApplication;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
ensurePublicFile('index.html', '<!DOCTYPE html><html><body><div id="root"></div></body></html>');
|
||||
ensurePublicFile(PRESENT_CHUNK, 'export default 1;\n');
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const moduleRef = await Test.createTestingModule({ imports: [NoRoutesModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
// Before init, like bootstrap.ts: a real file is answered ahead of Nest's router.
|
||||
applyPlatformStatic(app.getHttpAdapter().getInstance());
|
||||
// Same pair and order as the APP_FILTER providers in AppModule.
|
||||
app.useGlobalFilters(new TrekExceptionFilter(), new SpaFallbackFilter());
|
||||
await app.init();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
for (const file of created) fs.rmSync(file, { force: true });
|
||||
for (const dir of createdDirs.reverse()) fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (previousEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = previousEnv;
|
||||
});
|
||||
|
||||
it('answers a page of the app with index.html', async () => {
|
||||
const res = await request(server).get('/dashboard');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
|
||||
expect(res.text.toLowerCase()).toContain('<html');
|
||||
});
|
||||
|
||||
it('serves a build file that exists', async () => {
|
||||
const res = await request(server).get(`/${PRESENT_CHUNK}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('javascript');
|
||||
});
|
||||
|
||||
it('answers a chunk of the previous release with a 404, not index.html', async () => {
|
||||
const res = await request(server).get('/assets/DashboardPage-v4ODOTOr.js');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
|
||||
});
|
||||
|
||||
// The whole answer is asserted, not just the status: a checkout under a dot
|
||||
// directory (~/.local) made the old fallback fail with a 404 of its own, which
|
||||
// a status check alone would count as the fix.
|
||||
it.each([
|
||||
['a cache-busted retry of that chunk', '/assets/DashboardPage-v4ODOTOr.js?t=1790358416100'],
|
||||
['its stylesheet', '/assets/DashboardPage-v4ODOTOr.css'],
|
||||
['a top-level build file', '/manifest-e2e-2524.webmanifest'],
|
||||
])('does the same for %s', async (_case, url) => {
|
||||
const res = await request(server).get(url);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.body).toEqual({ error: `Cannot GET ${url}` });
|
||||
});
|
||||
|
||||
it('keeps the JSON envelope for a non-GET miss', async () => {
|
||||
const res = await request(server).post('/dashboard').send({});
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Cannot POST /dashboard' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* A real JPEG carrying the capture tags a phone writes, built byte by byte so
|
||||
* no actual person's photo has to live in the repo.
|
||||
*
|
||||
* The image data is tests/fixtures/small-image.jpg (a decodable 1x1 JPEG); its
|
||||
* JFIF APP0 is swapped for an Exif APP1 the way a camera lays the file out:
|
||||
* SOI, APP1 "Exif", then the rest. Every value is written as the raw tag string
|
||||
* or rational, so a test can also hand in a malformed one.
|
||||
*/
|
||||
export interface ExifJpegTags {
|
||||
/** 0x9003, e.g. '2026:05:30 15:52:19'. */
|
||||
DateTimeOriginal?: string;
|
||||
/** 0x9004 (DateTimeDigitized). */
|
||||
CreateDate?: string;
|
||||
/** 0x9010, the offset of the IFD0 DateTime. */
|
||||
OffsetTime?: string;
|
||||
/** 0x9011, the offset of DateTimeOriginal. */
|
||||
OffsetTimeOriginal?: string;
|
||||
/** 0x9012, the offset of DateTimeDigitized. */
|
||||
OffsetTimeDigitized?: string;
|
||||
/** Decimal degrees, stored as the usual degree/minute/second rationals plus N/S and E/W. */
|
||||
gps?: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
const BASE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
|
||||
const BYTE = 1;
|
||||
const ASCII = 2;
|
||||
const LONG = 4;
|
||||
const RATIONAL = 5;
|
||||
|
||||
interface Entry { tag: number; type: number; count: number; data: Buffer }
|
||||
|
||||
function ascii(tag: number, value: string): Entry {
|
||||
const data = Buffer.from(`${value}\0`, 'latin1');
|
||||
return { tag, type: ASCII, count: data.length, data };
|
||||
}
|
||||
|
||||
function long(tag: number, value: number): Entry {
|
||||
const data = Buffer.alloc(4);
|
||||
data.writeUInt32LE(value);
|
||||
return { tag, type: LONG, count: 1, data };
|
||||
}
|
||||
|
||||
function rationals(tag: number, pairs: Array<[number, number]>): Entry {
|
||||
const data = Buffer.alloc(pairs.length * 8);
|
||||
pairs.forEach(([num, den], i) => {
|
||||
data.writeUInt32LE(num, i * 8);
|
||||
data.writeUInt32LE(den, i * 8 + 4);
|
||||
});
|
||||
return { tag, type: RATIONAL, count: pairs.length, data };
|
||||
}
|
||||
|
||||
function dms(deg: number): Array<[number, number]> {
|
||||
const abs = Math.abs(deg);
|
||||
const d = Math.floor(abs);
|
||||
const m = Math.floor((abs - d) * 60);
|
||||
const s = Math.round(((abs - d) * 60 - m) * 60 * 10000);
|
||||
return [[d, 1], [m, 1], [s, 10000]];
|
||||
}
|
||||
|
||||
function ifdSize(entries: Entry[]): number {
|
||||
const outOfLine = entries.reduce((sum, e) => sum + (e.data.length > 4 ? e.data.length + (e.data.length % 2) : 0), 0);
|
||||
return 2 + entries.length * 12 + 4 + outOfLine;
|
||||
}
|
||||
|
||||
/** One IFD at TIFF offset `at`, entries sorted by tag as the spec wants. */
|
||||
function writeIfd(entries: Entry[], at: number): Buffer {
|
||||
const sorted = [...entries].sort((a, b) => a.tag - b.tag);
|
||||
const head = Buffer.alloc(2 + sorted.length * 12 + 4);
|
||||
const tail: Buffer[] = [];
|
||||
let dataAt = at + head.length;
|
||||
head.writeUInt16LE(sorted.length, 0);
|
||||
sorted.forEach((e, i) => {
|
||||
const o = 2 + i * 12;
|
||||
head.writeUInt16LE(e.tag, o);
|
||||
head.writeUInt16LE(e.type, o + 2);
|
||||
head.writeUInt32LE(e.count, o + 4);
|
||||
if (e.data.length <= 4) {
|
||||
e.data.copy(head, o + 8);
|
||||
} else {
|
||||
head.writeUInt32LE(dataAt, o + 8);
|
||||
const padded = e.data.length % 2 ? Buffer.concat([e.data, Buffer.alloc(1)]) : e.data;
|
||||
tail.push(padded);
|
||||
dataAt += padded.length;
|
||||
}
|
||||
});
|
||||
head.writeUInt32LE(0, head.length - 4);
|
||||
return Buffer.concat([head, ...tail]);
|
||||
}
|
||||
|
||||
export function jpegWithExif(tags: ExifJpegTags): Buffer {
|
||||
const exifEntries: Entry[] = [];
|
||||
if (tags.DateTimeOriginal !== undefined) exifEntries.push(ascii(0x9003, tags.DateTimeOriginal));
|
||||
if (tags.CreateDate !== undefined) exifEntries.push(ascii(0x9004, tags.CreateDate));
|
||||
if (tags.OffsetTime !== undefined) exifEntries.push(ascii(0x9010, tags.OffsetTime));
|
||||
if (tags.OffsetTimeOriginal !== undefined) exifEntries.push(ascii(0x9011, tags.OffsetTimeOriginal));
|
||||
if (tags.OffsetTimeDigitized !== undefined) exifEntries.push(ascii(0x9012, tags.OffsetTimeDigitized));
|
||||
|
||||
const gpsEntries: Entry[] = [];
|
||||
if (tags.gps) {
|
||||
gpsEntries.push({ tag: 0x0000, type: BYTE, count: 4, data: Buffer.from([2, 2, 0, 0]) });
|
||||
gpsEntries.push(ascii(0x0001, tags.gps.lat < 0 ? 'S' : 'N'));
|
||||
gpsEntries.push(rationals(0x0002, dms(tags.gps.lat)));
|
||||
gpsEntries.push(ascii(0x0003, tags.gps.lng < 0 ? 'W' : 'E'));
|
||||
gpsEntries.push(rationals(0x0004, dms(tags.gps.lng)));
|
||||
}
|
||||
|
||||
// IFD0 holds only the two pointers, so its size is known before the offsets are.
|
||||
const ifd0Count = (exifEntries.length ? 1 : 0) + (gpsEntries.length ? 1 : 0);
|
||||
const ifd0At = 8;
|
||||
const exifAt = ifd0At + 2 + ifd0Count * 12 + 4;
|
||||
const gpsAt = exifAt + (exifEntries.length ? ifdSize(exifEntries) : 0);
|
||||
const ifd0Entries: Entry[] = [];
|
||||
if (exifEntries.length) ifd0Entries.push(long(0x8769, exifAt));
|
||||
if (gpsEntries.length) ifd0Entries.push(long(0x8825, gpsAt));
|
||||
|
||||
const tiffHeader = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
|
||||
const tiff = Buffer.concat([
|
||||
tiffHeader,
|
||||
writeIfd(ifd0Entries, ifd0At),
|
||||
exifEntries.length ? writeIfd(exifEntries, exifAt) : Buffer.alloc(0),
|
||||
gpsEntries.length ? writeIfd(gpsEntries, gpsAt) : Buffer.alloc(0),
|
||||
]);
|
||||
const payload = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]);
|
||||
const app1 = Buffer.alloc(4);
|
||||
app1.writeUInt16BE(0xffe1, 0);
|
||||
app1.writeUInt16BE(payload.length + 2, 2);
|
||||
|
||||
const base = fs.readFileSync(BASE_JPEG);
|
||||
// SOI, then skip the JFIF APP0 (marker + its big-endian length).
|
||||
const app0Length = base.readUInt16BE(4);
|
||||
const rest = base.subarray(4 + app0Length);
|
||||
return Buffer.concat([base.subarray(0, 2), app1, payload, rest]);
|
||||
}
|
||||
@@ -536,3 +536,73 @@ describe('Regions geo', () => {
|
||||
expect(res.body).toHaveProperty('type', 'FeatureCollection');
|
||||
});
|
||||
});
|
||||
|
||||
describe('A place that moves takes its Atlas country with it (#2527)', () => {
|
||||
// The region cache is filled by a background task that GET /regions only starts,
|
||||
// so wait for the row the way the next Atlas load would find it.
|
||||
async function regionRowOf(placeId: number): Promise<{ country_code: string; region_code: string } | undefined> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const row = testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(placeId) as
|
||||
| { country_code: string; region_code: string }
|
||||
| undefined;
|
||||
if (row) return row;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function atlasCountries(userId: number): Promise<string[]> {
|
||||
const res = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(userId));
|
||||
expect(res.status).toBe(200);
|
||||
return (res.body.countries as { code: string }[]).map((c) => c.code);
|
||||
}
|
||||
|
||||
it('ATLAS-015: correcting a place from France to Germany moves it on the Atlas', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Wrongly imported', start_date: '2025-05-01', end_date: '2025-05-05' });
|
||||
const place = testDb
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id')
|
||||
.get(trip.id, 'Hotel', 48.8566, 2.3522, 'Rue de Rivoli, Paris, France') as { id: number };
|
||||
|
||||
// The place is seen on the Atlas once, which caches France for it.
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
expect((await regionRowOf(place.id))?.country_code).toBe('FR');
|
||||
expect(await atlasCountries(user.id)).toEqual(['FR']);
|
||||
|
||||
// The user fixes the location in the trip.
|
||||
const put = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ lat: 52.5163, lng: 13.3777, address: 'Pariser Platz, Berlin, Germany' });
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
expect(await atlasCountries(user.id)).toEqual(['DE']);
|
||||
|
||||
// And the regions follow on the next load, with nothing left in France.
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
expect((await regionRowOf(place.id))?.country_code).toBe('DE');
|
||||
const regions = await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
expect(Object.keys(regions.body.regions)).toEqual(['DE']);
|
||||
expect(await atlasCountries(user.id)).toEqual(['DE']);
|
||||
});
|
||||
|
||||
it('ATLAS-015: an edit that leaves the location alone keeps the cached country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris', start_date: '2025-05-01', end_date: '2025-05-05' });
|
||||
const place = testDb
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id')
|
||||
.get(trip.id, 'Louvre', 48.8606, 2.3376, 'Rue de Rivoli, Paris, France') as { id: number };
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
const cached = await regionRowOf(place.id);
|
||||
expect(cached?.country_code).toBe('FR');
|
||||
|
||||
// The place editor sends the whole form back, location included, for a notes edit.
|
||||
const put = await request(app)
|
||||
.put(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'Closed on Tuesdays', lat: 48.8606, lng: 2.3376, address: 'Rue de Rivoli, Paris, France' });
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
expect(testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(place.id)).toEqual(cached);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,6 +394,9 @@ describe('Budget summary and settlement', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.balances).toEqual([]);
|
||||
expect(res.body.flows).toEqual([]);
|
||||
// Additive since the VND/AUD report: what the amounts are in, and what was left out.
|
||||
expect(res.body.currency).toBe('EUR');
|
||||
expect(res.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Journey API integration tests.
|
||||
* Covers JOURNEY-INT-001 through JOURNEY-INT-020.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
addJourneyContributor,
|
||||
} from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { jpegWithExif } from '../helpers/exif-jpeg';
|
||||
import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
@@ -1223,4 +1224,90 @@ describe('Journey upload parity', () => {
|
||||
const diskName = res.body.cover_image.replace(/^journey\//, '');
|
||||
expect(fsMod.existsSync(pathMod.join(journeyDir, diskName))).toBe(true);
|
||||
});
|
||||
|
||||
describe('capture metadata read from the uploaded file (#2512)', () => {
|
||||
// A server whose zone is neither UTC nor the photographer's: the setup the
|
||||
// report came from, and the only one where a stamp read in the wrong zone shows.
|
||||
let prevTz: string | undefined;
|
||||
beforeEach(() => {
|
||||
prevTz = process.env.TZ;
|
||||
process.env.TZ = 'America/Chicago';
|
||||
});
|
||||
afterEach(() => {
|
||||
if (prevTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = prevTz;
|
||||
});
|
||||
|
||||
const phonePhoto = () => jpegWithExif({
|
||||
DateTimeOriginal: '2026:05:30 15:52:19',
|
||||
CreateDate: '2026:05:30 15:52:19',
|
||||
OffsetTime: '+02:00',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
OffsetTimeDigitized: '+02:00',
|
||||
gps: { lat: 49.274523, lng: -0.703421 },
|
||||
});
|
||||
|
||||
type CaptureRow = { taken_at: string | null; lat: number | null; lng: number | null };
|
||||
// The backfill runs detached after the response, so wait for it to land.
|
||||
async function captureOf(filePath: string): Promise<CaptureRow> {
|
||||
let row: CaptureRow | undefined;
|
||||
await vi.waitFor(() => {
|
||||
row = testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE file_path = ?').get(filePath) as CaptureRow;
|
||||
expect(row?.taken_at).toBeTruthy();
|
||||
});
|
||||
return row!;
|
||||
}
|
||||
|
||||
it('JOURNEY-P10: an entry photo stores the instant from DateTimeOriginal plus its offset, and its GPS', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-05-30' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await captureOf(res.body.photos[0].file_path);
|
||||
// Not 2026-05-30T20:52:19.000Z, which is the stamp read as Chicago time.
|
||||
expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(row.lat).toBeCloseTo(49.274523, 5);
|
||||
expect(row.lng).toBeCloseTo(-0.703421, 5);
|
||||
});
|
||||
|
||||
it('JOURNEY-P11: a gallery photo gets the same', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/gallery/photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await captureOf(res.body.photos[0].file_path);
|
||||
expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z');
|
||||
expect(row.lat).toBeCloseTo(49.274523, 5);
|
||||
expect(row.lng).toBeCloseTo(-0.703421, 5);
|
||||
});
|
||||
|
||||
it('JOURNEY-P12: zeros in the GPS block, as a receiver without a fix writes them, put no pin at 0,0', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/gallery/photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('photos', jpegWithExif({
|
||||
DateTimeOriginal: '2026:05:30 15:52:19',
|
||||
OffsetTimeOriginal: '+02:00',
|
||||
gps: { lat: 0, lng: 0 },
|
||||
}), { filename: 'nofix.jpg', contentType: 'image/jpeg' });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await captureOf(res.body.photos[0].file_path);
|
||||
expect(row).toEqual({ taken_at: '2026-05-30T13:52:19.000Z', lat: null, lng: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* The whole OIDC login against a real provider on this machine (#2506).
|
||||
*
|
||||
* oidc.test.ts stubs the four provider calls to test the flow around them. This
|
||||
* file stubs nothing but the resolver: the provider is a small HTTP server on
|
||||
* loopback that answers discovery, authorize, token, JWKS (with a real RS256
|
||||
* signature) and userinfo, so every request the login makes goes through the
|
||||
* SSRF guard to a socket.
|
||||
*
|
||||
* Its name resolves the way a LAN DNS server answers for a self-hosted IdP: an
|
||||
* fe80:: record next to the reachable address. Since 4.3.0 that one record
|
||||
* refused the name, and every login ended in `{"error":"OIDC login failed"}`.
|
||||
* Loopback stands in for the LAN address, being the one address a test can
|
||||
* reach; the admin lane OIDC rides on treats the two alike.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock, answers } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => undefined,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock, answers: new Map<string, { address: string; family: number }[]>() };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
SESSION_DURATION_REMEMBER: '30d',
|
||||
SESSION_DURATION_REMEMBER_MS: 2592000000,
|
||||
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
// The provider's names are answered here; every other name goes to the real resolver.
|
||||
vi.mock('dns/promises', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('dns/promises')>();
|
||||
const lookup = ((hostname: string, options?: { all?: boolean }) => {
|
||||
const answer = answers.get(hostname);
|
||||
if (!answer) return real.lookup(hostname, options as never);
|
||||
return Promise.resolve(options?.all ? answer : answer[0]);
|
||||
}) as typeof real.lookup;
|
||||
return { ...real, default: { ...real, lookup }, lookup };
|
||||
});
|
||||
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
|
||||
const CLIENT_ID = 'trek-local';
|
||||
const CLIENT_SECRET = 'local-secret';
|
||||
const EMAIL = 'lan-admin@home.test';
|
||||
|
||||
/** Read one cookie's value out of a response's Set-Cookie header, as a browser would. */
|
||||
function readCookie(res: request.Response, name: string): string | undefined {
|
||||
const raw = res.headers['set-cookie'];
|
||||
const all: string[] = Array.isArray(raw) ? raw : raw ? [raw as unknown as string] : [];
|
||||
const value = all.filter((c) => c.startsWith(`${name}=`)).pop()?.split(';')[0].slice(name.length + 1);
|
||||
return value ? decodeURIComponent(value) : undefined;
|
||||
}
|
||||
|
||||
// ── The provider ────────────────────────────────────────────────────────────
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', alg: 'RS256', use: 'sig' };
|
||||
const codes = new Set<string>();
|
||||
/** Every request the provider saw: method, path and the address the client dialled. */
|
||||
const seen: { method: string; path: string; local: string }[] = [];
|
||||
let provider: http.Server;
|
||||
let port = 0;
|
||||
|
||||
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
function signIdToken(issuer: string): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const signingInput = `${b64({ alg: 'RS256', kid: 'k1', typ: 'JWT' })}.${b64({
|
||||
iss: issuer, sub: 'lan-sub-1', aud: CLIENT_ID, iat: now, exp: now + 300, email: EMAIL, email_verified: true,
|
||||
})}`;
|
||||
return `${signingInput}.${crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function answerJson(res: http.ServerResponse, status: number, body: unknown) {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function handle(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
// The Host header carries the name TREK asked for, so one server plays every issuer.
|
||||
const issuer = `http://${req.headers.host}`;
|
||||
const url = new URL(req.url ?? '/', issuer);
|
||||
seen.push({ method: req.method ?? '', path: url.pathname, local: req.socket.localAddress ?? '' });
|
||||
if (url.pathname === '/.well-known/openid-configuration') {
|
||||
return answerJson(res, 200, {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
userinfo_endpoint: `${issuer}/userinfo`,
|
||||
jwks_uri: `${issuer}/jwks`,
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/authorize') {
|
||||
const code = crypto.randomBytes(12).toString('hex');
|
||||
codes.add(code);
|
||||
const back = new URL(url.searchParams.get('redirect_uri') ?? '');
|
||||
back.searchParams.set('code', code);
|
||||
back.searchParams.set('state', url.searchParams.get('state') ?? '');
|
||||
res.writeHead(302, { location: back.toString() });
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
let raw = '';
|
||||
req.on('data', (chunk) => (raw += chunk));
|
||||
req.on('end', () => {
|
||||
const form = new URLSearchParams(raw);
|
||||
const code = form.get('code') ?? '';
|
||||
if (!codes.delete(code) || form.get('client_secret') !== CLIENT_SECRET || !form.get('code_verifier')) {
|
||||
return answerJson(res, 400, { error: 'invalid_grant' });
|
||||
}
|
||||
answerJson(res, 200, { access_token: 'lan-access', token_type: 'Bearer', id_token: signIdToken(issuer) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/userinfo') {
|
||||
if (req.headers.authorization !== 'Bearer lan-access') return answerJson(res, 401, { error: 'invalid_token' });
|
||||
return answerJson(res, 200, { sub: 'lan-sub-1', email: EMAIL, email_verified: true, name: 'LAN Admin' });
|
||||
}
|
||||
if (url.pathname === '/jwks') return answerJson(res, 200, { keys: [jwk] });
|
||||
answerJson(res, 404, { error: 'not_found' });
|
||||
}
|
||||
|
||||
// ── The app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(async () => {
|
||||
provider = http.createServer(handle);
|
||||
await new Promise<void>((resolve) => provider.listen(0, '127.0.0.1', resolve));
|
||||
port = (provider.address() as AddressInfo).port;
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
resetRateLimits(nestApp);
|
||||
seen.length = 0;
|
||||
answers.clear();
|
||||
process.env.OIDC_CLIENT_ID = CLIENT_ID;
|
||||
process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET;
|
||||
process.env.APP_URL = 'http://localhost:3001';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
delete process.env.APP_URL;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
await new Promise((resolve) => provider.close(resolve));
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
/** Point TREK at the provider under `host`, answering the name with `answer`. */
|
||||
function useIssuer(host: string, answer: { address: string; family: number }[]): string {
|
||||
answers.set(host, answer);
|
||||
const issuer = `http://${host}:${port}`;
|
||||
process.env.OIDC_ISSUER = issuer;
|
||||
return issuer;
|
||||
}
|
||||
|
||||
describe('OIDC login against a provider whose name has an fe80:: record (#2506)', () => {
|
||||
it('OIDC-LAN-001: signs in end to end, and every provider request lands on the vetted address', async () => {
|
||||
const { user } = createUser(testDb, { email: EMAIL });
|
||||
// Link-local first, the order Windows and some LAN resolvers hand it out in.
|
||||
const issuer = useIssuer('idp.home.test', [
|
||||
{ address: 'fe80::1', family: 6 },
|
||||
{ address: 'fe80::f9f1:353:73:14a0', family: 6 },
|
||||
{ address: '127.0.0.1', family: 4 },
|
||||
]);
|
||||
|
||||
const login = await request(app).get('/api/auth/oidc/login');
|
||||
expect(login.status).toBe(302);
|
||||
const authorize = new URL(login.headers.location!);
|
||||
expect(authorize.origin).toBe(issuer);
|
||||
const state = readCookie(login, 'trek_oidc_state')!;
|
||||
expect(authorize.searchParams.get('state')).toBe(state);
|
||||
|
||||
// The browser's leg: the provider sends it back to /callback with a code.
|
||||
const back = await fetch(`http://127.0.0.1:${port}${authorize.pathname}${authorize.search}`, { redirect: 'manual' });
|
||||
const callbackUrl = new URL(back.headers.get('location')!);
|
||||
const cb = await request(app)
|
||||
.get(`${callbackUrl.pathname}${callbackUrl.search}`)
|
||||
.set('Cookie', `trek_oidc_state=${state}`);
|
||||
expect(cb.status).toBe(302);
|
||||
const landing = new URL(cb.headers.location!, 'http://localhost');
|
||||
expect(landing.searchParams.get('oidc_error')).toBeNull();
|
||||
const oidcCode = landing.searchParams.get('oidc_code')!;
|
||||
expect(oidcCode).toBeTruthy();
|
||||
|
||||
const exchange = await request(app)
|
||||
.get(`/api/auth/oidc/exchange?code=${oidcCode}`)
|
||||
.set('Cookie', `trek_oidc_exchange=${readCookie(cb, 'trek_oidc_exchange')}`);
|
||||
expect(exchange.status).toBe(200);
|
||||
const session = readCookie(exchange, 'trek_session');
|
||||
expect(session).toBeTruthy();
|
||||
|
||||
const me = await request(app).get('/api/auth/me').set('Cookie', `trek_session=${session}`);
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.user).toMatchObject({ id: user.id, email: EMAIL });
|
||||
|
||||
// Discovery, token, JWKS and userinfo all went out, each over loopback.
|
||||
const server = seen.filter((r) => r.path !== '/authorize');
|
||||
expect(server.map((r) => `${r.method} ${r.path}`)).toEqual([
|
||||
'GET /.well-known/openid-configuration',
|
||||
'POST /token',
|
||||
'GET /jwks',
|
||||
'GET /userinfo',
|
||||
]);
|
||||
expect(new Set(server.map((r) => r.local))).toEqual(new Set(['127.0.0.1']));
|
||||
});
|
||||
|
||||
it('OIDC-LAN-002: a name with nothing but link-local and metadata records is still refused', async () => {
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
useIssuer('metadata.home.test', [
|
||||
{ address: 'fe80::1', family: 6 },
|
||||
{ address: '169.254.169.254', family: 4 },
|
||||
{ address: 'fd00:ec2::254', family: 6 },
|
||||
]);
|
||||
|
||||
const login = await request(app).get('/api/auth/oidc/login');
|
||||
|
||||
expect(login.status).toBe(500);
|
||||
expect(login.body).toEqual({ error: 'OIDC login failed' });
|
||||
expect(errors).toHaveBeenCalledWith(
|
||||
'[OIDC] Login error:',
|
||||
'Requests to link-local / cloud-metadata addresses are not allowed',
|
||||
);
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user