cozy-client 60.30.6 → 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.
@@ -0,0 +1,381 @@
1
+ import {
2
+ BANNERS_DOCTYPE,
3
+ dismiss,
4
+ getActiveBanner,
5
+ getActiveBanners
6
+ } from './banner'
7
+ import vectors from './__fixtures__/io.cozy.banners.json'
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
+ })
13
+
14
+ /**
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.
23
+ */
24
+ describe('io.cozy.banners contract vectors', () => {
25
+ const now = new Date(vectors.now)
26
+
27
+ it.each(vectors.cases.map(testCase => [testCase.name, testCase]))(
28
+ '%s',
29
+ async (name, testCase) => {
30
+ const banners = await getActiveBanners(clientListing(testCase.input), {
31
+ now
32
+ })
33
+
34
+ const rendered = banners.map(banner => ({
35
+ bannerId: banner.bannerId,
36
+ severity: banner.severity,
37
+ surface: banner.surface,
38
+ cta: Boolean(banner.cta),
39
+ secondaryCta: Boolean(banner.secondaryCta)
40
+ }))
41
+
42
+ expect(rendered).toEqual(testCase.expected)
43
+ }
44
+ )
45
+ })
46
+
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
+ }
64
+
65
+ it('should return banners ready to render, with every fallback applied', async () => {
66
+ const banners = await getActiveBanners(clientListing([stored]))
67
+
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')
73
+ })
74
+
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)
80
+ })
81
+
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
+ }
86
+ })
87
+
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 } }])
108
+ )
109
+ expect(banner.cta).toBeUndefined()
110
+ }
111
+
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')
118
+ })
119
+
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']]
125
+
126
+ for (const startsAt of bounds) {
127
+ await expect(
128
+ getActiveBanners(clientListing([{ ...stored, startsAt }]))
129
+ ).resolves.toEqual([])
130
+ }
131
+ })
132
+
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
139
+
140
+ const banners = await getActiveBanners(clientListing([unranked, ranked]))
141
+
142
+ expect(banners.map(banner => banner.bannerId)).toEqual([
143
+ 'a.ranked',
144
+ 'b.unranked'
145
+ ])
146
+ })
147
+ })
148
+
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
+ }
160
+
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 }
164
+
165
+ const banner = await getActiveBanner(clientListing([low, high]))
166
+
167
+ expect(banner.bannerId).toBe('high')
168
+ })
169
+
170
+ it('should return null when nothing applies', async () => {
171
+ await expect(getActiveBanner(clientListing([]))).resolves.toBeNull()
172
+ })
173
+ })
174
+
175
+ describe('dismiss', () => {
176
+ const stored = {
177
+ _id: 'abc',
178
+ _rev: '1-aaa',
179
+ bannerId: 'quota.almost-full',
180
+ severity: 'critical',
181
+ surface: 'inline',
182
+ cta: { label: 'Go', url: 'javascript:alert(1)' },
183
+ dismissedAt: null
184
+ }
185
+ const at = '2026-07-22T12:00:00Z'
186
+
187
+ // dismiss issues two shapes of query: the collection, to find every version
188
+ // of the bannerId, then each document by id.
189
+ const clientReturning = (doc, saveImpl) => ({
190
+ query: jest
191
+ .fn()
192
+ .mockImplementation(definition =>
193
+ Promise.resolve(
194
+ definition?.id ? { data: doc } : { data: doc ? [].concat(doc) : [] }
195
+ )
196
+ ),
197
+ save: saveImpl ?? jest.fn().mockResolvedValue({ data: doc })
198
+ })
199
+
200
+ it('should record the timestamp on the stored document', async () => {
201
+ const client = clientReturning(stored)
202
+
203
+ await dismiss(client, stored, { dismissedAt: at })
204
+
205
+ expect(client.save).toHaveBeenCalledWith({
206
+ _type: BANNERS_DOCTYPE,
207
+ ...stored,
208
+ dismissedAt: at
209
+ })
210
+ })
211
+
212
+ it('should write back what the stack stored, not what the caller renders', async () => {
213
+ // A caller holds a resolved banner, whose severity, surface and cta have
214
+ // had the client fallbacks applied. Persisting those would overwrite the
215
+ // values the stack wrote, and nothing server side prevents it.
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
+ )
231
+ const client = clientReturning(stored)
232
+
233
+ await dismiss(client, resolved, { dismissedAt: at })
234
+
235
+ const persisted = client.save.mock.calls[0][0]
236
+ expect(persisted.severity).toBe('critical')
237
+ expect(persisted.surface).toBe('inline')
238
+ expect(persisted.cta).toEqual(stored.cta)
239
+ })
240
+
241
+ it('should refuse a banner with no identifier', async () => {
242
+ const client = clientReturning(stored)
243
+ await expect(dismiss(client, { bannerId: 'x' })).rejects.toThrow('_id')
244
+ expect(client.save).not.toHaveBeenCalled()
245
+ })
246
+
247
+ it('should stop when the banner was already deleted', async () => {
248
+ for (const missing of [null, []]) {
249
+ const client = clientReturning(missing)
250
+ await expect(
251
+ dismiss(client, stored, { dismissedAt: at })
252
+ ).resolves.toEqual({
253
+ data: null
254
+ })
255
+ expect(client.save).not.toHaveBeenCalled()
256
+ }
257
+ })
258
+
259
+ it('should not rewrite a dismissal another client already recorded', async () => {
260
+ const already = { ...stored, dismissedAt: '2026-07-20T08:00:00Z' }
261
+ const client = clientReturning(already)
262
+
263
+ await expect(dismiss(client, stored, { dismissedAt: at })).resolves.toEqual(
264
+ {
265
+ data: already
266
+ }
267
+ )
268
+ expect(client.save).not.toHaveBeenCalled()
269
+ })
270
+
271
+ it('should read the document back and write again on a conflict', async () => {
272
+ const conflict = Object.assign(new Error('Document update conflict'), {
273
+ status: 409
274
+ })
275
+ const fresh = { ...stored, _rev: '2-bbb' }
276
+ // The first read returns the revision that loses the race and the second
277
+ // the one that wins, so the assertion fails unless the document really was
278
+ // read again rather than the losing revision retried.
279
+ const client = {
280
+ query: jest
281
+ .fn()
282
+ // the read by id, then the re-read after the conflict
283
+ .mockResolvedValueOnce({ data: stored })
284
+ .mockResolvedValue({ data: fresh }),
285
+ save: jest
286
+ .fn()
287
+ .mockRejectedValueOnce(conflict)
288
+ .mockResolvedValue({})
289
+ }
290
+
291
+ await dismiss(client, stored, { dismissedAt: at })
292
+
293
+ // the read by id, then the re-read after the conflict
294
+ expect(client.query).toHaveBeenCalledTimes(2)
295
+ expect(client.save).toHaveBeenLastCalledWith({
296
+ ...fresh,
297
+ _type: BANNERS_DOCTYPE,
298
+ dismissedAt: at
299
+ })
300
+ })
301
+
302
+ it('should give up rather than loop when the retry conflicts too', async () => {
303
+ const conflict = Object.assign(new Error('Document update conflict'), {
304
+ status: 409
305
+ })
306
+ const client = clientReturning(
307
+ stored,
308
+ jest.fn().mockRejectedValue(conflict)
309
+ )
310
+
311
+ await expect(dismiss(client, stored, { dismissedAt: at })).rejects.toThrow(
312
+ 'Document update conflict'
313
+ )
314
+ expect(client.save).toHaveBeenCalledTimes(2)
315
+ })
316
+
317
+ it('should classify a conflict the pouch link rewrapped without a status', async () => {
318
+ // CozyPouchLink rebuilds the error and keeps only the message, so the
319
+ // status branch cannot be the one that recognises it.
320
+ const rewrapped = new Error(
321
+ 'Coud not apply mutation: Document update conflict'
322
+ )
323
+ const client = clientReturning(
324
+ stored,
325
+ jest
326
+ .fn()
327
+ .mockRejectedValueOnce(rewrapped)
328
+ .mockResolvedValue({ data: stored })
329
+ )
330
+
331
+ await dismiss(client, stored, { dismissedAt: at })
332
+
333
+ expect(client.save).toHaveBeenCalledTimes(2)
334
+ })
335
+
336
+ it('should read the document the pouch link resolves as an array', async () => {
337
+ const client = clientReturning([stored])
338
+
339
+ await dismiss(client, stored, { dismissedAt: at })
340
+
341
+ expect(client.save).toHaveBeenCalledWith({
342
+ ...stored,
343
+ _type: BANNERS_DOCTYPE,
344
+ dismissedAt: at
345
+ })
346
+ })
347
+
348
+ it('should write to the banners doctype whatever _type the document carries', async () => {
349
+ // Nothing server side stops an application from persisting a _type of its
350
+ // own on a stack authored banner, and the spread order decides where the
351
+ // write lands.
352
+ const poisoned = { ...stored, _type: 'io.cozy.settings' }
353
+ const client = clientReturning(poisoned)
354
+
355
+ await dismiss(client, poisoned, { dismissedAt: at })
356
+
357
+ expect(client.save.mock.calls[0][0]._type).toBe(BANNERS_DOCTYPE)
358
+ })
359
+
360
+ it('should not report a failed read as a deleted banner', async () => {
361
+ // client.query resolves undefined when the client swallows the rejection
362
+ // through its onError option. Reporting that as {data: null} would tell the
363
+ // caller the banner is gone while nothing was written.
364
+ const client = {
365
+ query: jest.fn().mockResolvedValue(undefined),
366
+ save: jest.fn()
367
+ }
368
+
369
+ await expect(dismiss(client, stored, { dismissedAt: at })).rejects.toThrow(
370
+ 'resolved nothing'
371
+ )
372
+ expect(client.save).not.toHaveBeenCalled()
373
+ })
374
+
375
+ it('should not swallow any other error', async () => {
376
+ const boom = Object.assign(new Error('Server error'), { status: 500 })
377
+ const client = clientReturning(stored, jest.fn().mockRejectedValue(boom))
378
+
379
+ await expect(dismiss(client, stored)).rejects.toThrow('Server error')
380
+ })
381
+ })
@@ -5,7 +5,7 @@ var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWild
5
5
  Object.defineProperty(exports, "__esModule", {
6
6
  value: true
7
7
  });
