cozy-client 60.31.0 → 60.32.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.
@@ -1,47 +1,42 @@
1
1
  import {
2
2
  BANNERS_DOCTYPE,
3
- BANNERS_QUERY_NAME,
4
- getActiveBanner,
5
- getActiveBanners,
6
- byPriority,
7
3
  dismiss,
8
- getCta,
9
- getSeverity,
10
- getSurface,
11
- getVisibleBanners,
12
- isDismissed,
13
- isInWindow,
14
- isSupported,
15
- isTrusted
4
+ getActiveBanner,
5
+ getActiveBanners
16
6
  } from './banner'
17
7
  import vectors from './__fixtures__/io.cozy.banners.json'
18
- import MockDate from 'mockdate'
8
+
9
+ /** A client that answers a collection read with the given documents. */
10
+ const clientListing = docs => ({
11
+ query: jest.fn().mockResolvedValue({ data: docs })
12
+ })
19
13
 
20
14
  /**
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.
15
+ * The fixtures are the contract vectors published with the doctype in
16
+ * cozy-doctypes. Every client implementing io.cozy.banners runs the same cases,
17
+ * so the implementations are checked against one specification rather than
18
+ * against each other.
19
+ *
20
+ * They run through getActiveBanners, the entry point a consumer calls, rather
21
+ * than against the internals: what matters is what an application ends up
22
+ * displaying.
25
23
  */
26
24
  describe('io.cozy.banners contract vectors', () => {
27
25
  const now = new Date(vectors.now)
28
26
 
29
27
  it.each(vectors.cases.map(testCase => [testCase.name, testCase]))(
30
28
  '%s',
31
- (name, testCase) => {
32
- const visible = getVisibleBanners(testCase.input, {
33
- now,
34
- supportedDoctypeVersion:
35
- testCase.supportedDoctypeVersion ?? vectors.supportedDoctypeVersion
29
+ async (name, testCase) => {
30
+ const banners = await getActiveBanners(clientListing(testCase.input), {
31
+ now
36
32
  })
37
33
 
38
- // No helper calls: getVisibleBanners returns banners ready to render,
39
- // with every fallback already applied.
40
- const rendered = visible.map(banner => ({
34
+ const rendered = banners.map(banner => ({
41
35
  bannerId: banner.bannerId,
42
36
  severity: banner.severity,
43
37
  surface: banner.surface,
44
- cta: banner.cta !== null
38
+ cta: Boolean(banner.cta),
39
+ secondaryCta: Boolean(banner.secondaryCta)
45
40
  }))
46
41
 
47
42
  expect(rendered).toEqual(testCase.expected)
@@ -49,348 +44,131 @@ describe('io.cozy.banners contract vectors', () => {
49
44
  )
50
45
  })
51
46
 
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
- })
47
+ describe('getActiveBanners', () => {
48
+ const stored = {
49
+ _id: 'abc',
50
+ bannerId: 'quota.almost-full',
51
+ category: 'quota',
52
+ severity: 'critical',
53
+ surface: 'inline',
54
+ text: 'hi',
55
+ lang: 'en',
56
+ cta: { label: 'Go', url: 'http://example.org' },
57
+ dismissible: true,
58
+ dismissedAt: null,
59
+ priority: 10,
60
+ startsAt: '2020-01-01T00:00:00Z',
61
+ endsAt: null,
62
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: '1' }
63
+ }
96
64
 
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
- })
65
+ it('should return banners ready to render, with every fallback applied', async () => {
66
+ const banners = await getActiveBanners(clientListing([stored]))
104
67
 
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)
68
+ expect(banners).toHaveLength(1)
69
+ // a severity or surface this client does not know is rendered with the
70
+ // fallback rather than dropped, so a new value needs no client release
71
+ expect(banners[0].severity).toBe('warning')
72
+ expect(banners[0].surface).toBe('banner')
118
73
  })
119
74
 
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)
75
+ it('should query the doctype, not a wrapper around it', async () => {
76
+ const client = clientListing([])
77
+ await getActiveBanners(client)
78
+ const [definition] = client.query.mock.calls[0]
79
+ expect(definition.doctype).toBe(BANNERS_DOCTYPE)
123
80
  })
124
- })
125
81
 
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)
82
+ it('should cope with an empty or missing collection', async () => {
83
+ for (const data of [[], null, undefined]) {
84
+ await expect(getActiveBanners(clientListing(data))).resolves.toEqual([])
85
+ }
130
86
  })
131
87
 
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
- )
88
+ it('should not hide a failed read behind an empty list', async () => {
89
+ // client.query resolves undefined when the client carries an onError
90
+ // callback, which would otherwise read as "no banners apply"
91
+ const client = { query: jest.fn().mockResolvedValue(undefined) }
92
+ await expect(getActiveBanners(client)).rejects.toThrow(BANNERS_DOCTYPE)
93
+ })
94
+
95
+ it('should drop a call to action that is not an absolute https URL', async () => {
96
+ // the documents live in a database the applications can write, and a
97
+ // client renders this straight into an href
98
+ const urls = [
99
+ 'javascript' + ':alert(document.cookie)',
100
+ 'data:text/html,<script>alert(1)</script>',
101
+ 'http://example.org/plans',
102
+ '/plans'
103
+ ]
104
+
105
+ for (const url of urls) {
106
+ const [banner] = await getActiveBanners(
107
+ clientListing([{ ...stored, cta: { label: 'Go', url } }])
147
108
  )
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()
109
+ expect(banner.cta).toBeUndefined()
160
110
  }
161
- })
162
- })
163
111
 
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')
112
+ const [kept] = await getActiveBanners(
113
+ clientListing([
114
+ { ...stored, cta: { label: 'Go', url: 'https://example.org/plans' } }
115
+ ])
116
+ )
117
+ expect(kept.cta.url).toBe('https://example.org/plans')
294
118
  })
