cozy-client 60.30.6 → 60.31.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,761 @@
1
+ import {
2
+ BANNERS_DOCTYPE,
3
+ BANNERS_QUERY_NAME,
4
+ getActiveBanner,
5
+ getActiveBanners,
6
+ byPriority,
7
+ dismiss,
8
+ getCta,
9
+ getSeverity,
10
+ getSurface,
11
+ getVisibleBanners,
12
+ isDismissed,
13
+ isInWindow,
14
+ isSupported,
15
+ isTrusted
16
+ } from './banner'
17
+ import vectors from './__fixtures__/io.cozy.banners.json'
18
+ import MockDate from 'mockdate'
19
+
20
+ /**
21
+ * The fixtures are the shared contract vectors published with the doctype in
22
+ * cozy-doctypes (fixtures/io.cozy.banners.json). Every client implementing
23
+ * io.cozy.banners runs the same cases, so the implementations are checked
24
+ * against one specification rather than against each other.
25
+ */
26
+ describe('io.cozy.banners contract vectors', () => {
27
+ const now = new Date(vectors.now)
28
+
29
+ it.each(vectors.cases.map(testCase => [testCase.name, testCase]))(
30
+ '%s',
31
+ (name, testCase) => {
32
+ const visible = getVisibleBanners(testCase.input, {
33
+ now,
34
+ supportedDoctypeVersion:
35
+ testCase.supportedDoctypeVersion ?? vectors.supportedDoctypeVersion
36
+ })
37
+
38
+ // No helper calls: getVisibleBanners returns banners ready to render,
39
+ // with every fallback already applied.
40
+ const rendered = visible.map(banner => ({
41
+ bannerId: banner.bannerId,
42
+ severity: banner.severity,
43
+ surface: banner.surface,
44
+ cta: banner.cta !== null
45
+ }))
46
+
47
+ expect(rendered).toEqual(testCase.expected)
48
+ }
49
+ )
50
+ })
51
+
52
+ describe('isTrusted', () => {
53
+ it('should reject a document written by an application', () => {
54
+ expect(isTrusted({ cozyMetadata: { createdByApp: 'drive' } })).toBe(false)
55
+ })
56
+
57
+ it('should reject a document with no metadata', () => {
58
+ expect(isTrusted({})).toBe(false)
59
+ expect(isTrusted(undefined)).toBe(false)
60
+ })
61
+ })
62
+
63
+ const stackBanner = attributes => ({
64
+ bannerId: 'quota.exceeded',
65
+ category: 'quota',
66
+ severity: 'info',
67
+ surface: 'banner',
68
+ text: 'Message.',
69
+ lang: 'en',
70
+ dismissible: true,
71
+ dismissedAt: null,
72
+ priority: 50,
73
+ startsAt: '2026-07-01T00:00:00Z',
74
+ endsAt: null,
75
+ cozyMetadata: {
76
+ createdByApp: 'stack',
77
+ doctypeVersion: 1,
78
+ metadataVersion: 1
79
+ },
80
+ ...attributes
81
+ })
82
+
83
+ const now = new Date('2026-07-22T12:00:00Z')
84
+ const idsOf = banners => banners.map(banner => banner.bannerId)
85
+
86
+ describe('getSurface', () => {
87
+ it('should keep every surface the doctype defines', () => {
88
+ expect(getSurface({ surface: 'banner' })).toBe('banner')
89
+ expect(getSurface({ surface: 'modal' })).toBe('modal')
90
+ })
91
+
92
+ it('should fall back for a surface the doctype no longer defines', () => {
93
+ expect(getSurface({ surface: 'inline' })).toBe('banner')
94
+ })
95
+ })
96
+
97
+ describe('getSeverity', () => {
98
+ it('should keep every severity the doctype defines', () => {
99
+ expect(getSeverity({ severity: 'info' })).toBe('info')
100
+ expect(getSeverity({ severity: 'warning' })).toBe('warning')
101
+ expect(getSeverity({ severity: 'error' })).toBe('error')
102
+ })
103
+ })
104
+
105
+ describe('getCta', () => {
106
+ it('should drop a call to action with no readable label', () => {
107
+ expect(getCta({ cta: { url: 'https://example.org' } })).toBe(null)
108
+ expect(getCta({ cta: { label: '', url: 'https://example.org' } })).toBe(
109
+ null
110
+ )
111
+ // Whitespace and a zero width space render as a button with nothing on it.
112
+ expect(getCta({ cta: { label: ' ', url: 'https://example.org' } })).toBe(
113
+ null
114
+ )
115
+ expect(
116
+ getCta({ cta: { label: '\u200B', url: 'https://example.org' } })
117
+ ).toBe(null)
118
+ })
119
+
120
+ it('should accept a scheme written in any case', () => {
121
+ const cta = { label: 'Upgrade', url: 'HTTPS://example.org' }
122
+ expect(getCta({ cta })).toBe(cta)
123
+ })
124
+ })
125
+
126
+ describe('isInWindow', () => {
127
+ it('should hide a banner whose window cannot be read', () => {
128
+ expect(isInWindow(stackBanner({ endsAt: 'not-a-date' }), now)).toBe(false)
129
+ expect(isInWindow(stackBanner({ startsAt: '2026-13-45' }), now)).toBe(false)
130
+ })
131
+
132
+ it('should hide a banner with no start bound rather than treat it as open', () => {
133
+ // startsAt is not one of the fields the doctype allows to be missing, so an
134
+ // absent one is an unreadable window. Treating it as "no lower bound" would
135
+ // make deleting the field enough to turn a scheduled banner into a
136
+ // permanent one, which anything able to rewrite the document could do.
137
+ expect(isInWindow(stackBanner({ startsAt: undefined }), now)).toBe(false)
138
+ expect(isInWindow(stackBanner({ startsAt: null }), now)).toBe(false)
139
+ expect(
140
+ idsOf(
141
+ getVisibleBanners(
142
+ [stackBanner({ startsAt: undefined, endsAt: null })],
143
+ {
144
+ now
145
+ }
146
+ )
147
+ )
148
+ ).toEqual([])
149
+ })
150
+
151
+ it('should default to the current time', () => {
152
+ MockDate.set('2026-07-22T12:00:00Z')
153
+ try {
154
+ expect(
155
+ isInWindow(stackBanner({ startsAt: '2026-08-01T00:00:00Z' }))
156
+ ).toBe(false)
157
+ expect(isInWindow(stackBanner())).toBe(true)
158
+ } finally {
159
+ MockDate.reset()
160
+ }
161
+ })
162
+ })
163
+
164
+ describe('isDismissed', () => {
165
+ it('should read an absent value as not dismissed', () => {
166
+ expect(isDismissed({ dismissedAt: null })).toBe(false)
167
+ expect(isDismissed({})).toBe(false)
168
+ expect(isDismissed(undefined)).toBe(false)
169
+ })
170
+ })
171
+
172
+ describe('isSupported', () => {
173
+ it('should default to the version this client was written against', () => {
174
+ expect(isSupported(stackBanner())).toBe(true)
175
+ })
176
+ })
177
+
178
+ describe('byPriority', () => {
179
+ it('should order equal priorities by code unit rather than by locale', () => {
180
+ const banners = [{ bannerId: 'a.x' }, { bannerId: 'B.x' }]
181
+ expect(idsOf([...banners].sort(byPriority))).toEqual(['B.x', 'a.x'])
182
+ })
183
+
184
+ it('should stay transitive when a priority is missing', () => {
185
+ const high = { bannerId: 'z.high', priority: 100 }
186
+ const none = { bannerId: 'm.none' }
187
+ const low = { bannerId: 'a.low', priority: 10 }
188
+
189
+ expect(byPriority(high, none)).toBeLessThan(0)
190
+ expect(byPriority(none, low)).toBeGreaterThan(0)
191
+ expect(byPriority(high, low)).toBeLessThan(0)
192
+ })
193
+ })
194
+
195
+ describe('getVisibleBanners', () => {
196
+ it('should not let an out of window version hide a live one', () => {
197
+ const live = stackBanner({ _id: 'v1' })
198
+ const future = stackBanner({
199
+ _id: 'v2',
200
+ startsAt: '2026-08-01T00:00:00Z',
201
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: 2 }
202
+ })
203
+
204
+ const visible = getVisibleBanners([live, future], {
205
+ now,
206
+ supportedDoctypeVersion: 2
207
+ })
208
+
209
+ expect(idsOf(visible)).toEqual(['quota.exceeded'])
210
+ })
211
+
212
+ it('should keep a bannerId hidden once any of its versions is dismissed', () => {
213
+ const dismissed = stackBanner({
214
+ _id: 'v1',
215
+ dismissedAt: '2026-07-20T00:00:00Z'
216
+ })
217
+ const upgraded = stackBanner({
218
+ _id: 'v2',
219
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: 2 }
220
+ })
221
+
222
+ const visible = getVisibleBanners([dismissed, upgraded], {
223
+ now,
224
+ supportedDoctypeVersion: 2
225
+ })
226
+
227
+ expect(visible).toEqual([])
228
+ })
229
+
230
+ it('should not depend on the order the documents came back in', () => {
231
+ const older = stackBanner({
232
+ _id: 'a',
233
+ priority: 10,
234
+ cozyMetadata: {
235
+ createdByApp: 'stack',
236
+ doctypeVersion: 1,
237
+ updatedAt: '2026-07-01T00:00:00Z'
238
+ }
239
+ })
240
+ const newer = stackBanner({
241
+ _id: 'b',
242
+ priority: 90,
243
+ cozyMetadata: {
244
+ createdByApp: 'stack',
245
+ doctypeVersion: 1,
246
+ updatedAt: '2026-07-10T00:00:00Z'
247
+ }
248
+ })
249
+
250
+ const one = getVisibleBanners([older, newer], { now })
251
+ const other = getVisibleBanners([newer, older], { now })
252
+
253
+ expect(one).toEqual(other)
254
+ expect(one[0]._id).toBe('b')
255
+ })
256
+
257
+ it('should keep documents that lost their bannerId apart', () => {
258
+ const first = stackBanner({ _id: 'a', bannerId: undefined })
259
+ const second = stackBanner({ _id: 'b', bannerId: undefined })
260
+
261
+ expect(getVisibleBanners([first, second], { now })).toHaveLength(2)
262
+ })
263
+
264
+ it('should ignore a dismissal recorded by anything but the stack', () => {
265
+ // An application holding write permission on the doctype could otherwise
266
+ // suppress a platform message by authoring a dismissed document of its own,
267
+ // without touching the one the stack wrote.
268
+ const live = stackBanner({ _id: 'stack' })
269
+ const forged = stackBanner({
270
+ _id: 'forged',
271
+ dismissedAt: '2026-07-20T00:00:00Z',
272
+ cozyMetadata: { createdByApp: 'drive', doctypeVersion: 1 }
273
+ })
274
+
275
+ expect(idsOf(getVisibleBanners([forged, live], { now }))).toEqual([
276
+ 'quota.exceeded'
277
+ ])
278
+ })
279
+
280
+ it('should not leave a blocking modal without a way out', () => {
281
+ // The doctype guarantees a non dismissible modal carries a call to action.
282
+ // Dropping an unusable one must not paint a dialog with no dismiss control
283
+ // and no button.
284
+ const trapped = stackBanner({
285
+ surface: 'modal',
286
+ dismissible: false,
287
+ cta: { label: 'Upgrade', url: 'http://example.org/plans' }
288
+ })
289
+
290
+ const [visible] = getVisibleBanners([trapped], { now })
291
+
292
+ expect(visible.cta).toBeNull()
293
+ expect(visible.surface).toBe('banner')
294
+ })
295
+
296
+ it('should keep a modal whose call to action survived', () => {
297
+ const usable = stackBanner({
298
+ surface: 'modal',
299
+ dismissible: false,
300
+ cta: { label: 'Upgrade', url: 'https://example.org/plans' }
301
+ })
302
+
303
+ expect(getVisibleBanners([usable], { now })[0].surface).toBe('modal')
304
+ })
305
+
306
+ it('should not let a version written as null lose to its older sibling', () => {
307
+ // Number(null) is a finite 0, so an unusable version must not be read as a
308
+ // real one: it would lose the version comparison outright and render the
309
+ // stale document.
310
+ const fresher = stackBanner({
311
+ _id: 'fresher',
312
+ surface: 'modal',
313
+ cozyMetadata: {
314
+ createdByApp: 'stack',
315
+ doctypeVersion: null,
316
+ updatedAt: '2026-07-20T00:00:00Z'
317
+ }
318
+ })
319
+ const stale = stackBanner({
320
+ _id: 'stale',
321
+ cozyMetadata: {
322
+ createdByApp: 'stack',
323
+ doctypeVersion: '1',
324
+ updatedAt: '2026-07-01T00:00:00Z'
325
+ }
326
+ })
327
+
328
+ expect(getVisibleBanners([fresher, stale], { now })[0]._id).toBe('fresher')
329
+ })
330
+
331
+ it('should fall back on the supported version when the option is unusable', () => {
332
+ // ?? would let NaN through, and every `version <= NaN` is false, so the
333
+ // whole feature would go dark with no error.
334
+ expect(
335
+ idsOf(
336
+ getVisibleBanners([stackBanner()], {
337
+ now,
338
+ supportedDoctypeVersion: Number('nope')
339
+ })
340
+ )
341
+ ).toEqual(['quota.exceeded'])
342
+ })
343
+
344
+ it('should keep bannerId and _id in separate identities', () => {
345
+ // A document that lost its bannerId keys on its _id, which must not collide
346
+ // with another document whose bannerId happens to be that same string.
347
+ const dismissedNoId = stackBanner({
348
+ _id: 'quota.exceeded',
349
+ bannerId: undefined,
350
+ dismissedAt: '2026-07-20T00:00:00Z'
351
+ })
352
+ const live = stackBanner({ _id: 'other' })
353
+
354
+ expect(idsOf(getVisibleBanners([dismissedNoId, live], { now }))).toEqual([
355
+ 'quota.exceeded'
356
+ ])
357
+ })
358
+
359
+ it('should order documents that lost their bannerId deterministically', () => {
360
+ const first = stackBanner({ _id: 'zzz', bannerId: undefined })
361
+ const second = stackBanner({ _id: 'aaa', bannerId: undefined })
362
+
363
+ const one = getVisibleBanners([first, second], { now }).map(b => b._id)
364
+ const other = getVisibleBanners([second, first], { now }).map(b => b._id)
365
+
366
+ expect(one).toEqual(['aaa', 'zzz'])
367
+ expect(one).toEqual(other)
368
+ })
369
+
370
+ it('should return the stored document when no fallback applied', () => {
371
+ // The store relies on referential equality, so an untouched banner must not
372
+ // be copied on every read.
373
+ const clean = stackBanner({ cta: null })
374
+
375
+ expect(getVisibleBanners([clean], { now })[0]).toBe(clean)
376
+ })
377
+
378
+ it('should fall back on the clock when now is unusable', () => {
379
+ // The banner is live at the frozen clock, so this separates the guard from
380
+ // isInWindow's own rejection of a NaN time, which would hide it instead.
381
+ const live = stackBanner()
382
+
383
+ MockDate.set('2026-07-22T12:00:00Z')
384
+ try {
385
+ expect(
386
+ idsOf(getVisibleBanners([live], { now: new Date('nope') }))
387
+ ).toEqual(['quota.exceeded'])
388
+ expect(
389
+ idsOf(getVisibleBanners([live], { now: '2026-07-22T12:00:00Z' }))
390
+ ).toEqual(['quota.exceeded'])
391
+ } finally {
392
+ MockDate.reset()
393
+ }
394
+ })
395
+ })
396
+
397
+ describe('dismiss', () => {
398
+ const stored = {
399
+ _id: 'abc',
400
+ _rev: '1-aaa',
401
+ bannerId: 'quota.almost-full',
402
+ severity: 'critical',
403
+ surface: 'inline',
404
+ cta: { label: 'Go', url: 'javascript:alert(1)' },
405
+ dismissedAt: null
406
+ }
407
+ const at = '2026-07-22T12:00:00Z'
408
+
409
+ // dismiss issues two shapes of query: the collection, to find every version
410
+ // of the bannerId, then each document by id.
411
+ const clientReturning = (doc, saveImpl) => ({
412
+ query: jest
413
+ .fn()
414
+ .mockImplementation(definition =>
415
+ Promise.resolve(
416
+ definition?.id ? { data: doc } : { data: doc ? [].concat(doc) : [] }
417
+ )
418
+ ),
419
+ save: saveImpl ?? jest.fn().mockResolvedValue({ data: doc })
420
+ })
421
+
422
+ it('should record the timestamp on the stored document', async () => {
423
+ const client = clientReturning(stored)
424
+
425
+ await dismiss(client, stored, { dismissedAt: at })
426
+
427
+ expect(client.save).toHaveBeenCalledWith({
428
+ _type: BANNERS_DOCTYPE,
429
+ ...stored,
430
+ dismissedAt: at
431
+ })
432
+ })
433
+
434
+ it('should write back what the stack stored, not what the caller renders', async () => {
435
+ // A caller holds a resolved banner, whose severity, surface and cta have
436
+ // had the client fallbacks applied. Persisting those would overwrite the
437
+ // values the stack wrote, and nothing server side prevents it.
438
+ const [resolved] = getVisibleBanners([
439
+ {
440
+ ...stored,
441
+ category: 'quota',
442
+ text: 'hi',
443
+ lang: 'en',
444
+ dismissible: true,
445
+ priority: 1,
446
+ startsAt: '2020-01-01T00:00:00Z',
447
+ endsAt: null,
448
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: 1 }
449
+ }
450
+ ])
451
+ const client = clientReturning(stored)
452
+
453
+ await dismiss(client, resolved, { dismissedAt: at })
454
+
455
+ const persisted = client.save.mock.calls[0][0]
456
+ expect(persisted.severity).toBe('critical')
457
+ expect(persisted.surface).toBe('inline')
458
+ expect(persisted.cta).toEqual(stored.cta)
459
+ })
460
+
461
+ it('should refuse a banner with no identifier', async () => {
462
+ const client = clientReturning(stored)
463
+ await expect(dismiss(client, { bannerId: 'x' })).rejects.toThrow('_id')
464
+ expect(client.save).not.toHaveBeenCalled()
465
+ })
466
+
467
+ it('should stop when the banner was already deleted', async () => {
468
+ for (const missing of [null, []]) {
469
+ const client = clientReturning(missing)
470
+ await expect(
471
+ dismiss(client, stored, { dismissedAt: at })
472
+ ).resolves.toEqual({
473
+ data: null
474
+ })
475
+ expect(client.save).not.toHaveBeenCalled()
476
+ }
477
+ })
478
+
479
+ it('should not rewrite a dismissal another client already recorded', async () => {
480
+ const already = { ...stored, dismissedAt: '2026-07-20T08:00:00Z' }
481
+ const client = clientReturning(already)
482
+
483
+ await expect(dismiss(client, stored, { dismissedAt: at })).resolves.toEqual(
484
+ {
485
+ data: already
486
+ }
487
+ )
488
+ expect(client.save).not.toHaveBeenCalled()
489
+ })
490
+
491
+ it('should read the document back and write again on a conflict', async () => {
492
+ const conflict = Object.assign(new Error('Document update conflict'), {
493
+ status: 409
494
+ })
495
+ const fresh = { ...stored, _rev: '2-bbb' }
496
+ // The first read returns the revision that loses the race and the second
497
+ // the one that wins, so the assertion fails unless the document really was
498
+ // read again rather than the losing revision retried.
499
+ const client = {
500
+ query: jest
501
+ .fn()
502
+ // the collection lookup for every version of the bannerId
503
+ .mockResolvedValueOnce({ data: [stored] })
504
+ // the read by id, then the re-read after the conflict
505
+ .mockResolvedValueOnce({ data: stored })
506
+ .mockResolvedValue({ data: fresh }),
507
+ save: jest
508
+ .fn()
509
+ .mockRejectedValueOnce(conflict)
510
+ .mockResolvedValue({})
511
+ }
512
+
513
+ await dismiss(client, stored, { dismissedAt: at })
514
+
515
+ expect(client.query).toHaveBeenCalledTimes(3)
516
+ expect(client.save).toHaveBeenLastCalledWith({
517
+ ...fresh,
518
+ _type: BANNERS_DOCTYPE,
519
+ dismissedAt: at
520
+ })
521
+ })
522
+
523
+ it('should give up rather than loop when the retry conflicts too', async () => {
524
+ const conflict = Object.assign(new Error('Document update conflict'), {
525
+ status: 409
526
+ })
527
+ const client = clientReturning(
528
+ stored,
529
+ jest.fn().mockRejectedValue(conflict)
530
+ )
531
+
532
+ await expect(dismiss(client, stored, { dismissedAt: at })).rejects.toThrow(
533
+ 'Document update conflict'
534
+ )
535
+ expect(client.save).toHaveBeenCalledTimes(2)
536
+ })
537
+
538
+ it('should classify a conflict the pouch link rewrapped without a status', async () => {
539
+ // CozyPouchLink rebuilds the error and keeps only the message, so the
540
+ // status branch cannot be the one that recognises it.
541
+ const rewrapped = new Error(
542
+ 'Coud not apply mutation: Document update conflict'
543
+ )
544
+ const client = clientReturning(
545
+ stored,
546
+ jest
547
+ .fn()
548
+ .mockRejectedValueOnce(rewrapped)
549
+ .mockResolvedValue({ data: stored })
550
+ )
551
+
552
+ await dismiss(client, stored, { dismissedAt: at })
553
+
554
+ expect(client.save).toHaveBeenCalledTimes(2)
555
+ })
556
+
557
+ it('should read the document the pouch link resolves as an array', async () => {
558
+ const client = clientReturning([stored])
559
+
560
+ await dismiss(client, stored, { dismissedAt: at })
561
+
562
+ expect(client.save).toHaveBeenCalledWith({
563
+ ...stored,
564
+ _type: BANNERS_DOCTYPE,
565
+ dismissedAt: at
566
+ })
567
+ })
568
+
569
+ it('should write to the banners doctype whatever _type the document carries', async () => {
570
+ // Nothing server side stops an application from persisting a _type of its
571
+ // own on a stack authored banner, and the spread order decides where the
572
+ // write lands.
573
+ const poisoned = { ...stored, _type: 'io.cozy.settings' }
574
+ const client = clientReturning(poisoned)
575
+
576
+ await dismiss(client, poisoned, { dismissedAt: at })
577
+
578
+ expect(client.save.mock.calls[0][0]._type).toBe(BANNERS_DOCTYPE)
579
+ })
580
+
581
+ it('should not report a failed read as a deleted banner', async () => {
582
+ // client.query resolves undefined when the client swallows the rejection
583
+ // through its onError option. Reporting that as {data: null} would tell the
584
+ // caller the banner is gone while nothing was written.
585
+ const client = {
586
+ query: jest.fn().mockResolvedValue(undefined),
587
+ save: jest.fn()
588
+ }
589
+
590
+ await expect(dismiss(client, stored, { dismissedAt: at })).rejects.toThrow(
591
+ 'resolved nothing'
592
+ )
593
+ expect(client.save).not.toHaveBeenCalled()
594
+ })
595
+
596
+ it('should not swallow any other error', async () => {
597
+ const boom = Object.assign(new Error('Server error'), { status: 500 })
598
+ const client = clientReturning(stored, jest.fn().mockRejectedValue(boom))
599
+
600
+ await expect(dismiss(client, stored)).rejects.toThrow('Server error')
601
+ })
602
+ })
603
+
604
+ describe('getCta url validation', () => {
605
+ const withUrl = url => ({ cta: { label: 'Go', url } })
606
+
607
+ it('should keep an absolute https URL', () => {
608
+ expect(getCta(withUrl('https://example.org/plans'))).not.toBeNull()
609
+ })
610
+
611
+ it.each([
612
+ ['https://', 'a scheme with no host'],
613
+ ['http://example.org', 'plain http'],
614
+ ['javascript:alert(1)', 'a script scheme'],
615
+ ['//example.org', 'a protocol relative URL'],
616
+ ['/plans', 'a relative path']
617
+ ])('should drop %s (%s)', url => {
618
+ expect(getCta(withUrl(url))).toBeNull()
619
+ })
620
+ })
621
+
622
+ describe('getActiveBanners', () => {
623
+ const stored = {
624
+ _id: 'abc',
625
+ bannerId: 'quota.almost-full',
626
+ category: 'quota',
627
+ severity: 'critical',
628
+ surface: 'inline',
629
+ text: 'hi',
630
+ lang: 'en',
631
+ dismissible: true,
632
+ dismissedAt: null,
633
+ priority: 10,
634
+ startsAt: '2020-01-01T00:00:00Z',
635
+ endsAt: null,
636
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: '1' }
637
+ }
638
+
639
+ it('should query and return banners ready to render', async () => {
640
+ const client = { query: jest.fn().mockResolvedValue({ data: [stored] }) }
641
+
642
+ const banners = await getActiveBanners(client)
643
+
644
+ // asserted precisely: passing the { definition, options } wrapper instead
645
+ // of the definition itself throws "Cannot init query with no doctype" at
646
+ // runtime, which a permissive mock hides.
647
+ const [definition, queryOptions] = client.query.mock.calls[0]
648
+ expect(definition.doctype).toBe(BANNERS_DOCTYPE)
649
+ // Named under its own key rather than the bare doctype: client.query keys
650
+ // the store entry on the name but the in flight promise on the definition,
651
+ // so sharing the name with another query makes this one resolve nothing.
652
+ expect(queryOptions.as).toBe(BANNERS_QUERY_NAME)
653
+ expect(BANNERS_QUERY_NAME).not.toBe(BANNERS_DOCTYPE)
654
+ expect(banners).toHaveLength(1)
655
+ // fallbacks already applied, so the caller renders what it is given
656
+ expect(banners[0].severity).toBe('warning')
657
+ expect(banners[0].surface).toBe('banner')
658
+ })
659
+
660
+ it('should cope with an empty or missing collection', async () => {
661
+ for (const data of [[], null, undefined]) {
662
+ const client = { query: jest.fn().mockResolvedValue({ data }) }
663
+ await expect(getActiveBanners(client)).resolves.toEqual([])
664
+ }
665
+ })
666
+
667
+ it('should read a collection the pouch link resolves as a bare object', async () => {
668
+ const client = { query: jest.fn().mockResolvedValue({ data: stored }) }
669
+ await expect(getActiveBanners(client)).resolves.toEqual([])
670
+ })
671
+
672
+ it('should not report a query that resolved nothing as an empty instance', async () => {
673
+ const client = { query: jest.fn().mockResolvedValue(undefined) }
674
+ await expect(getActiveBanners(client)).rejects.toThrow('resolved nothing')
675
+ })
676
+ })
677
+
678
+ describe('getActiveBanner', () => {
679
+ it('should return the highest priority banner', async () => {
680
+ const low = {
681
+ _id: 'a',
682
+ bannerId: 'low',
683
+ severity: 'info',
684
+ surface: 'banner',
685
+ priority: 1,
686
+ dismissedAt: null,
687
+ startsAt: '2020-01-01T00:00:00Z',
688
+ endsAt: null,
689
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: '1' }
690
+ }
691
+ const high = { ...low, _id: 'b', bannerId: 'high', priority: 99 }
692
+ const client = { query: jest.fn().mockResolvedValue({ data: [low, high] }) }
693
+
694
+ const banner = await getActiveBanner(client)
695
+
696
+ expect(banner.bannerId).toBe('high')
697
+ })
698
+
699
+ it('should return null when nothing applies', async () => {
700
+ const client = { query: jest.fn().mockResolvedValue({ data: [] }) }
701
+ await expect(getActiveBanner(client)).resolves.toBeNull()
702
+ })
703
+ })
704
+
705
+ describe('dismiss across versions', () => {
706
+ const at = '2026-08-28T10:00:00Z'
707
+ const v1 = {
708
+ _id: 'v1doc',
709
+ bannerId: 'quota.exceeded',
710
+ dismissedAt: null,
711
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: '1' }
712
+ }
713
+ const v2 = {
714
+ ...v1,
715
+ _id: 'v2doc',
716
+ cozyMetadata: { ...v1.cozyMetadata, doctypeVersion: '2' }
717
+ }
718
+ const other = { ...v1, _id: 'otherdoc', bannerId: 'billing.failed' }
719
+
720
+ it('should write the dismissal to every version of the bannerId', async () => {
721
+ const byId = { v1doc: v1, v2doc: v2, otherdoc: other }
722
+ const client = {
723
+ query: jest
724
+ .fn()
725
+ .mockImplementation(definition =>
726
+ Promise.resolve(
727
+ definition?.id
728
+ ? { data: byId[definition.id] }
729
+ : { data: [v1, v2, other] }
730
+ )
731
+ ),
732
+ save: jest.fn().mockImplementation(doc => Promise.resolve({ data: doc }))
733
+ }
734
+
735
+ await dismiss(client, v1, { dismissedAt: at })
736
+
737
+ const written = client.save.mock.calls.map(([doc]) => doc._id).sort()
738
+ expect(written).toEqual(['v1doc', 'v2doc'])
739
+ // the sibling under a different bannerId is left alone
740
+ expect(written).not.toContain('otherdoc')
741
+ })
742
+
743
+ it('should answer with the document the caller asked about', async () => {
744
+ const client = {
745
+ query: jest
746
+ .fn()
747
+ .mockImplementation(definition =>
748
+ Promise.resolve(
749
+ definition?.id
750
+ ? { data: definition.id === 'v1doc' ? v1 : v2 }
751
+ : { data: [v1, v2] }
752
+ )
753
+ ),
754
+ save: jest.fn().mockImplementation(doc => Promise.resolve({ data: doc }))
755
+ }
756
+
757
+ const result = await dismiss(client, v2, { dismissedAt: at })
758
+
759
+ expect(result.data._id).toBe('v2doc')
760
+ })
761
+ })