8
- exports.assistant = exports.ai = exports.doctypes = exports.geo = exports.user = exports.paper = exports.dacc = exports.sharing = exports.timeseries = exports.document = exports.contact = exports.utils = exports.permission = exports.note = exports.account = exports.konnectorFolder = exports.folder = exports.file = exports.applications = exports.instance = exports.trigger = exports.accounts = exports.triggers = void 0;
8
+ exports.banner = exports.assistant = exports.ai = exports.doctypes = exports.geo = exports.user = exports.paper = exports.dacc = exports.sharing = exports.timeseries = exports.document = exports.contact = exports.utils = exports.permission = exports.note = exports.account = exports.konnectorFolder = exports.folder = exports.file = exports.applications = exports.instance = exports.trigger = exports.accounts = exports.triggers = void 0;
9
9
 
10
10
  var trigger = _interopRequireWildcard(require("./trigger"));
11
11
 
@@ -90,6 +90,10 @@ exports.ai = ai;
90
90
  var assistant = _interopRequireWildcard(require("./assistant"));
91
91
 
92
92
  exports.assistant = assistant;
93
+
94
+ var banner = _interopRequireWildcard(require("./banner"));
95
+
96
+ exports.banner = banner;
93
97
  // For backward compatibility before 9.0.0
94
98
  var triggers = trigger;
