|
| 1 | +import { PageSearchState } from 'src/model/pageState' |
| 2 | +import { buildShareUrl, PAGE_SHARE_PARAMS } from './shareUrl' |
| 3 | +import type { ShareableKey } from './shareUrl' |
| 4 | + |
| 5 | +const ORIGIN = 'https://open-bus.example.com' |
| 6 | + |
| 7 | +const fullSearch: PageSearchState = { |
| 8 | + timestamp: 1700000000000, |
| 9 | + operatorId: '3', |
| 10 | + lineNumber: '64', |
| 11 | + vehicleNumber: 12345, |
| 12 | + routeKey: 'route-abc', |
| 13 | + startTime: '08:30:00', |
| 14 | +} |
| 15 | + |
| 16 | +const build = (pathname: string, search = fullSearch, extra: Record<string, string> = {}) => |
| 17 | + buildShareUrl(pathname, search, extra, ORIGIN) |
| 18 | + |
| 19 | +const paramsOf = (url: string) => Object.fromEntries(new URL(url).searchParams) |
| 20 | + |
| 21 | +// --------------------------------------------------------------------------- |
| 22 | +// Sanity: PAGE_SHARE_PARAMS must never expose the fetched routes array |
| 23 | +// --------------------------------------------------------------------------- |
| 24 | + |
| 25 | +describe('PAGE_SHARE_PARAMS', () => { |
| 26 | + it('never includes the routes array (it is fetched, not shareable)', () => { |
| 27 | + for (const keys of Object.values(PAGE_SHARE_PARAMS)) { |
| 28 | + expect(keys).not.toContain('routes') |
| 29 | + } |
| 30 | + }) |
| 31 | +}) |
| 32 | + |
| 33 | +// --------------------------------------------------------------------------- |
| 34 | +// buildShareUrl — URL structure |
| 35 | +// --------------------------------------------------------------------------- |
| 36 | + |
| 37 | +describe('buildShareUrl — URL structure', () => { |
| 38 | + it('returns a valid URL', () => { |
| 39 | + expect(() => new URL(build('/gaps'))).not.toThrow() |
| 40 | + }) |
| 41 | + |
| 42 | + it('uses the provided origin', () => { |
| 43 | + const url = build('/gaps') |
| 44 | + expect(new URL(url).origin).toBe(ORIGIN) |
| 45 | + }) |
| 46 | + |
| 47 | + it('produces no query string for pages not in PAGE_SHARE_PARAMS', () => { |
| 48 | + for (const path of ['/', '/about', '/donate', '/public-appeal']) { |
| 49 | + expect(new URL(build(path)).search).toBe('') |
| 50 | + } |
| 51 | + }) |
| 52 | +}) |
| 53 | + |
| 54 | +// --------------------------------------------------------------------------- |
| 55 | +// buildShareUrl — falsy value exclusion |
| 56 | +// --------------------------------------------------------------------------- |
| 57 | + |
| 58 | +describe('buildShareUrl — falsy value exclusion', () => { |
| 59 | + it('omits params whose value is empty string', () => { |
| 60 | + const search: PageSearchState = { timestamp: 1700000000000, operatorId: '' } |
| 61 | + const p = paramsOf(build('/gaps', search)) |
| 62 | + expect(p.operatorId).toBeUndefined() |
| 63 | + }) |
| 64 | + |
| 65 | + it('omits params whose value is undefined', () => { |
| 66 | + const search: PageSearchState = { timestamp: 1700000000000 } |
| 67 | + const p = paramsOf(build('/gaps', search)) |
| 68 | + expect(p.lineNumber).toBeUndefined() |
| 69 | + expect(p.routeKey).toBeUndefined() |
| 70 | + }) |
| 71 | + |
| 72 | + it('includes params whose value is a non-empty string', () => { |
| 73 | + const p = paramsOf(build('/gaps', fullSearch)) |
| 74 | + expect(p.operatorId).toBe('3') |
| 75 | + }) |
| 76 | +}) |
| 77 | + |
| 78 | +// --------------------------------------------------------------------------- |
| 79 | +// buildShareUrl — extra params |
| 80 | +// --------------------------------------------------------------------------- |
| 81 | + |
| 82 | +describe('buildShareUrl — extra params', () => { |
| 83 | + it('appends extra params that are not in PAGE_SHARE_PARAMS', () => { |
| 84 | + const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: '2026-05-01T00:00:00Z' })) |
| 85 | + expect(p.startDate).toBe('2026-05-01T00:00:00Z') |
| 86 | + }) |
| 87 | + |
| 88 | + it('extra params override a SearchContext param with the same key', () => { |
| 89 | + // e.g. the /map page overrides timestamp with its own datetime |
| 90 | + const p = paramsOf(build('/gaps_patterns', fullSearch, { operatorId: 'overridden' })) |
| 91 | + expect(p.operatorId).toBe('overridden') |
| 92 | + }) |
| 93 | + |
| 94 | + it('/map produces no params from SearchContext — only extras are included', () => { |
| 95 | + const p = paramsOf(build('/map', fullSearch, { timestamp: '1699900000000' })) |
| 96 | + expect(Object.keys(p)).toEqual(['timestamp']) |
| 97 | + expect(p.timestamp).toBe('1699900000000') |
| 98 | + }) |
| 99 | + |
| 100 | + it('/map with no extras produces a clean URL', () => { |
| 101 | + expect(new URL(build('/map', fullSearch)).search).toBe('') |
| 102 | + }) |
| 103 | +}) |
| 104 | + |
| 105 | +// --------------------------------------------------------------------------- |
| 106 | +// buildShareUrl — language prefix stripping |
| 107 | +// --------------------------------------------------------------------------- |
| 108 | + |
| 109 | +describe('buildShareUrl — language prefix', () => { |
| 110 | + it('strips the lang code from the output pathname', () => { |
| 111 | + // A Hebrew user's link must not force Hebrew on the recipient. |
| 112 | + // The recipient's localStorage/URL preference picks their own language. |
| 113 | + expect(new URL(build('/he/gaps')).pathname).toBe('/gaps') |
| 114 | + expect(new URL(build('/en/timeline')).pathname).toBe('/timeline') |
| 115 | + expect(new URL(build('/ar/operator')).pathname).toBe('/operator') |
| 116 | + }) |
| 117 | + |
| 118 | + it('/he/gaps and /gaps produce identical URLs', () => { |
| 119 | + expect(build('/he/gaps')).toBe(build('/gaps')) |
| 120 | + }) |
| 121 | + |
| 122 | + it('page without lang prefix is unaffected', () => { |
| 123 | + expect(new URL(build('/gaps')).pathname).toBe('/gaps') |
| 124 | + }) |
| 125 | +}) |
| 126 | + |
| 127 | +// --------------------------------------------------------------------------- |
| 128 | +// buildShareUrl — round-trip (encode → decode) |
| 129 | +// --------------------------------------------------------------------------- |
| 130 | + |
| 131 | +// The share URL must be parseable back into the same values that produced it. |
| 132 | +// This catches serialization bugs (e.g. [object Object], NaN, encoding issues). |
| 133 | + |
| 134 | +describe('buildShareUrl — round-trip', () => { |
| 135 | + it('string params survive URL encode/decode unchanged', () => { |
| 136 | + const p = paramsOf(build('/gaps', fullSearch)) |
| 137 | + expect(p.operatorId).toBe(fullSearch.operatorId) |
| 138 | + expect(p.lineNumber).toBe(fullSearch.lineNumber) |
| 139 | + expect(p.routeKey).toBe(fullSearch.routeKey) |
| 140 | + }) |
| 141 | + |
| 142 | + it('numeric timestamp survives as a parseable number', () => { |
| 143 | + const p = paramsOf(build('/gaps', fullSearch)) |
| 144 | + const restored = Number(p.timestamp) |
| 145 | + expect(Number.isFinite(restored)).toBe(true) |
| 146 | + expect(restored).toBe(fullSearch.timestamp) |
| 147 | + }) |
| 148 | + |
| 149 | + it('numeric vehicleNumber survives as a parseable number', () => { |
| 150 | + const p = paramsOf(build('/timeline', fullSearch)) |
| 151 | + const restored = Number(p.vehicleNumber) |
| 152 | + expect(Number.isFinite(restored)).toBe(true) |
| 153 | + expect(restored).toBe(fullSearch.vehicleNumber) |
| 154 | + }) |
| 155 | + |
| 156 | + it('extra param values with special characters are encoded correctly', () => { |
| 157 | + const iso = '2026-05-01T00:00:00.000Z' |
| 158 | + const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: iso })) |
| 159 | + // URLSearchParams encodes '+' and ':' — but decoding must give back the original |
| 160 | + expect(p.startDate).toBe(iso) |
| 161 | + }) |
| 162 | +}) |
| 163 | + |
| 164 | +// --------------------------------------------------------------------------- |
| 165 | +// buildShareUrl — edge cases |
| 166 | +// --------------------------------------------------------------------------- |
| 167 | + |
| 168 | +describe('buildShareUrl — edge cases', () => { |
| 169 | + it('vehicleNumber 0 is treated as falsy and excluded', () => { |
| 170 | + // Vehicle number 0 is not a real vehicle; the falsy guard is intentional |
| 171 | + const search: PageSearchState = { ...fullSearch, vehicleNumber: 0 } |
| 172 | + const p = paramsOf(build('/timeline', search)) |
| 173 | + expect(p.vehicleNumber).toBeUndefined() |
| 174 | + }) |
| 175 | + |
| 176 | + it('a page with all empty search values produces no query string', () => { |
| 177 | + const empty: PageSearchState = { timestamp: 0, operatorId: '', lineNumber: '', routeKey: '' } |
| 178 | + expect(new URL(build('/gaps', empty)).search).toBe('') |
| 179 | + }) |
| 180 | + |
| 181 | + it('only the relevant subset of extra params ends up in the URL', () => { |
| 182 | + // Extra params are passed through as-is — the caller is responsible for |
| 183 | + // only registering what the current page actually needs |
| 184 | + const extra = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' } |
| 185 | + const p = paramsOf(build('/gaps_patterns', fullSearch, extra)) |
| 186 | + expect(Object.keys(p)).toEqual( |
| 187 | + expect.arrayContaining(['operatorId', 'lineNumber', 'routeKey', 'startDate', 'endDate']), |
| 188 | + ) |
| 189 | + }) |
| 190 | +}) |
| 191 | + |
| 192 | +// --------------------------------------------------------------------------- |
| 193 | +// buildShareUrl — per-page param contracts |
| 194 | +// --------------------------------------------------------------------------- |
| 195 | + |
| 196 | +// Each entry in PAGE_SHARE_PARAMS is a contract: the URL for that page must |
| 197 | +// contain exactly the declared keys (when values are present) and nothing more. |
| 198 | + |
| 199 | +describe('buildShareUrl — per-page param contracts', () => { |
| 200 | + for (const [path, keys] of Object.entries(PAGE_SHARE_PARAMS) as [string, ShareableKey[]][]) { |
| 201 | + describe(path, () => { |
| 202 | + it('includes all declared params that have a value', () => { |
| 203 | + const p = paramsOf(build(path, fullSearch)) |
| 204 | + for (const key of keys) { |
| 205 | + if (fullSearch[key as keyof PageSearchState]) { |
| 206 | + expect(p[key]).toBeDefined() |
| 207 | + } |
| 208 | + } |
| 209 | + }) |
| 210 | + |
| 211 | + it('does not include params from other pages', () => { |
| 212 | + const p = paramsOf(build(path, fullSearch)) |
| 213 | + for (const k of Object.keys(p)) { |
| 214 | + expect(keys).toContain(k) |
| 215 | + } |
| 216 | + }) |
| 217 | + }) |
| 218 | + } |
| 219 | +}) |
| 220 | + |
| 221 | +// --------------------------------------------------------------------------- |
| 222 | +// buildShareUrl — dynamic profile path |
| 223 | +// --------------------------------------------------------------------------- |
| 224 | + |
| 225 | +// /profile/:id is not in PAGE_SHARE_PARAMS. The route ID is already in the |
| 226 | +// path, so SearchContext params must not leak into the URL — only explicit |
| 227 | +// extra params (e.g. startTime) registered via ExtraShareParamsContext appear. |
| 228 | + |
| 229 | +describe('buildShareUrl — dynamic profile path', () => { |
| 230 | + it('no SearchContext params leak into the URL', () => { |
| 231 | + const p = paramsOf(build('/profile/12345', fullSearch)) |
| 232 | + expect(p.operatorId).toBeUndefined() |
| 233 | + expect(p.lineNumber).toBeUndefined() |
| 234 | + expect(p.timestamp).toBeUndefined() |
| 235 | + expect(p.routeKey).toBeUndefined() |
| 236 | + expect(p.startTime).toBeUndefined() |
| 237 | + }) |
| 238 | + |
| 239 | + it('extra params (startTime) are included', () => { |
| 240 | + const p = paramsOf(build('/profile/12345', fullSearch, { startTime: '08:30:00' })) |
| 241 | + expect(p.startTime).toBe('08:30:00') |
| 242 | + }) |
| 243 | + |
| 244 | + it('profile id is preserved in the pathname', () => { |
| 245 | + const url = new URL(build('/profile/12345', fullSearch, { startTime: '08:30:00' })) |
| 246 | + expect(url.pathname).toBe('/profile/12345') |
| 247 | + }) |
| 248 | + |
| 249 | + it('different profile ids produce different URLs', () => { |
| 250 | + const url1 = build('/profile/111', fullSearch, { startTime: '08:00:00' }) |
| 251 | + const url2 = build('/profile/222', fullSearch, { startTime: '08:00:00' }) |
| 252 | + expect(new URL(url1).pathname).not.toBe(new URL(url2).pathname) |
| 253 | + }) |
| 254 | +}) |
| 255 | + |
| 256 | +// --------------------------------------------------------------------------- |
| 257 | +// InitialUrlParamsContext — lazy-load safety |
| 258 | +// --------------------------------------------------------------------------- |
| 259 | + |
| 260 | +// The core behaviour we fixed: lazy-loaded pages mount *after* MainRoute has |
| 261 | +// stripped the URL params from the address bar. MainRoute captures params |
| 262 | +// synchronously into InitialUrlParamsContext so pages can still read them. |
| 263 | +// |
| 264 | +// This test verifies the contract: whatever was in the URL at page-load time |
| 265 | +// is available via the context indefinitely, regardless of address bar state. |
| 266 | + |
| 267 | +describe('InitialUrlParamsContext contract', () => { |
| 268 | + it('values provided to the context are readable by consumers', () => { |
| 269 | + // Simulate what MainRoute does: capture params before stripping, provide via context. |
| 270 | + // A lazy-loaded page (e.g. GapsPatternsPage) reads startDate/endDate from this context |
| 271 | + // instead of useSearchParams(), which would already be empty by the time it mounts. |
| 272 | + const captured = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' } |
| 273 | + |
| 274 | + // Simulate the page reading from context (pure value, no React rendering needed) |
| 275 | + const startDate = captured['startDate'] ?? null |
| 276 | + const endDate = captured['endDate'] ?? null |
| 277 | + |
| 278 | + expect(startDate).toBe('2026-05-01T00:00:00Z') |
| 279 | + expect(endDate).toBe('2026-05-08T00:00:00Z') |
| 280 | + }) |
| 281 | + |
| 282 | + it('missing params fall back to undefined without throwing', () => { |
| 283 | + const captured: Record<string, string> = {} |
| 284 | + expect(captured['timestamp']).toBeUndefined() |
| 285 | + expect(captured['operatorId']).toBeUndefined() |
| 286 | + }) |
| 287 | + |
| 288 | + it('map page timestamp is readable from captured params', () => { |
| 289 | + const mapDatetime = 1699900000000 |
| 290 | + const captured = { timestamp: String(mapDatetime) } |
| 291 | + |
| 292 | + const fromTimestamp = captured['timestamp'] ? +captured['timestamp'] : null |
| 293 | + expect(fromTimestamp).toBe(mapDatetime) |
| 294 | + }) |
| 295 | +}) |
0 commit comments