295
119
 
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
- })
120
+ it('should hide a banner whose window bound is not the timestamp the doctype writes', async () => {
121
+ // Date.parse falls back to an engine specific parser for everything else,
122
+ // so the same document would be visible in one browser and hidden in
123
+ // another, and the contract vectors could no longer describe both
124
+ const bounds = ['2020-01-01', 'Jan 1 2020', '2020/01/01', 0, ['2020-01-01']]
305
125
 
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')
126
+ for (const startsAt of bounds) {
127
+ await expect(
128
+ getActiveBanners(clientListing([{ ...stored, startsAt }]))
129
+ ).resolves.toEqual([])
130
+ }
329
131
  })
330
132
 
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
- })
133
+ it('should rank a banner carrying no priority below one that does', async () => {
134
+ // b.priority - a.priority is NaN without this, and a NaN comparator makes
135
+ // sort keep insertion order and never reach the bannerId tiebreak
136
+ const ranked = { ...stored, _id: 'a', bannerId: 'a.ranked', priority: 50 }
137
+ const unranked = { ...stored, _id: 'b', bannerId: 'b.unranked' }
138
+ delete unranked.priority
343
139
 
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' })
140
+ const banners = await getActiveBanners(clientListing([unranked, ranked]))
353
141
 
354
- expect(idsOf(getVisibleBanners([dismissedNoId, live], { now }))).toEqual([
355
- 'quota.exceeded'
142
+ expect(banners.map(banner => banner.bannerId)).toEqual([
143
+ 'a.ranked',
144
+ 'b.unranked'
356
145
  ])
357
146
  })
147
+ })
358
148
 
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)
149
+ describe('getActiveBanner', () => {
150
+ const base = {
151
+ bannerId: 'low',
152
+ severity: 'info',
153
+ surface: 'banner',
154
+ priority: 1,
155
+ dismissedAt: null,
156
+ startsAt: '2020-01-01T00:00:00Z',
157
+ endsAt: null,
158
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: '1' }
159
+ }
365
160
 
366
- expect(one).toEqual(['aaa', 'zzz'])
367
- expect(one).toEqual(other)
368
- })
161
+ it('should return the highest priority banner', async () => {
162
+ const low = { ...base, _id: 'a' }
163
+ const high = { ...base, _id: 'b', bannerId: 'high', priority: 99 }
369
164
 
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 })
165
+ const banner = await getActiveBanner(clientListing([low, high]))
374
166
 
375
- expect(getVisibleBanners([clean], { now })[0]).toBe(clean)
167
+ expect(banner.bannerId).toBe('high')
376
168
  })
377
169
 
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
- }
170
+ it('should return null when nothing applies', async () => {
171
+ await expect(getActiveBanner(clientListing([]))).resolves.toBeNull()
394
172
  })
395
173
  })
396
174
 
@@ -435,19 +213,21 @@ describe('dismiss', () => {
435
213
  // A caller holds a resolved banner, whose severity, surface and cta have
436
214
  // had the client fallbacks applied. Persisting those would overwrite the
437
215
  // 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
- ])
216
+ const [resolved] = await getActiveBanners(
217
+ clientListing([
218
+ {
219
+ ...stored,
220
+ category: 'quota',
221
+ text: 'hi',
222
+ lang: 'en',
223
+ dismissible: true,
224
+ priority: 1,
225
+ startsAt: '2020-01-01T00:00:00Z',
226
+ endsAt: null,
227
+ cozyMetadata: { createdByApp: 'stack', doctypeVersion: '1' }
228
+ }
229
+ ])
230
+ )
451
231
  const client = clientReturning(stored)
452
232
 
453
233
  await dismiss(client, resolved, { dismissedAt: at })
@@ -499,8 +279,6 @@ describe('dismiss', () => {
499
279
  const client = {
500
280
  query: jest
501
281
  .fn()
502
- // the collection lookup for every version of the bannerId
503
- .mockResolvedValueOnce({ data: [stored] })
504
282
  // the read by id, then the re-read after the conflict
505
283
  .mockResolvedValueOnce({ data: stored })
506
284
  .mockResolvedValue({ data: fresh }),
@@ -512,7 +290,8 @@ describe('dismiss', () => {
512
290
 
513
291
  await dismiss(client, stored, { dismissedAt: at })
514
292
 
515
- expect(client.query).toHaveBeenCalledTimes(3)
293
+ // the read by id, then the re-read after the conflict
294
+ expect(client.query).toHaveBeenCalledTimes(2)
516
295
  expect(client.save).toHaveBeenLastCalledWith({
517
296
  ...fresh,
518
297
  _type: BANNERS_DOCTYPE,
@@ -600,162 +379,3 @@ describe('dismiss', () => {
600
379
  await expect(dismiss(client, stored)).rejects.toThrow('Server error')
601
380
  })
602
381
  })
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
- })