95
99
  exports.triggers = triggers;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-client",
3
- "version": "60.30.6",
3
+ "version": "60.32.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.node.js",
6
6
  "browser": "dist/index.js",
@@ -74,5 +74,5 @@
74
74
  "react-native-ios11-devicecheck": "^0.0.3"
75
75
  },
76
76
  "sideEffects": false,
77
- "gitHead": "88101a3d13943828cd1cfcf39c40535c0a259afa"
77
+ "gitHead": "afc1cbe7a956b8abbb875e5436ed031f05207a24"
78
78
  }
@@ -0,0 +1,87 @@
1
+ export const BANNERS_DOCTYPE: "io.cozy.banners";
2
+ export function getActiveBanners(client: object, { now }?: {
3
+ now: Date;
4
+ }): Promise<Banner[]>;
5
+ export function getActiveBanner(client: object, options?: {
6
+ now: Date;
7
+ }): Promise<Banner | null>;
8
+ export function dismiss(client: object, banner: Banner, { dismissedAt }?: {
9
+ dismissedAt: string;
10
+ }): Promise<{
11
+ data: Banner | null;
12
+ }>;
13
+ export type BannerCta = {
14
+ /**
15
+ * - The label, plain text
16
+ */
17
+ label: string;
18
+ /**
19
+ * - Where the label points
20
+ */
21
+ url: string;
22
+ };
23
+ /**
24
+ * An io.cozy.banners document
25
+ */
26
+ export type Banner = {
27
+ /**
28
+ * - Identifier of the document
29
+ */
30
+ _id?: string;
31
+ /**
32
+ * - Identifier of the banner itself
33
+ */
34
+ bannerId: string;
35
+ /**
36
+ * - What the banner is about
37
+ */
38
+ category: string;
39
+ /**
40
+ * - How loudly to render it
41
+ */
42
+ severity: 'info' | 'warning' | 'error';
43
+ /**
44
+ * - Where to render it
45
+ */
46
+ surface: 'banner' | 'modal';
47
+ /**
48
+ * - Optional heading, used on the modal surface
49
+ */
50
+ title?: string;
51
+ /**
52
+ * - The message, already localized
53
+ */
54
+ text: string;
55
+ /**
56
+ * - BCP 47 tag of the language `text` and `cta.label` are in
57
+ */
58
+ lang: string;
59
+ /**
60
+ * - Optional call to action
61
+ */
62
+ cta?: BannerCta;
63
+ /**
64
+ * - Optional lesser action, needs a cta
65
+ */
66
+ secondaryCta?: BannerCta;
67
+ /**
68
+ * - Whether the client offers a dismiss control
69
+ */
70
+ dismissible: boolean;
71
+ /**
72
+ * - When the user dismissed it
73
+ */
74
+ dismissedAt?: string;
75
+ /**
76
+ * - Sort order, highest first
77
+ */
78
+ priority: number;
79
+ /**
80
+ * - Start of the validity window, inclusive
81
+ */
82
+ startsAt: string;
83
+ /**
84
+ * - End of the validity window, exclusive
85
+ */
86
+ endsAt?: string;
87
+ };
@@ -1,4 +1,5 @@
1
1
  export const triggers: typeof trigger;
2
+ import * as banner from "./banner";
2
3
  import * as trigger from "./trigger";
3
4
  import * as instance from "./instance";
4
5
  import * as applications from "./applications";
@@ -21,4 +22,4 @@ import * as geo from "./geo";
21
22
  import * as doctypes from "./doctypes";
22
23
  import * as ai from "./ai";
23
24
  import * as assistant from "./assistant";
24
- export { trigger, instance, applications, file, folder, konnectorFolder, note, account, permission, utils, contact, document, timeseries, sharing, dacc, paper, user, geo, doctypes, ai, assistant };
25
+ export { banner, trigger, instance, applications, file, folder, konnectorFolder, note, account, permission, utils, contact, document, timeseries, sharing, dacc, paper, user, geo, doctypes, ai, assistant };