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