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.
- package/dist/CozyClient.js +1 -1
- package/dist/models/__fixtures__/io.cozy.banners.json +56 -401
- package/dist/models/banner.js +221 -800
- package/dist/models/banner.spec.js +137 -517
- package/package.json +2 -2
- package/types/models/banner.d.ts +31 -137
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
* cozy-doctypes
|
|
23
|
-
*
|
|
24
|
-
* against
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(
|
|
109
|
-
|
|
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
|
|
121
|
-
const
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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(
|
|
355
|
-
'
|
|
142
|
+
expect(banners.map(banner => banner.bannerId)).toEqual([
|
|
143
|
+
'a.ranked',
|
|
144
|
+
'b.unranked'
|
|
356
145
|
])
|
|
357
146
|
})
|
|
147
|
+
})
|
|
358
148
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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(
|
|
167
|
+
expect(banner.bannerId).toBe('high')
|
|
376
168
|
})
|
|
377
169
|
|
|
378
|
-
it('should
|
|
379
|
-
|
|
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] =
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
})
|