Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / items / item.utils.spec.ts
blob641c51c6c642f376f8c84a67964548db85628d00
1 import { itemBuilder } from '@proton/pass/lib/items/item.builder';
2 import { createTestItem } from '@proton/pass/lib/items/item.test.utils';
3 import type { Draft } from '@proton/pass/store/reducers';
4 import type { IndexedByShareIdAndItemId, ItemRevision, LoginItem, SelectedItem } from '@proton/pass/types';
5 import { UNIX_DAY, UNIX_MONTH, UNIX_WEEK } from '@proton/pass/utils/time/constants';
6 import { getEpoch } from '@proton/pass/utils/time/epoch';
8 import {
9     batchByShareId,
10     filterItemsByShareId,
11     filterItemsByType,
12     filterItemsByUserIdentifier,
13     flattenItemsByShareId,
14     getItemActionId,
15     getItemKey,
16     getItemKeyRevision,
17     getSanitizedUserIdentifiers,
18     interpolateRecentItems,
19     intoIdentityItemPreview,
20     intoLoginItemPreview,
21     intoRevisionID,
22     intoSelectedItem,
23     intoUserIdentifier,
24     matchDraftsForShare,
25     sortItems,
26 } from './item.utils';
28 jest.mock('@proton/pass/utils/time/epoch', () => ({
29     ...jest.requireActual('@proton/pass/utils/time/epoch'),
30     getEpoch: jest.fn(),
31 }));
33 describe('Item utils', () => {
34     describe('getItemKey', () => {
35         test('should return the correct key string', () => {
36             const input = createTestItem('login', { shareId: 'share123', itemId: 'item456', revision: 789 });
37             const result = getItemKey(input);
38             expect(result).toBe('share123::item456');
39         });
40     });
42     describe('getItemKeyRevision', () => {
43         test('should return the correct key string', () => {
44             const input = createTestItem('login', { shareId: 'share123', itemId: 'item456', revision: 1 });
45             const result = getItemKeyRevision(input);
46             expect(result).toBe('share123::item456::1');
47         });
48     });
50     describe('intoSelectedItem', () => {
51         test('should return the correct `SelectedItem` object', () => {
52             const input = createTestItem('login', { shareId: 'share123', itemId: 'item456', revision: 789 });
53             const expected: SelectedItem = { shareId: 'share123', itemId: 'item456' };
54             const result = intoSelectedItem(input);
55             expect(result).toStrictEqual(expected);
56         });
57     });
59     describe('getItemActionId', () => {
60         test('should return the correct optimistic ID for optimistic payloads', () => {
61             const input = { optimisticId: 'optimistic123', shareId: 'share123' };
62             const result = getItemActionId(input);
63             expect(result).toBe('share123::optimistic123');
64         });
66         test('should return the correct optimistic ID for non-optimistic payloads', () => {
67             const input = { itemId: 'item456', shareId: 'share123' };
68             const result = getItemActionId(input);
69             expect(result).toBe('share123::item456');
70         });
72         test('should prioritize `optimisticId` over `itemId`', () => {
73             const input = { optimisticId: 'optimistic123', itemId: 'item456', shareId: 'share123' };
74             const result = getItemActionId(input);
75             expect(result).toBe('share123::optimistic123');
76         });
77     });
79     describe('flattenItemsByShareId', () => {
80         test('should return a flat array of `ItemRevision` objects', () => {
81             const revisions = [
82                 createTestItem('login', { shareId: 'share123', itemId: 'item1', revision: 1 }),
83                 createTestItem('login', { shareId: 'share123', itemId: 'item2', revision: 2 }),
84                 createTestItem('login', { shareId: 'share456', itemId: 'item3', revision: 3 }),
85             ];
87             const input: IndexedByShareIdAndItemId<ItemRevision> = {
88                 share123: { item1: revisions[0], item2: revisions[1] },
89                 share456: { item3: revisions[2] },
90             };
92             const result = flattenItemsByShareId(input);
93             expect(result).toEqual(revisions);
94         });
96         test('should return an empty array if no items are present', () => {
97             const input = {};
98             const result = flattenItemsByShareId(input);
99             expect(result).toEqual([]);
100         });
101     });
103     describe('interpolateRecentItems', () => {
104         test('should interpolate items into correct date clusters', () => {
105             const now = 1000000000;
106             (getEpoch as jest.Mock).mockReturnValue(now);
108             const items = [
109                 createTestItem('login', {
110                     shareId: 'share1',
111                     itemId: 'item1',
112                     revision: 1,
113                     modifyTime: now - UNIX_DAY / 2,
114                 }),
115                 createTestItem('login', {
116                     shareId: 'share2',
117                     itemId: 'item2',
118                     revision: 2,
119                     modifyTime: now - UNIX_DAY * 3,
120                 }),
121                 createTestItem('login', {
122                     shareId: 'share3',
123                     itemId: 'item3',
124                     revision: 3,
125                     modifyTime: now - UNIX_WEEK * 2 + UNIX_DAY,
126                 }),
127                 createTestItem('login', {
128                     shareId: 'share4',
129                     itemId: 'item4',
130                     revision: 4,
131                     modifyTime: now - UNIX_MONTH,
132                 }),
133             ];
135             const expectedClusters = [
136                 { label: 'Today', boundary: now - UNIX_DAY },
137                 { label: 'Last week', boundary: now - UNIX_WEEK },
138                 { label: 'Last 2 weeks', boundary: now - UNIX_WEEK * 2 },
139                 { label: 'Last month', boundary: now - UNIX_MONTH },
140             ];
142             expect(interpolateRecentItems(items)(true)).toEqual({
143                 interpolation: [
144                     { type: 'interpolation', cluster: expectedClusters[0] },
145                     { type: 'entry', entry: items[0] },
146                     { type: 'entry', entry: items[1] },
147                     { type: 'entry', entry: items[2] },
148                     { type: 'entry', entry: items[3] },
149                 ],
150                 interpolationIndexes: [0],
151                 interpolated: true,
152                 clusters: expectedClusters,
153             });
154         });
156         test('should return all items in fallback cluster if `shouldInterpolate` is false', () => {
157             const now = 1000000000;
158             (getEpoch as jest.Mock).mockReturnValue(now);
160             const items = [
161                 createTestItem('login', {
162                     shareId: 'share1',
163                     itemId: 'item1',
164                     revision: 1,
165                     modifyTime: now - UNIX_DAY,
166                 }),
167             ];
169             expect(interpolateRecentItems(items)(false)).toEqual({
170                 interpolation: [{ type: 'entry', entry: items[0] }],
171                 interpolationIndexes: [],
172                 interpolated: false,
173                 clusters: [],
174             });
175         });
176     });
178     describe('filterItemsByShareId', () => {
179         test('should return all items if `shareId` is not defined', () => {
180             const items = [
181                 createTestItem('login', { shareId: 'share1', itemId: 'item1', revision: 1 }),
182                 createTestItem('login', { shareId: 'share2', itemId: 'item2', revision: 2 }),
183             ];
185             expect(filterItemsByShareId()(items)).toEqual(items);
186         });
188         test('should filter items by `shareId`', () => {
189             const items = [
190                 createTestItem('login', { shareId: 'share1', itemId: 'item1', revision: 1 }),
191                 createTestItem('login', { shareId: 'share2', itemId: 'item2', revision: 2 }),
192             ];
194             expect(filterItemsByShareId('share1')(items)).toEqual([items[0]]);
195         });
196     });
198     describe('filterItemsByType', () => {
199         test('should return all items if `itemType` is not defined', () => {
200             const items = [
201                 createTestItem('login', {
202                     shareId: 'share1',
203                     itemId: 'item1',
204                     revision: 1,
205                     data: itemBuilder('login').data,
206                 }),
207                 createTestItem('note', {
208                     shareId: 'share2',
209                     itemId: 'item2',
210                     revision: 2,
211                     data: itemBuilder('note').data,
212                 }),
213             ];
215             expect(filterItemsByType()(items)).toEqual(items);
216         });
218         test('should filter items by `itemType`', () => {
219             const items = [
220                 createTestItem('login', {
221                     shareId: 'share1',
222                     itemId: 'item1',
223                     revision: 1,
224                     data: itemBuilder('login').data,
225                 }),
226                 createTestItem('note', {
227                     shareId: 'share2',
228                     itemId: 'item2',
229                     revision: 2,
230                     data: itemBuilder('note').data,
231                 }),
232             ];
234             expect(filterItemsByType('login')(items)).toEqual([items[0]]);
235         });
236     });
238     describe('filterItemsByUserIdentifier', () => {
239         test('should filter items by user identifier', () => {
240             const login1 = itemBuilder('login');
241             login1.get('content').set('itemEmail', 'user@example.com');
243             const login2 = itemBuilder('login');
244             login2.get('content').set('itemEmail', 'other@example.com');
246             const items = [
247                 createTestItem('login', {
248                     shareId: 'share1',
249                     itemId: 'item1',
250                     revision: 1,
251                     data: login1.data,
252                 }),
253                 createTestItem('login', {
254                     shareId: 'share2',
255                     itemId: 'item2',
256                     revision: 2,
257                     data: login2.data,
258                 }),
259             ] as LoginItem[];
261             expect(filterItemsByUserIdentifier('user@example.com')(items)).toEqual([items[0]]);
262         });
264         test('should return an empty array if no items match the user identifier', () => {
265             const login1 = itemBuilder('login');
266             login1.get('content').set('itemEmail', 'other@example.com');
268             const login2 = itemBuilder('login');
269             login2.get('content').set('itemEmail', 'another@example.com');
271             const items = [
272                 createTestItem('login', {
273                     shareId: 'share1',
274                     itemId: 'item1',
275                     revision: 1,
276                     data: login1.data,
277                 }),
278                 createTestItem('login', {
279                     shareId: 'share2',
280                     itemId: 'item2',
281                     revision: 2,
282                     data: login2.data,
283                 }),
284             ] as LoginItem[];
286             expect(filterItemsByUserIdentifier('user@example.com')(items)).toEqual([]);
287         });
288     });
290     describe('sortItems', () => {
291         const items = [
292             createTestItem('login', {
293                 shareId: 'share1',
294                 itemId: 'item1',
295                 revision: 1,
296                 createTime: 1000,
297                 modifyTime: 2000,
298             }),
299             createTestItem('login', {
300                 shareId: 'share2',
301                 itemId: 'item2',
302                 revision: 2,
303                 createTime: 2000,
304                 modifyTime: 3000,
305             }),
306             createTestItem('login', {
307                 shareId: 'share3',
308                 itemId: 'item3',
309                 revision: 3,
310                 createTime: 3000,
311                 modifyTime: 1000,
312             }),
313         ];
315         test('should sort items by createTimeASC', () => {
316             const sortedItems = sortItems('createTimeASC')(items);
317             expect(sortedItems.map((item) => item.itemId)).toEqual(['item1', 'item2', 'item3']);
318         });
320         test('should sort items by createTimeDESC', () => {
321             const sortedItems = sortItems('createTimeDESC')(items);
322             expect(sortedItems.map((item) => item.itemId)).toEqual(['item3', 'item2', 'item1']);
323         });
325         test('should sort items by most recent (lastUseTime or modifyTime)', () => {
326             const sortedItems = sortItems('recent')(items);
327             expect(sortedItems.map((item) => item.itemId)).toEqual(['item2', 'item1', 'item3']);
328         });
330         test('should sort items by titleASC', () => {
331             const login1 = itemBuilder('login');
332             login1.get('metadata').set('name', 'Zebra');
334             const login2 = itemBuilder('login');
335             login2.get('metadata').set('name', 'Apple');
337             const login3 = itemBuilder('login');
338             login3.get('metadata').set('name', 'Mango');
340             const itemsWithTitles = [
341                 createTestItem('login', { shareId: 'share1', itemId: 'item1', revision: 1, data: login1.data }),
342                 createTestItem('login', { shareId: 'share2', itemId: 'item2', revision: 2, data: login2.data }),
343                 createTestItem('login', { shareId: 'share3', itemId: 'item3', revision: 3, data: login3.data }),
344             ];
345             const sortedItems = sortItems('titleASC')(itemsWithTitles);
346             expect(sortedItems.map((item) => item.data.metadata.name)).toEqual(['Apple', 'Mango', 'Zebra']);
347         });
348     });
350     describe('matchDraftsForShare', () => {
351         const drafts: Draft[] = [
352             { mode: 'edit', shareId: 'share1', itemId: 'item1', revision: 1, formData: null },
353             { mode: 'edit', shareId: 'share2', itemId: 'item2', revision: 2, formData: null },
354             { mode: 'edit', shareId: 'share1', itemId: 'item3', revision: 3, formData: null },
355         ];
357         test('should filter drafts by `shareId` only', () => {
358             const filteredDrafts = matchDraftsForShare(drafts, 'share1');
359             expect(filteredDrafts).toEqual([drafts[0], drafts[2]]);
360         });
362         test('should filter drafts by `shareId` and `itemIds`', () => {
363             const filteredDrafts = matchDraftsForShare(drafts, 'share1', ['item1']);
364             expect(filteredDrafts).toEqual([drafts[0]]);
365         });
366     });
368     describe('batchByShareId', () => {
369         const items = [
370             createTestItem('login', { shareId: 'share1', itemId: 'item1', revision: 1 }),
371             createTestItem('login', { shareId: 'share1', itemId: 'item2', revision: 2 }),
372             createTestItem('login', { shareId: 'share2', itemId: 'item3', revision: 3 }),
373         ];
375         test('should batch items by `shareId`', () => {
376             const batches = batchByShareId(items, (item) => ({ itemId: item.itemId, revision: item.revision }));
378             expect(batches).toEqual([
379                 {
380                     shareId: 'share1',
381                     items: [
382                         { itemId: 'item1', revision: 1 },
383                         { itemId: 'item2', revision: 2 },
384                     ],
385                 },
386                 { shareId: 'share2', items: [{ itemId: 'item3', revision: 3 }] },
387             ]);
388         });
390         test('should handle empty items array', () => {
391             const batches = batchByShareId([], (item) => item);
392             expect(batches).toEqual([]);
393         });
394     });
396     describe('intoRevisionID', () => {
397         const item = createTestItem('login', { shareId: 'share1', itemId: 'item1', revision: 1 });
399         test('should convert an item to a revision ID', () => {
400             const revisionID = intoRevisionID(item);
401             expect(revisionID).toStrictEqual({ ItemID: 'item1', Revision: 1 });
402         });
403     });
405     describe('intoUserIdentifier', () => {
406         const login = itemBuilder('login');
408         test('should return username if not empty', () => {
409             login.get('content').set('itemUsername', 'username').set('itemEmail', 'email@example.com');
410             const item = createTestItem('login', { data: login.data }) as LoginItem;
411             expect(intoUserIdentifier(item)).toBe('username');
412         });
414         test('should return email if username is empty', () => {
415             login.get('content').set('itemUsername', '').set('itemEmail', 'email@example.com');
416             const item = createTestItem('login', { data: login.data }) as LoginItem;
417             expect(intoUserIdentifier(item)).toBe('email@example.com');
418         });
419     });
421     describe('intoLoginItemPreview', () => {
422         const login = itemBuilder('login');
423         login.get('content').set('urls', ['https://example.com']).set('itemUsername', 'username');
424         login.get('metadata').set('name', 'My Login');
426         const item = createTestItem('login', {
427             shareId: 'share1',
428             itemId: 'item1',
429             data: login.data,
430         }) as LoginItem;
432         test('should convert an item to a LoginItemPreview', () => {
433             expect(intoLoginItemPreview(item)).toStrictEqual({
434                 itemId: 'item1',
435                 shareId: 'share1',
436                 name: 'My Login',
437                 userIdentifier: 'username',
438                 url: 'https://example.com',
439             });
440         });
441     });
443     describe('intoIdentityItemPreview', () => {
444         const identity = itemBuilder('identity');
445         identity.get('content').set('fullName', 'John Doe');
446         identity.get('metadata').set('name', 'My Identity');
448         const item = createTestItem('identity', {
449             shareId: 'share1',
450             itemId: 'item1',
451             data: identity.data,
452         }) as ItemRevision<'identity'>;
454         test('should convert an item to an IdentityItemPreview', () => {
455             expect(intoIdentityItemPreview(item)).toStrictEqual({
456                 itemId: 'item1',
457                 shareId: 'share1',
458                 name: 'My Identity',
459                 fullName: 'John Doe',
460             });
461         });
462     });
464     describe('getSanitizedUserIdentifiers', () => {
465         test.each([
466             [
467                 'valid email & empty username',
468                 { itemEmail: 'test@proton.me', itemUsername: '' },
469                 { email: 'test@proton.me', username: '' },
470             ],
471             [
472                 'valid email & invalid username email',
473                 { itemEmail: 'test@proton.me', itemUsername: 'username' },
474                 { email: 'test@proton.me', username: 'username' },
475             ],
476             [
477                 'valid email & valid username email',
478                 { itemEmail: 'test@proton.me', itemUsername: 'test@proton.me' },
479                 { email: 'test@proton.me', username: 'test@proton.me' },
480             ],
481             [
482                 'invalid email & empty username',
483                 { itemEmail: 'invalid-email', itemUsername: '' },
484                 { email: '', username: 'invalid-email' },
485             ],
486             [
487                 'invalid email & valid username email',
488                 { itemEmail: 'invalid-email', itemUsername: 'test@proton.me' },
489                 { email: 'test@proton.me', username: 'invalid-email' },
490             ],
491             [
492                 'empty email & valid username email',
493                 { itemEmail: '', itemUsername: 'valid@example.com' },
494                 { email: 'valid@example.com', username: '' },
495             ],
496             [
497                 'empty email & invalid username email',
498                 { itemEmail: '', itemUsername: 'invalid-email' },
499                 { email: '', username: 'invalid-email' },
500             ],
501             [
502                 'empty email & valid username email',
503                 { itemEmail: '', itemUsername: 'valid@proton.me' },
504                 { email: 'valid@proton.me', username: '' },
505             ],
506             ['empty email, empty username', { itemEmail: '', itemUsername: '' }, { email: '', username: '' }],
507         ])('should handle %s correctly', (_, input, expected) => {
508             const result = getSanitizedUserIdentifiers(input);
509             expect(result).toEqual(expected);
510         });
511     });