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';
12 filterItemsByUserIdentifier,
13 flattenItemsByShareId,
17 getSanitizedUserIdentifiers,
18 interpolateRecentItems,
19 intoIdentityItemPreview,
26 } from './item.utils';
28 jest.mock('@proton/pass/utils/time/epoch', () => ({
29 ...jest.requireActual('@proton/pass/utils/time/epoch'),
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');
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');
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);
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');
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');
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');
79 describe('flattenItemsByShareId', () => {
80 test('should return a flat array of `ItemRevision` objects', () => {
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 }),
87 const input: IndexedByShareIdAndItemId<ItemRevision> = {
88 share123: { item1: revisions[0], item2: revisions[1] },
89 share456: { item3: revisions[2] },
92 const result = flattenItemsByShareId(input);
93 expect(result).toEqual(revisions);
96 test('should return an empty array if no items are present', () => {
98 const result = flattenItemsByShareId(input);
99 expect(result).toEqual([]);
103 describe('interpolateRecentItems', () => {
104 test('should interpolate items into correct date clusters', () => {
105 const now = 1000000000;
106 (getEpoch as jest.Mock).mockReturnValue(now);
109 createTestItem('login', {
113 modifyTime: now - UNIX_DAY / 2,
115 createTestItem('login', {
119 modifyTime: now - UNIX_DAY * 3,
121 createTestItem('login', {
125 modifyTime: now - UNIX_WEEK * 2 + UNIX_DAY,
127 createTestItem('login', {
131 modifyTime: now - UNIX_MONTH,
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 },
142 expect(interpolateRecentItems(items)(true)).toEqual({
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] },
150 interpolationIndexes: [0],
152 clusters: expectedClusters,
156 test('should return all items in fallback cluster if `shouldInterpolate` is false', () => {
157 const now = 1000000000;
158 (getEpoch as jest.Mock).mockReturnValue(now);
161 createTestItem('login', {
165 modifyTime: now - UNIX_DAY,
169 expect(interpolateRecentItems(items)(false)).toEqual({
170 interpolation: [{ type: 'entry', entry: items[0] }],
171 interpolationIndexes: [],
178 describe('filterItemsByShareId', () => {
179 test('should return all items if `shareId` is not defined', () => {
181 createTestItem('login', { shareId: 'share1', itemId: 'item1', revision: 1 }),
182 createTestItem('login', { shareId: 'share2', itemId: 'item2', revision: 2 }),
185 expect(filterItemsByShareId()(items)).toEqual(items);
188 test('should filter items by `shareId`', () => {
190 createTestItem('login', { shareId: 'share1', itemId: 'item1', revision: 1 }),
191 createTestItem('login', { shareId: 'share2', itemId: 'item2', revision: 2 }),
194 expect(filterItemsByShareId('share1')(items)).toEqual([items[0]]);
198 describe('filterItemsByType', () => {
199 test('should return all items if `itemType` is not defined', () => {
201 createTestItem('login', {
205 data: itemBuilder('login').data,
207 createTestItem('note', {
211 data: itemBuilder('note').data,
215 expect(filterItemsByType()(items)).toEqual(items);
218 test('should filter items by `itemType`', () => {
220 createTestItem('login', {
224 data: itemBuilder('login').data,
226 createTestItem('note', {
230 data: itemBuilder('note').data,
234 expect(filterItemsByType('login')(items)).toEqual([items[0]]);
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');
247 createTestItem('login', {
253 createTestItem('login', {
261 expect(filterItemsByUserIdentifier('user@example.com')(items)).toEqual([items[0]]);
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');
272 createTestItem('login', {
278 createTestItem('login', {
286 expect(filterItemsByUserIdentifier('user@example.com')(items)).toEqual([]);
290 describe('sortItems', () => {
292 createTestItem('login', {
299 createTestItem('login', {
306 createTestItem('login', {
315 test('should sort items by createTimeASC', () => {
316 const sortedItems = sortItems('createTimeASC')(items);
317 expect(sortedItems.map((item) => item.itemId)).toEqual(['item1', 'item2', 'item3']);
320 test('should sort items by createTimeDESC', () => {
321 const sortedItems = sortItems('createTimeDESC')(items);
322 expect(sortedItems.map((item) => item.itemId)).toEqual(['item3', 'item2', 'item1']);
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']);
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 }),
345 const sortedItems = sortItems('titleASC')(itemsWithTitles);
346 expect(sortedItems.map((item) => item.data.metadata.name)).toEqual(['Apple', 'Mango', 'Zebra']);
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 },
357 test('should filter drafts by `shareId` only', () => {
358 const filteredDrafts = matchDraftsForShare(drafts, 'share1');
359 expect(filteredDrafts).toEqual([drafts[0], drafts[2]]);
362 test('should filter drafts by `shareId` and `itemIds`', () => {
363 const filteredDrafts = matchDraftsForShare(drafts, 'share1', ['item1']);
364 expect(filteredDrafts).toEqual([drafts[0]]);
368 describe('batchByShareId', () => {
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 }),
375 test('should batch items by `shareId`', () => {
376 const batches = batchByShareId(items, (item) => ({ itemId: item.itemId, revision: item.revision }));
378 expect(batches).toEqual([
382 { itemId: 'item1', revision: 1 },
383 { itemId: 'item2', revision: 2 },
386 { shareId: 'share2', items: [{ itemId: 'item3', revision: 3 }] },
390 test('should handle empty items array', () => {
391 const batches = batchByShareId([], (item) => item);
392 expect(batches).toEqual([]);
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 });
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');
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');
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', {
432 test('should convert an item to a LoginItemPreview', () => {
433 expect(intoLoginItemPreview(item)).toStrictEqual({
437 userIdentifier: 'username',
438 url: 'https://example.com',
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', {
452 }) as ItemRevision<'identity'>;
454 test('should convert an item to an IdentityItemPreview', () => {
455 expect(intoIdentityItemPreview(item)).toStrictEqual({
459 fullName: 'John Doe',
464 describe('getSanitizedUserIdentifiers', () => {
467 'valid email & empty username',
468 { itemEmail: 'test@proton.me', itemUsername: '' },
469 { email: 'test@proton.me', username: '' },
472 'valid email & invalid username email',
473 { itemEmail: 'test@proton.me', itemUsername: 'username' },
474 { email: 'test@proton.me', username: 'username' },
477 'valid email & valid username email',
478 { itemEmail: 'test@proton.me', itemUsername: 'test@proton.me' },
479 { email: 'test@proton.me', username: 'test@proton.me' },
482 'invalid email & empty username',
483 { itemEmail: 'invalid-email', itemUsername: '' },
484 { email: '', username: 'invalid-email' },
487 'invalid email & valid username email',
488 { itemEmail: 'invalid-email', itemUsername: 'test@proton.me' },
489 { email: 'test@proton.me', username: 'invalid-email' },
492 'empty email & valid username email',
493 { itemEmail: '', itemUsername: 'valid@example.com' },
494 { email: 'valid@example.com', username: '' },
497 'empty email & invalid username email',
498 { itemEmail: '', itemUsername: 'invalid-email' },
499 { email: '', username: 'invalid-email' },
502 'empty email & valid username email',
503 { itemEmail: '', itemUsername: 'valid@proton.me' },
504 { email: 'valid@proton.me', username: '' },
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);