@blueprint-ts/core 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { installRouteInjection } from '../../../../src/vue/router/routeResourceBinding/installRouteInjection'
3
+
4
+ /**
5
+ * Flush all pending microtasks / promises.
6
+ */
7
+ function flushPromises() {
8
+ return new Promise<void>(resolve => setTimeout(resolve, 0))
9
+ }
10
+
11
+ /**
12
+ * Minimal stub for vue-router's Router – we only need `beforeResolve`.
13
+ */
14
+ function createMockRouter() {
15
+ const guards: Array<(to: any) => Promise<void>> = []
16
+
17
+ return {
18
+ beforeResolve(guard: (to: any) => Promise<void>) {
19
+ guards.push(guard)
20
+ },
21
+ async simulateNavigation(to: any) {
22
+ for (const guard of guards) {
23
+ await guard(to)
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ function createRoute(params: Record<string, string>, matched: any[]) {
30
+ return {
31
+ params,
32
+ matched,
33
+ meta: {} as Record<string, any>
34
+ }
35
+ }
36
+
37
+ describe('installRouteInjection', () => {
38
+ let mockRouter: ReturnType<typeof createMockRouter>
39
+
40
+ beforeEach(() => {
41
+ mockRouter = createMockRouter()
42
+ })
43
+
44
+ it('resolves injected props and stores them in _injectedProps', async () => {
45
+ installRouteInjection(mockRouter as any)
46
+
47
+ const resolveFn = vi.fn().mockResolvedValue('resolved-invoice')
48
+
49
+ const to = createRoute({ invoiceId: '123' }, [
50
+ {
51
+ path: ':invoiceId',
52
+ meta: {
53
+ inject: {
54
+ invoice: {
55
+ from: 'invoiceId',
56
+ resolve: () => ({ resolve: resolveFn })
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ])
62
+
63
+ await mockRouter.simulateNavigation(to)
64
+ await flushPromises()
65
+
66
+ expect(resolveFn).toHaveBeenCalledTimes(1)
67
+ expect(to.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
68
+ })
69
+
70
+ it('reuses cached value when navigating to a child route with the same param', async () => {
71
+ installRouteInjection(mockRouter as any)
72
+
73
+ const resolveFn = vi.fn().mockResolvedValue('resolved-invoice')
74
+ const parentRecord = {
75
+ path: ':invoiceId',
76
+ meta: {
77
+ inject: {
78
+ invoice: {
79
+ from: 'invoiceId',
80
+ resolve: () => ({ resolve: resolveFn })
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // First navigation: parent route
87
+ const to1 = createRoute({ invoiceId: '123' }, [parentRecord])
88
+ await mockRouter.simulateNavigation(to1)
89
+ await flushPromises()
90
+
91
+ expect(resolveFn).toHaveBeenCalledTimes(1)
92
+ expect(to1.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
93
+
94
+ // Second navigation: parent + child route, same invoiceId
95
+ const to2 = createRoute({ invoiceId: '123' }, [
96
+ parentRecord,
97
+ { path: 'details', meta: {} }
98
+ ])
99
+ await mockRouter.simulateNavigation(to2)
100
+ await flushPromises()
101
+
102
+ // resolve should NOT be called again – cached value reused
103
+ expect(resolveFn).toHaveBeenCalledTimes(1)
104
+ expect(to2.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
105
+ })
106
+
107
+ it('re-resolves when param value changes', async () => {
108
+ installRouteInjection(mockRouter as any)
109
+
110
+ const resolveFn = vi.fn()
111
+ .mockResolvedValueOnce('invoice-A')
112
+ .mockResolvedValueOnce('invoice-B')
113
+
114
+ const makeParent = () => ({
115
+ path: ':invoiceId',
116
+ meta: {
117
+ inject: {
118
+ invoice: {
119
+ from: 'invoiceId',
120
+ resolve: () => ({ resolve: resolveFn })
121
+ }
122
+ }
123
+ }
124
+ })
125
+
126
+ // Navigate with invoiceId=1
127
+ const to1 = createRoute({ invoiceId: '1' }, [makeParent()])
128
+ await mockRouter.simulateNavigation(to1)
129
+ await flushPromises()
130
+
131
+ expect(resolveFn).toHaveBeenCalledTimes(1)
132
+ expect(to1.meta._injectedProps).toEqual({ invoice: 'invoice-A' })
133
+
134
+ // Navigate with invoiceId=2 – must re-resolve
135
+ const to2 = createRoute({ invoiceId: '2' }, [makeParent()])
136
+ await mockRouter.simulateNavigation(to2)
137
+ await flushPromises()
138
+
139
+ expect(resolveFn).toHaveBeenCalledTimes(2)
140
+ expect(to2.meta._injectedProps).toEqual({ invoice: 'invoice-B' })
141
+ })
142
+
143
+ it('refresh() bypasses cache and fetches fresh data', async () => {
144
+ installRouteInjection(mockRouter as any)
145
+
146
+ const resolveFn = vi.fn()
147
+ .mockResolvedValueOnce('old-data')
148
+ .mockResolvedValueOnce('fresh-data')
149
+
150
+ const to = createRoute({ invoiceId: '123' }, [
151
+ {
152
+ path: ':invoiceId',
153
+ meta: {
154
+ inject: {
155
+ invoice: {
156
+ from: 'invoiceId',
157
+ resolve: () => ({ resolve: resolveFn })
158
+ }
159
+ }
160
+ }
161
+ }
162
+ ])
163
+
164
+ await mockRouter.simulateNavigation(to)
165
+ await flushPromises()
166
+ expect(to.meta._injectedProps).toEqual({ invoice: 'old-data' })
167
+
168
+ // Manually refresh
169
+ await to.meta.refresh!('invoice')
170
+ expect(resolveFn).toHaveBeenCalledTimes(2)
171
+ expect(to.meta._injectedProps).toEqual({ invoice: 'fresh-data' })
172
+ })
173
+
174
+ it('evicts cache entries when navigating away from a route', async () => {
175
+ installRouteInjection(mockRouter as any)
176
+
177
+ const invoiceResolve = vi.fn().mockResolvedValue('invoice-data')
178
+ const productResolve = vi.fn().mockResolvedValue('product-data')
179
+
180
+ // Navigate to invoice route
181
+ const to1 = createRoute({ invoiceId: '1' }, [
182
+ {
183
+ path: ':invoiceId',
184
+ meta: {
185
+ inject: {
186
+ invoice: {
187
+ from: 'invoiceId',
188
+ resolve: () => ({ resolve: invoiceResolve })
189
+ }
190
+ }
191
+ }
192
+ }
193
+ ])
194
+ await mockRouter.simulateNavigation(to1)
195
+ await flushPromises()
196
+ expect(invoiceResolve).toHaveBeenCalledTimes(1)
197
+
198
+ // Navigate to a completely different route (product)
199
+ const to2 = createRoute({ productId: '5' }, [
200
+ {
201
+ path: ':productId',
202
+ meta: {
203
+ inject: {
204
+ product: {
205
+ from: 'productId',
206
+ resolve: () => ({ resolve: productResolve })
207
+ }
208
+ }
209
+ }
210
+ }
211
+ ])
212
+ await mockRouter.simulateNavigation(to2)
213
+ await flushPromises()
214
+
215
+ // Now navigate back to invoice with the same id – should re-resolve (cache was evicted)
216
+ const to3 = createRoute({ invoiceId: '1' }, [
217
+ {
218
+ path: ':invoiceId',
219
+ meta: {
220
+ inject: {
221
+ invoice: {
222
+ from: 'invoiceId',
223
+ resolve: () => ({ resolve: invoiceResolve })
224
+ }
225
+ }
226
+ }
227
+ }
228
+ ])
229
+ await mockRouter.simulateNavigation(to3)
230
+ await flushPromises()
231
+ expect(invoiceResolve).toHaveBeenCalledTimes(2)
232
+ })
233
+
234
+ it('child routes inherit parent injected props without re-resolving across multiple children', async () => {
235
+ installRouteInjection(mockRouter as any)
236
+
237
+ const resolveFn = vi.fn().mockResolvedValue('invoice-data')
238
+ const parentRecord = {
239
+ path: ':invoiceId',
240
+ meta: {
241
+ inject: {
242
+ invoice: {
243
+ from: 'invoiceId',
244
+ resolve: () => ({ resolve: resolveFn })
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ // Navigate to parent
251
+ await mockRouter.simulateNavigation(createRoute({ invoiceId: '42' }, [parentRecord]))
252
+ await flushPromises()
253
+ expect(resolveFn).toHaveBeenCalledTimes(1)
254
+
255
+ // Navigate to child A
256
+ const toChildA = createRoute({ invoiceId: '42' }, [parentRecord, { path: 'edit', meta: {} }])
257
+ await mockRouter.simulateNavigation(toChildA)
258
+ await flushPromises()
259
+ expect(resolveFn).toHaveBeenCalledTimes(1)
260
+ expect(toChildA.meta._injectedProps).toEqual({ invoice: 'invoice-data' })
261
+
262
+ // Navigate to child B
263
+ const toChildB = createRoute({ invoiceId: '42' }, [parentRecord, { path: 'payments', meta: {} }])
264
+ await mockRouter.simulateNavigation(toChildB)
265
+ await flushPromises()
266
+ expect(resolveFn).toHaveBeenCalledTimes(1)
267
+ expect(toChildB.meta._injectedProps).toEqual({ invoice: 'invoice-data' })
268
+ })
269
+
270
+ it('sets loading state to true while resolving and false after', async () => {
271
+ installRouteInjection(mockRouter as any)
272
+
273
+ let resolvePromise: (value: string) => void
274
+ const resolveFn = vi.fn().mockImplementation(() => {
275
+ return new Promise<string>((resolve) => {
276
+ resolvePromise = resolve
277
+ })
278
+ })
279
+
280
+ const to = createRoute({ invoiceId: '123' }, [
281
+ {
282
+ path: ':invoiceId',
283
+ meta: {
284
+ inject: {
285
+ invoice: {
286
+ from: 'invoiceId',
287
+ resolve: () => ({ resolve: resolveFn })
288
+ }
289
+ }
290
+ }
291
+ }
292
+ ])
293
+
294
+ await mockRouter.simulateNavigation(to)
295
+
296
+ // Loading should be true while the resolver is pending
297
+ const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
298
+ expect(state.invoice.loading).toBe(true)
299
+ expect(state.invoice.error).toBeNull()
300
+
301
+ // Resolve the promise
302
+ resolvePromise!('resolved-invoice')
303
+ await flushPromises()
304
+
305
+ // Loading should be false after resolution
306
+ expect(state.invoice.loading).toBe(false)
307
+ expect(state.invoice.error).toBeNull()
308
+ expect(to.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
309
+ })
310
+
311
+ it('sets error state when resolver fails', async () => {
312
+ installRouteInjection(mockRouter as any)
313
+
314
+ const resolveFn = vi.fn().mockRejectedValue(new Error('Network error'))
315
+
316
+ const to = createRoute({ invoiceId: '123' }, [
317
+ {
318
+ path: ':invoiceId',
319
+ meta: {
320
+ inject: {
321
+ invoice: {
322
+ from: 'invoiceId',
323
+ resolve: () => ({ resolve: resolveFn })
324
+ }
325
+ }
326
+ }
327
+ }
328
+ ])
329
+
330
+ await mockRouter.simulateNavigation(to)
331
+ await flushPromises()
332
+
333
+ const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
334
+ expect(state.invoice.loading).toBe(false)
335
+ expect(state.invoice.error).toBeInstanceOf(Error)
336
+ expect(state.invoice.error!.message).toBe('Network error')
337
+ })
338
+
339
+ it('navigation is non-blocking (guard returns before resolvers complete)', async () => {
340
+ installRouteInjection(mockRouter as any)
341
+
342
+ let resolvePromise: (value: string) => void
343
+ const resolveFn = vi.fn().mockImplementation(() => {
344
+ return new Promise<string>((resolve) => {
345
+ resolvePromise = resolve
346
+ })
347
+ })
348
+
349
+ const to = createRoute({ invoiceId: '123' }, [
350
+ {
351
+ path: ':invoiceId',
352
+ meta: {
353
+ inject: {
354
+ invoice: {
355
+ from: 'invoiceId',
356
+ resolve: () => ({ resolve: resolveFn })
357
+ }
358
+ }
359
+ }
360
+ }
361
+ ])
362
+
363
+ // simulateNavigation should return even though the resolver hasn't completed
364
+ await mockRouter.simulateNavigation(to)
365
+
366
+ // Props should NOT be set yet (resolver still pending)
367
+ expect((to.meta._injectedProps as any).invoice).toBeUndefined()
368
+
369
+ // Now resolve
370
+ resolvePromise!('resolved-invoice')
371
+ await flushPromises()
372
+
373
+ expect(to.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
374
+ })
375
+
376
+ it('refresh() with silent option does not set loading state', async () => {
377
+ installRouteInjection(mockRouter as any)
378
+
379
+ const resolveFn = vi.fn()
380
+ .mockResolvedValueOnce('old-data')
381
+ .mockResolvedValueOnce('fresh-data')
382
+
383
+ const to = createRoute({ invoiceId: '123' }, [
384
+ {
385
+ path: ':invoiceId',
386
+ meta: {
387
+ inject: {
388
+ invoice: {
389
+ from: 'invoiceId',
390
+ resolve: () => ({ resolve: resolveFn })
391
+ }
392
+ }
393
+ }
394
+ }
395
+ ])
396
+
397
+ await mockRouter.simulateNavigation(to)
398
+ await flushPromises()
399
+ expect(to.meta._injectedProps).toEqual({ invoice: 'old-data' })
400
+
401
+ const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
402
+
403
+ // Track loading state changes during silent refresh
404
+ let loadingWasTrue = false
405
+ const checkLoading = setInterval(() => {
406
+ if (state.invoice.loading) loadingWasTrue = true
407
+ }, 0)
408
+
409
+ await to.meta.refresh!('invoice', { silent: true })
410
+
411
+ clearInterval(checkLoading)
412
+ expect(loadingWasTrue).toBe(false)
413
+ expect(state.invoice.loading).toBe(false)
414
+ expect(to.meta._injectedProps).toEqual({ invoice: 'fresh-data' })
415
+ })
416
+
417
+ it('refresh() resets loading and error state', async () => {
418
+ installRouteInjection(mockRouter as any)
419
+
420
+ const resolveFn = vi.fn()
421
+ .mockRejectedValueOnce(new Error('First fail'))
422
+ .mockResolvedValueOnce('success-data')
423
+
424
+ const to = createRoute({ invoiceId: '123' }, [
425
+ {
426
+ path: ':invoiceId',
427
+ meta: {
428
+ inject: {
429
+ invoice: {
430
+ from: 'invoiceId',
431
+ resolve: () => ({ resolve: resolveFn })
432
+ }
433
+ }
434
+ }
435
+ }
436
+ ])
437
+
438
+ await mockRouter.simulateNavigation(to)
439
+ await flushPromises()
440
+
441
+ const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
442
+ expect(state.invoice.error).toBeInstanceOf(Error)
443
+
444
+ // Refresh should clear the error and resolve successfully
445
+ await to.meta.refresh!('invoice')
446
+ expect(state.invoice.loading).toBe(false)
447
+ expect(state.invoice.error).toBeNull()
448
+ expect(to.meta._injectedProps).toEqual({ invoice: 'success-data' })
449
+ })
450
+ })