diff --git a/src/modules/shared/api/api-xbrowsersync/api-xbrowsersync.service.spec.ts b/src/modules/shared/api/api-xbrowsersync/api-xbrowsersync.service.spec.ts new file mode 100644 index 000000000..dd4c783ee --- /dev/null +++ b/src/modules/shared/api/api-xbrowsersync/api-xbrowsersync.service.spec.ts @@ -0,0 +1,221 @@ +import '../../../../test/mock-angular'; +import { $q } from '../../../../test/mock-services'; +import { + DailyNewSyncLimitReachedError, + DataOutOfSyncError, + InvalidServiceError, + NetworkConnectionError, + NotAcceptingNewSyncsError, + RequestEntityTooLargeError, + ServiceOfflineError, + SyncNotFoundError, + TooManyRequestsError +} from '../../errors/errors'; +import { ApiXbrowsersyncService } from './api-xbrowsersync.service'; + +describe('ApiXbrowsersyncService', () => { + let apiSvc: ApiXbrowsersyncService; + const mock$injector = { + get: jest.fn(), + annotate: jest.fn(), + has: jest.fn(), + instantiate: jest.fn(), + invoke: jest.fn(), + loadNewModules: jest.fn(), + modules: {}, + strictDi: false + } as any; + const mock$httpFn = jest.fn(); + const mock$http = Object.assign(mock$httpFn, { + get: jest.fn(), + post: jest.fn(), + put: jest.fn() + }) as any; + const mockNetworkSvc = { isNetworkConnected: jest.fn(), getErrorFromHttpResponse: jest.fn() } as any; + const mockStoreSvc = { get: jest.fn(), set: jest.fn() } as any; + const mockUtilitySvc = { + checkSyncCredentialsExist: jest.fn(), + compareVersions: jest.fn() + } as any; + + beforeEach(() => { + apiSvc = new ApiXbrowsersyncService(mock$injector, mock$http, $q, mockNetworkSvc, mockStoreSvc, mockUtilitySvc); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('apiRequestSucceeded: Returns resolved promise with response', async () => { + const testResponse = { data: 'test' }; + + const result = await apiSvc.apiRequestSucceeded(testResponse); + + expect(result).toStrictEqual(testResponse); + }); + + test('checkNetworkConnection: Resolves when network is connected', async () => { + mockNetworkSvc.isNetworkConnected.mockReturnValue(true); + + await expect(apiSvc.checkNetworkConnection()).resolves.toBeUndefined(); + }); + + test('checkNetworkConnection: Rejects with NetworkConnectionError when not connected', async () => { + mockNetworkSvc.isNetworkConnected.mockReturnValue(false); + + await expect(apiSvc.checkNetworkConnection()).rejects.toThrow(NetworkConnectionError); + }); + + test('getServiceUrl: Returns service URL from store', async () => { + mockStoreSvc.get.mockResolvedValue({ serviceUrl: 'https://api.example.com' }); + + const result = await apiSvc.getServiceUrl(); + + expect(result).toBe('https://api.example.com'); + }); + + // getErrorFromHttpResponse tests + test('getErrorFromHttpResponse: Returns SyncNotFoundError for 401', () => { + const response = { status: 401, data: { message: 'Not found' } } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(SyncNotFoundError); + }); + + test('getErrorFromHttpResponse: Returns InvalidServiceError for 404', () => { + const response = { status: 404, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(InvalidServiceError); + }); + + test('getErrorFromHttpResponse: Returns NotAcceptingNewSyncsError for 405', () => { + const response = { status: 405, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(NotAcceptingNewSyncsError); + }); + + test('getErrorFromHttpResponse: Returns DailyNewSyncLimitReachedError for 406', () => { + const response = { status: 406, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(DailyNewSyncLimitReachedError); + }); + + test('getErrorFromHttpResponse: Returns DataOutOfSyncError for 409', () => { + const response = { status: 409, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(DataOutOfSyncError); + }); + + test('getErrorFromHttpResponse: Returns RequestEntityTooLargeError for 413', () => { + const response = { status: 413, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(RequestEntityTooLargeError); + }); + + test('getErrorFromHttpResponse: Returns TooManyRequestsError for 429', () => { + const response = { status: 429, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(TooManyRequestsError); + }); + + test('getErrorFromHttpResponse: Returns ServiceOfflineError for 500+', () => { + const response = { status: 500, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(ServiceOfflineError); + }); + + test('getErrorFromHttpResponse: Returns ServiceOfflineError for 503', () => { + const response = { status: 503, data: {} } as any; + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBeInstanceOf(ServiceOfflineError); + }); + + test('getErrorFromHttpResponse: Delegates to networkSvc for other status codes', () => { + const response = { status: 400, data: {} } as any; + const mockError = new Error('network error'); + mockNetworkSvc.getErrorFromHttpResponse.mockReturnValue(mockError); + + const result = apiSvc.getErrorFromHttpResponse(response); + + expect(result).toBe(mockError); + }); + + test('handleFailedRequest: Throws error from getErrorFromHttpResponse', () => { + const response = { status: 401, data: { message: 'Unauthorized' } } as any; + + expect(() => apiSvc.handleFailedRequest(response)).toThrow(SyncNotFoundError); + }); + + test('checkServiceStatus: Rejects when not connected', async () => { + mockNetworkSvc.isNetworkConnected.mockReturnValue(false); + + await expect(apiSvc.checkServiceStatus('https://api.example.com')).rejects.toThrow(NetworkConnectionError); + }); + + test('checkServiceStatus: Validates service response', async () => { + mockNetworkSvc.isNetworkConnected.mockReturnValue(true); + mockUtilitySvc.compareVersions.mockReturnValue(false); + mock$httpFn.mockResolvedValue({ + data: { status: 1, version: '1.1.13' } + }); + + const result = await apiSvc.checkServiceStatus('https://api.example.com'); + + expect(result.status).toBe(1); + expect(result.version).toBe('1.1.13'); + }); + + test('checkServiceStatus: Throws InvalidServiceError for invalid response', async () => { + mockNetworkSvc.isNetworkConnected.mockReturnValue(true); + mock$httpFn.mockResolvedValue({ + data: {} + }); + + await expect(apiSvc.checkServiceStatus('https://api.example.com')).rejects.toThrow(InvalidServiceError); + }); + + test('checkServiceStatus: Throws UnsupportedApiVersionError for old API version', async () => { + mockNetworkSvc.isNetworkConnected.mockReturnValue(true); + mockUtilitySvc.compareVersions.mockReturnValue(true); + mock$httpFn.mockResolvedValue({ + data: { status: 1, version: '1.0.0' } + }); + + const { UnsupportedApiVersionError } = require('../../errors/errors'); + await expect(apiSvc.checkServiceStatus('https://api.example.com')).rejects.toThrow(UnsupportedApiVersionError); + }); + + test('formatServiceInfo: Returns undefined for no input', () => { + const result = apiSvc.formatServiceInfo(undefined); + + expect(result).toBeUndefined(); + }); + + test('formatServiceInfo: Converts maxSyncSize from bytes to KB', () => { + const result = apiSvc.formatServiceInfo({ + status: 1, + version: '1.1.13', + maxSyncSize: 524288 + } as any); + + expect(result.maxSyncSize).toBe(512); + }); +}); diff --git a/src/modules/shared/backup-restore/backup-restore.service.spec.ts b/src/modules/shared/backup-restore/backup-restore.service.spec.ts new file mode 100644 index 000000000..6a66c909e --- /dev/null +++ b/src/modules/shared/backup-restore/backup-restore.service.spec.ts @@ -0,0 +1,155 @@ +import '../../../test/mock-angular'; +import { $q } from '../../../test/mock-services'; +import { FailedRestoreDataError } from '../errors/errors'; +import { BackupRestoreService } from './backup-restore.service'; + +describe('BackupRestoreService', () => { + let backupRestoreSvc: BackupRestoreService; + const mockBookmarkSvc = { getBookmarksForExport: jest.fn() } as any; + const mockLogSvc = { logInfo: jest.fn() } as any; + const mockPlatformSvc = { + downloadFile: jest.fn(), + getAppVersion: jest.fn(), + queueSync: jest.fn() + } as any; + const mockStoreSvc = { get: jest.fn(), set: jest.fn() } as any; + const mockUpgradeSvc = { upgradeBookmarks: jest.fn() } as any; + const mockUtilitySvc = { + getDateTimeString: jest.fn().mockReturnValue('20230115103045'), + isSyncEnabled: jest.fn(), + getApiService: jest.fn() + } as any; + + beforeEach(() => { + backupRestoreSvc = new BackupRestoreService( + $q, + mockBookmarkSvc, + mockLogSvc, + mockPlatformSvc, + mockStoreSvc, + mockUpgradeSvc, + mockUtilitySvc + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('createBackupData: Creates backup object with correct structure', () => { + const bookmarks = [{ id: 1, title: 'Test', url: 'https://test.com' }]; + const syncInfo = { id: 'sync-id', serviceType: 'xbrowsersync' } as any; + + const result = backupRestoreSvc.createBackupData(bookmarks, syncInfo); + + expect(result.xbrowsersync).toBeDefined(); + expect(result.xbrowsersync.date).toBe('20230115103045'); + expect(result.xbrowsersync.sync).toStrictEqual(syncInfo); + expect(result.xbrowsersync.data.bookmarks).toStrictEqual(bookmarks); + }); + + test('getBackupFilename: Returns filename with timestamp', () => { + const result = backupRestoreSvc.getBackupFilename(); + + expect(result).toBe('xbs_backup_20230115103045.txt'); + }); + + test('getSyncInfo: Returns sync info without password', async () => { + mockStoreSvc.get.mockResolvedValue({ + id: 'sync-id', + password: 'secret', + serviceType: 'xbrowsersync', + version: '1.5.0' + }); + + const result = await backupRestoreSvc.getSyncInfo(); + + expect(result.id).toBe('sync-id'); + expect(result.serviceType).toBe('xbrowsersync'); + expect(result.version).toBe('1.5.0'); + expect((result as any).password).toBeUndefined(); + }); + + test('getSetAutoBackUpSchedule: Gets value from store when no argument', async () => { + const schedule = { autoBackUpHour: 2, autoBackUpMinute: 0, autoBackUpNumber: 1, autoBackUpUnit: 'day' }; + mockStoreSvc.get.mockResolvedValue(schedule); + + const result = await backupRestoreSvc.getSetAutoBackUpSchedule(); + + expect(result).toStrictEqual(schedule); + }); + + test('getSetAutoBackUpSchedule: Sets and returns value when argument provided', async () => { + const schedule = { autoBackUpHour: 3, autoBackUpMinute: 30, autoBackUpNumber: 2, autoBackUpUnit: 'day' }; + mockStoreSvc.set.mockResolvedValue(); + + const result = await backupRestoreSvc.getSetAutoBackUpSchedule(schedule as any); + + expect(mockStoreSvc.set).toBeCalled(); + expect(result).toStrictEqual(schedule); + }); + + test('getSetAutoBackUpSchedule: Logs message when clearing schedule', async () => { + mockStoreSvc.set.mockResolvedValue(); + + await backupRestoreSvc.getSetAutoBackUpSchedule(null); + + expect(mockLogSvc.logInfo).toBeCalledWith('Auto back up schedule cleared'); + }); + + test('restoreBackupData: Throws FailedRestoreDataError for invalid backup format', () => { + const badData = { invalid: 'data' } as any; + + expect(() => backupRestoreSvc.restoreBackupData(badData)).toThrow(FailedRestoreDataError); + }); + + test('restoreBackupData: Handles v1.5.0+ backup format with xbrowsersync key', async () => { + const backupData = { + xbrowsersync: { + date: '20230101120000', + sync: { id: 'sync-id', serviceType: 'xbrowsersync', version: '1.5.0' }, + data: { + bookmarks: [{ id: 1, title: 'Test', url: 'https://test.com' }] + } + } + } as any; + mockPlatformSvc.getAppVersion.mockResolvedValue('1.5.0'); + mockUpgradeSvc.upgradeBookmarks.mockResolvedValue(backupData.xbrowsersync.data.bookmarks); + mockUtilitySvc.isSyncEnabled.mockResolvedValue(false); + mockStoreSvc.set.mockResolvedValue(); + mockPlatformSvc.queueSync.mockResolvedValue(); + + await backupRestoreSvc.restoreBackupData(backupData); + + expect(mockPlatformSvc.queueSync).toBeCalled(); + }); + + test('restoreBackupData: Handles pre-v1.5.0 backup format with xBrowserSync key', async () => { + const backupData = { + xBrowserSync: { + bookmarks: [{ id: 1, title: 'Test', url: 'https://test.com' }] + } + } as any; + mockPlatformSvc.getAppVersion.mockResolvedValue('1.5.0'); + mockUpgradeSvc.upgradeBookmarks.mockResolvedValue(backupData.xBrowserSync.bookmarks); + mockUtilitySvc.isSyncEnabled.mockResolvedValue(false); + mockStoreSvc.set.mockResolvedValue(); + mockPlatformSvc.queueSync.mockResolvedValue(); + + await backupRestoreSvc.restoreBackupData(backupData); + + expect(mockPlatformSvc.queueSync).toBeCalled(); + }); + + test('saveBackupFile: Creates and downloads backup file', async () => { + mockBookmarkSvc.getBookmarksForExport.mockResolvedValue([{ id: 1, title: 'Test' }]); + mockStoreSvc.get.mockResolvedValue({ id: 'sync-id', serviceType: 'xbrowsersync' }); + mockUtilitySvc.isSyncEnabled.mockResolvedValue(true); + mockPlatformSvc.downloadFile.mockResolvedValue(); + + await backupRestoreSvc.saveBackupFile(true); + + expect(mockPlatformSvc.downloadFile).toBeCalled(); + }); +}); diff --git a/src/modules/shared/bookmark/bookmark-helper/bookmark-helper.service.spec.ts b/src/modules/shared/bookmark/bookmark-helper/bookmark-helper.service.spec.ts new file mode 100644 index 000000000..09e380bb8 --- /dev/null +++ b/src/modules/shared/bookmark/bookmark-helper/bookmark-helper.service.spec.ts @@ -0,0 +1,583 @@ +import '../../../../test/mock-angular'; +import { $q } from '../../../../test/mock-services'; +import Globals from '../../global-shared.constants'; +import { BookmarkContainer, BookmarkType } from '../bookmark.enum'; +import { Bookmark } from '../bookmark.interface'; +import { BookmarkHelperService } from './bookmark-helper.service'; + +describe('BookmarkHelperService', () => { + let bookmarkHelperSvc: BookmarkHelperService; + const mock$injector = { + get: jest.fn(), + annotate: jest.fn(), + has: jest.fn(), + instantiate: jest.fn(), + invoke: jest.fn(), + loadNewModules: jest.fn(), + modules: {}, + strictDi: false + } as any; + const mockCryptoSvc = { decryptData: jest.fn() } as any; + const mockStoreSvc = { get: jest.fn(), set: jest.fn() } as any; + const mockUtilitySvc = { + trimToNearestWord: jest.fn((text: string) => text), + splitTextIntoWords: jest.fn((text: string) => (text ? text.toLowerCase().split(/\s+/).filter(Boolean) : [])), + sortWords: jest.fn((words: string[]) => [...new Set(words)].sort()), + stringsAreEquivalent: jest.fn((a: string, b: string) => a === b), + filterFalsyValues: jest.fn((values: string[]) => values.filter((x) => x)) + } as any; + + beforeEach(() => { + bookmarkHelperSvc = new BookmarkHelperService(mock$injector, $q, mockCryptoSvc, mockStoreSvc, mockUtilitySvc); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + // cleanBookmark tests + test('cleanBookmark: Removes invalid keys from bookmark', () => { + const bookmark: any = { + title: 'Test', + url: 'https://example.com', + invalidKey: 'should be removed', + anotherInvalid: 123 + }; + + const result = bookmarkHelperSvc.cleanBookmark(bookmark); + + expect(result.title).toBe('Test'); + expect(result.url).toBe('https://example.com'); + expect((result as any).invalidKey).toBeUndefined(); + expect((result as any).anotherInvalid).toBeUndefined(); + }); + + test('cleanBookmark: Removes empty description', () => { + const bookmark: Bookmark = { + title: 'Test', + url: 'https://example.com', + description: ' ' + }; + + const result = bookmarkHelperSvc.cleanBookmark(bookmark); + + expect(result.description).toBeUndefined(); + }); + + test('cleanBookmark: Keeps non-empty description', () => { + const bookmark: Bookmark = { + title: 'Test', + url: 'https://example.com', + description: 'A valid description' + }; + + const result = bookmarkHelperSvc.cleanBookmark(bookmark); + + expect(result.description).toBe('A valid description'); + }); + + test('cleanBookmark: Removes empty tags array', () => { + const bookmark: Bookmark = { + title: 'Test', + url: 'https://example.com', + tags: [] + }; + + const result = bookmarkHelperSvc.cleanBookmark(bookmark); + + expect(result.tags).toBeUndefined(); + }); + + test('cleanBookmark: Keeps non-empty tags array', () => { + const bookmark: Bookmark = { + title: 'Test', + url: 'https://example.com', + tags: ['tag1', 'tag2'] + }; + + const result = bookmarkHelperSvc.cleanBookmark(bookmark); + + expect(result.tags).toStrictEqual(['tag1', 'tag2']); + }); + + // cleanAllBookmarks tests + test('cleanAllBookmarks: Cleans all bookmarks recursively', () => { + const bookmarks: any[] = [ + { + title: 'Folder', + children: [ + { title: 'Child', url: 'https://example.com', invalidKey: 'removed' } + ], + invalidKey: 'removed' + } + ]; + + const result = bookmarkHelperSvc.cleanAllBookmarks(bookmarks); + + expect(result[0].title).toBe('Folder'); + expect((result[0] as any).invalidKey).toBeUndefined(); + expect(result[0].children[0].title).toBe('Child'); + expect((result[0].children[0] as any).invalidKey).toBeUndefined(); + }); + + // getBookmarkType tests + test('getBookmarkType: Returns Container for Menu bookmark', () => { + const bookmark: Bookmark = { title: BookmarkContainer.Menu, children: [] }; + + const result = bookmarkHelperSvc.getBookmarkType(bookmark); + + expect(result).toBe(BookmarkType.Container); + }); + + test('getBookmarkType: Returns Container for Other bookmark', () => { + const bookmark: Bookmark = { title: BookmarkContainer.Other, children: [] }; + + const result = bookmarkHelperSvc.getBookmarkType(bookmark); + + expect(result).toBe(BookmarkType.Container); + }); + + test('getBookmarkType: Returns Container for Toolbar bookmark', () => { + const bookmark: Bookmark = { title: BookmarkContainer.Toolbar, children: [] }; + + const result = bookmarkHelperSvc.getBookmarkType(bookmark); + + expect(result).toBe(BookmarkType.Container); + }); + + test('getBookmarkType: Returns Folder for bookmark with children array', () => { + const bookmark: Bookmark = { title: 'My Folder', children: [] }; + + const result = bookmarkHelperSvc.getBookmarkType(bookmark); + + expect(result).toBe(BookmarkType.Folder); + }); + + test('getBookmarkType: Returns Separator for bookmark with separator URL', () => { + const bookmark: Bookmark = { url: Globals.Bookmarks.SeparatorUrl }; + + const result = bookmarkHelperSvc.getBookmarkType(bookmark); + + expect(result).toBe(BookmarkType.Separator); + }); + + test('getBookmarkType: Returns Bookmark for regular bookmark', () => { + const bookmark: Bookmark = { title: 'Test', url: 'https://example.com' }; + + const result = bookmarkHelperSvc.getBookmarkType(bookmark); + + expect(result).toBe(BookmarkType.Bookmark); + }); + + // findBookmarkById tests + test('findBookmarkById: Finds bookmark at top level', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: 'First', url: 'https://first.com' }, + { id: 2, title: 'Second', url: 'https://second.com' } + ]; + + const result = bookmarkHelperSvc.findBookmarkById(2, bookmarks); + + expect(result).toBeDefined(); + expect((result as Bookmark).title).toBe('Second'); + }); + + test('findBookmarkById: Finds bookmark in nested children', () => { + const bookmarks: Bookmark[] = [ + { + id: 1, + title: 'Folder', + children: [ + { id: 2, title: 'Child', url: 'https://child.com' } + ] + } + ]; + + const result = bookmarkHelperSvc.findBookmarkById(2, bookmarks); + + expect(result).toBeDefined(); + expect((result as Bookmark).title).toBe('Child'); + }); + + test('findBookmarkById: Returns undefined when id not found', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: 'First', url: 'https://first.com' } + ]; + + const result = bookmarkHelperSvc.findBookmarkById(999, bookmarks); + + expect(result).toBeUndefined(); + }); + + test('findBookmarkById: Returns undefined for undefined id', () => { + const bookmarks: Bookmark[] = [{ id: 1, title: 'Test' }]; + + const result = bookmarkHelperSvc.findBookmarkById(undefined as any, bookmarks); + + expect(result).toBeUndefined(); + }); + + // eachBookmark tests + test('eachBookmark: Iterates through all bookmarks', () => { + const titles: string[] = []; + const bookmarks: Bookmark[] = [ + { + title: 'Folder', + children: [ + { title: 'Child1', url: 'https://child1.com' }, + { title: 'Child2', url: 'https://child2.com' } + ] + }, + { title: 'Root', url: 'https://root.com' } + ]; + + bookmarkHelperSvc.eachBookmark((bookmark) => { + titles.push(bookmark.title); + }, bookmarks); + + expect(titles).toStrictEqual(['Folder', 'Child1', 'Child2', 'Root']); + }); + + test('eachBookmark: Stops iterating when condition is met', () => { + const titles: string[] = []; + const bookmarks: Bookmark[] = [ + { title: 'First', url: 'https://first.com' }, + { title: 'Second', url: 'https://second.com' }, + { title: 'Third', url: 'https://third.com' } + ]; + let found = false; + + bookmarkHelperSvc.eachBookmark( + (bookmark) => { + titles.push(bookmark.title); + if (bookmark.title === 'Second') { + found = true; + } + }, + bookmarks, + () => found + ); + + expect(titles).toStrictEqual(['First', 'Second']); + }); + + // getContainer tests + test('getContainer: Returns container by name', () => { + const bookmarks: Bookmark[] = [ + { title: BookmarkContainer.Menu, children: [] }, + { title: BookmarkContainer.Other, children: [] } + ]; + + const result = bookmarkHelperSvc.getContainer(BookmarkContainer.Menu, bookmarks); + + expect(result).toBeDefined(); + expect(result.title).toBe(BookmarkContainer.Menu); + }); + + test('getContainer: Returns undefined when container not found', () => { + const bookmarks: Bookmark[] = [ + { title: BookmarkContainer.Menu, children: [] } + ]; + + const result = bookmarkHelperSvc.getContainer(BookmarkContainer.Other, bookmarks); + + expect(result).toBeUndefined(); + }); + + test('getContainer: Creates container when not found and createIfNotPresent is true', () => { + const bookmarks: Bookmark[] = [ + { title: BookmarkContainer.Menu, children: [] } + ]; + + const result = bookmarkHelperSvc.getContainer(BookmarkContainer.Other, bookmarks, true); + + expect(result).toBeDefined(); + expect(bookmarks.length).toBe(2); + }); + + // getNewBookmarkId tests + test('getNewBookmarkId: Returns highest id + 1', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: 'First', url: 'https://first.com' }, + { id: 5, title: 'Second', url: 'https://second.com' }, + { id: 3, title: 'Third', url: 'https://third.com' } + ]; + + const result = bookmarkHelperSvc.getNewBookmarkId(bookmarks); + + expect(result).toBe(6); + }); + + test('getNewBookmarkId: Considers taken ids', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: 'First', url: 'https://first.com' } + ]; + + const result = bookmarkHelperSvc.getNewBookmarkId(bookmarks, [0, 10]); + + expect(result).toBe(11); + }); + + test('getNewBookmarkId: Returns 1 for empty bookmarks', () => { + const result = bookmarkHelperSvc.getNewBookmarkId([]); + + expect(result).toBe(1); + }); + + // newBookmark tests + test('newBookmark: Creates a bookmark with url (no children)', () => { + const result = bookmarkHelperSvc.newBookmark('Test', 'https://example.com', 'Description', ['tag1']); + + expect(result.title).toBe('Test'); + expect(result.url).toBe('https://example.com'); + expect(result.children).toBeUndefined(); + }); + + test('newBookmark: Creates a folder without url (with children)', () => { + const result = bookmarkHelperSvc.newBookmark('Folder'); + + expect(result.title).toBe('Folder'); + expect(result.url).toBeUndefined(); + expect(result.children).toStrictEqual([]); + }); + + test('newBookmark: Generates id when bookmarks provided', () => { + const existingBookmarks: Bookmark[] = [ + { id: 5, title: 'Existing', url: 'https://existing.com' } + ]; + + const result = bookmarkHelperSvc.newBookmark('New', 'https://new.com', undefined, undefined, existingBookmarks); + + expect(result.id).toBe(6); + }); + + // removeEmptyContainers tests + test('removeEmptyContainers: Removes containers with no children', () => { + const bookmarks: Bookmark[] = [ + { title: BookmarkContainer.Menu, children: [] }, + { title: BookmarkContainer.Other, children: [{ title: 'Child', url: 'https://child.com' }] }, + { title: BookmarkContainer.Toolbar, children: [] } + ]; + + const result = bookmarkHelperSvc.removeEmptyContainers(bookmarks); + + expect(result.length).toBe(1); + expect(result[0].title).toBe(BookmarkContainer.Other); + }); + + test('removeEmptyContainers: Keeps all containers when all have children', () => { + const bookmarks: Bookmark[] = [ + { title: BookmarkContainer.Menu, children: [{ title: 'A', url: 'https://a.com' }] }, + { title: BookmarkContainer.Other, children: [{ title: 'B', url: 'https://b.com' }] }, + { title: BookmarkContainer.Toolbar, children: [{ title: 'C', url: 'https://c.com' }] } + ]; + + const result = bookmarkHelperSvc.removeEmptyContainers(bookmarks); + + expect(result.length).toBe(3); + }); + + // getContainerByBookmarkId tests + test('getContainerByBookmarkId: Returns container when id matches container', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: BookmarkContainer.Menu, children: [] }, + { id: 2, title: BookmarkContainer.Other, children: [] } + ]; + + const result = bookmarkHelperSvc.getContainerByBookmarkId(1, bookmarks); + + expect(result).toBeDefined(); + expect(result.title).toBe(BookmarkContainer.Menu); + }); + + test('getContainerByBookmarkId: Returns parent container for child bookmark', () => { + const bookmarks: Bookmark[] = [ + { + id: 1, + title: BookmarkContainer.Menu, + children: [ + { id: 10, title: 'Child', url: 'https://child.com' } + ] + }, + { id: 2, title: BookmarkContainer.Other, children: [] } + ]; + + const result = bookmarkHelperSvc.getContainerByBookmarkId(10, bookmarks); + + expect(result).toBeDefined(); + expect(result.title).toBe(BookmarkContainer.Menu); + }); + + // searchBookmarksByKeywords tests + test('searchBookmarksByKeywords: Returns matching bookmarks', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: 'JavaScript Tutorial', url: 'https://js.com', tags: ['javascript'] }, + { id: 2, title: 'Python Guide', url: 'https://py.com', tags: ['python'] } + ]; + + const results = bookmarkHelperSvc.searchBookmarksByKeywords(bookmarks, 'en', ['javascript']); + + expect(results.length).toBe(1); + expect(results[0].title).toBe('JavaScript Tutorial'); + }); + + test('searchBookmarksByKeywords: Returns empty array when no matches', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: 'JavaScript Tutorial', url: 'https://js.com' } + ]; + + const results = bookmarkHelperSvc.searchBookmarksByKeywords(bookmarks, 'en', ['ruby']); + + expect(results.length).toBe(0); + }); + + test('searchBookmarksByKeywords: Searches children of folders', () => { + const bookmarks: Bookmark[] = [ + { + title: 'Dev Folder', + children: [ + { id: 1, title: 'JavaScript Tutorial', url: 'https://js.com', tags: ['javascript'] } + ] + } + ]; + + const results = bookmarkHelperSvc.searchBookmarksByKeywords(bookmarks, 'en', ['javascript']); + + expect(results.length).toBe(1); + }); + + test('searchBookmarksByKeywords: Ignores separators', () => { + const bookmarks: Bookmark[] = [ + { url: Globals.Bookmarks.SeparatorUrl }, + { id: 1, title: 'Test', url: 'https://test.com', tags: ['test'] } + ]; + + const results = bookmarkHelperSvc.searchBookmarksByKeywords(bookmarks, 'en', ['test']); + + expect(results.length).toBe(1); + }); + + // searchBookmarksByUrl tests + test('searchBookmarksByUrl: Finds bookmarks by URL', () => { + const bookmarks: Bookmark[] = [ + { id: 1, title: 'Example', url: 'https://example.com/page1' }, + { id: 2, title: 'Other', url: 'https://other.com' } + ]; + + const results = bookmarkHelperSvc.searchBookmarksByUrl(bookmarks, 'example.com', 'en'); + + expect(results.length).toBe(1); + expect(results[0].title).toBe('Example'); + }); + + test('searchBookmarksByUrl: Searches recursively in children', () => { + const bookmarks: Bookmark[] = [ + { + title: 'Folder', + children: [ + { id: 1, title: 'Deep', url: 'https://example.com/deep' } + ] + } + ]; + + const results = bookmarkHelperSvc.searchBookmarksByUrl(bookmarks, 'example.com', 'en'); + + expect(results.length).toBe(1); + }); + + // extractBookmarkMetadata tests + test('extractBookmarkMetadata: Extracts metadata from bookmark', () => { + const bookmark: Bookmark = { + title: 'Test', + url: 'https://example.com', + description: 'A description', + tags: ['tag1'] + }; + + const result = bookmarkHelperSvc.extractBookmarkMetadata(bookmark); + + expect(result.title).toBe('Test'); + expect(result.url).toBe('https://example.com'); + expect(result.description).toBe('A description'); + expect(result.tags).toStrictEqual(['tag1']); + }); + + test('extractBookmarkMetadata: Removes undefined properties', () => { + const bookmark: Bookmark = { + title: 'Test', + url: 'https://example.com' + }; + + const result = bookmarkHelperSvc.extractBookmarkMetadata(bookmark); + + expect(result.title).toBe('Test'); + expect(result.url).toBe('https://example.com'); + expect(result.description).toBeUndefined(); + expect(result.tags).toBeUndefined(); + }); + + // modifyBookmarkById tests + test('modifyBookmarkById: Throws BookmarkNotFoundError when bookmark not found', () => { + const bookmarks: Bookmark[] = [{ id: 1, title: 'Test', url: 'https://test.com' }]; + + expect(() => + bookmarkHelperSvc.modifyBookmarkById(999, { title: 'Updated' }, bookmarks) + ).toThrow(); + }); + + test('modifyBookmarkById: Updates bookmark metadata', async () => { + const bookmarks: Bookmark[] = [ + { + id: 1, + title: BookmarkContainer.Menu, + children: [ + { id: 2, title: 'Old Title', url: 'https://old.com' } + ] + } + ]; + + const result = await bookmarkHelperSvc.modifyBookmarkById( + 2, + { title: 'New Title', url: 'https://new.com' }, + bookmarks + ); + + const modified = bookmarkHelperSvc.findBookmarkById(2, result) as Bookmark; + expect(modified.title).toBe('New Title'); + }); + + // removeBookmarkById tests + test('removeBookmarkById: Removes bookmark from children', async () => { + const bookmarks: Bookmark[] = [ + { + id: 1, + title: BookmarkContainer.Menu, + children: [ + { id: 2, title: 'Keep', url: 'https://keep.com' }, + { id: 3, title: 'Remove', url: 'https://remove.com' } + ] + } + ]; + + const result = await bookmarkHelperSvc.removeBookmarkById(3, bookmarks); + + expect(result[0].children.length).toBe(1); + expect(result[0].children[0].title).toBe('Keep'); + }); + + // searchBookmarksForLookaheads tests + test('searchBookmarksForLookaheads: Returns matching words starting with input', () => { + mockUtilitySvc.splitTextIntoWords.mockReturnValue(['javascript', 'java', 'python']); + + const bookmarks: Bookmark[] = [ + { id: 1, title: 'JavaScript Tutorial', url: 'https://js.com', tags: ['javascript'] } + ]; + + const results = bookmarkHelperSvc.searchBookmarksForLookaheads('jav', 'en', false, bookmarks); + + expect(results).toContain('javascript'); + expect(results).toContain('java'); + expect(results).not.toContain('python'); + }); +}); diff --git a/src/modules/shared/crypto/crypto.service.spec.ts b/src/modules/shared/crypto/crypto.service.spec.ts new file mode 100644 index 000000000..eb3062710 --- /dev/null +++ b/src/modules/shared/crypto/crypto.service.spec.ts @@ -0,0 +1,232 @@ +import { TextEncoder as NodeTextEncoder, TextDecoder as NodeTextDecoder } from 'util'; +(global as any).TextEncoder = (global as any).TextEncoder || NodeTextEncoder; +(global as any).TextDecoder = (global as any).TextDecoder || NodeTextDecoder; + +import '../../../test/mock-angular'; + +jest.mock('lzutf8', () => { + const impl = { + compress: (data: string) => new (global as any).TextEncoder().encode(data), + decompress: (data: Uint8Array) => new (global as any).TextDecoder().decode(data) + }; + return { ...impl, default: impl, __esModule: true }; +}); + +jest.mock('base64-js', () => { + const impl = { + toByteArray: (base64: string) => new Uint8Array(Buffer.from(base64, 'base64')), + fromByteArray: (bytes: Uint8Array) => Buffer.from(bytes).toString('base64') + }; + return { + ...impl, + default: impl, + __esModule: true + }; +}); + +import { $q } from '../../../test/mock-services'; +import { ArgumentError, InvalidCredentialsError } from '../errors/errors'; +import { CryptoService } from './crypto.service'; + +describe('CryptoService', () => { + let cryptoSvc: CryptoService; + const mockLogSvc = { logWarning: jest.fn(), logInfo: jest.fn() } as any; + const mockStoreSvc = { get: jest.fn(), set: jest.fn() } as any; + const mockUtilitySvc = { + checkSyncCredentialsExist: jest.fn(), + getSyncVersion: jest.fn() + } as any; + + beforeEach(() => { + cryptoSvc = new CryptoService($q, mockLogSvc, mockStoreSvc, mockUtilitySvc); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('concatUint8Arrays: Concatenates two arrays', () => { + const first = new Uint8Array([1, 2, 3]); + const second = new Uint8Array([4, 5, 6]); + + const result = cryptoSvc.concatUint8Arrays(first, second); + + expect(result).toStrictEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + }); + + test('concatUint8Arrays: Returns second array when first is empty', () => { + const result = cryptoSvc.concatUint8Arrays(new Uint8Array(), new Uint8Array([4, 5])); + + expect(result).toStrictEqual(new Uint8Array([4, 5])); + }); + + test('concatUint8Arrays: Returns first array when second is empty', () => { + const result = cryptoSvc.concatUint8Arrays(new Uint8Array([1, 2]), new Uint8Array()); + + expect(result).toStrictEqual(new Uint8Array([1, 2])); + }); + + test('concatUint8Arrays: Handles undefined arguments with defaults', () => { + const result = cryptoSvc.concatUint8Arrays(); + + expect(result).toStrictEqual(new Uint8Array()); + }); + + test('decryptData: Returns empty string when no data provided', async () => { + const result = await cryptoSvc.decryptData(''); + + expect(result).toBe(''); + }); + + test('decryptData: Returns empty string when undefined data provided', async () => { + const result = await cryptoSvc.decryptData(undefined as any); + + expect(result).toBe(''); + }); + + test('encryptData: Returns empty string when no data provided', async () => { + const result = await cryptoSvc.encryptData(''); + + expect(result).toBe(''); + }); + + test('encryptData: Returns empty string when undefined data provided', async () => { + const result = await cryptoSvc.encryptData(undefined as any); + + expect(result).toBe(''); + }); + + test('encryptData: Throws ArgumentError when data is not a string', () => { + expect(() => cryptoSvc.encryptData(123 as any)).toThrow(ArgumentError); + }); + + test('decryptData: Throws InvalidCredentialsError when credentials check fails', async () => { + mockUtilitySvc.checkSyncCredentialsExist.mockRejectedValue(new Error('no creds')); + + await expect(cryptoSvc.decryptData('someEncryptedData')).rejects.toThrow(InvalidCredentialsError); + }); + + test('encryptData: Throws InvalidCredentialsError when credentials check fails', async () => { + mockUtilitySvc.checkSyncCredentialsExist.mockRejectedValue(new Error('no creds')); + + await expect(cryptoSvc.encryptData('test data')).rejects.toThrow(InvalidCredentialsError); + }); + + test('getPasswordHash: Returns plain password for old sync version (no version)', async () => { + mockUtilitySvc.getSyncVersion.mockResolvedValue(undefined); + + const result = await cryptoSvc.getPasswordHash('mypassword', 'salt'); + + expect(result).toBe('mypassword'); + }); + + test('getPasswordHash: Calls crypto.subtle.importKey for current sync version', async () => { + mockUtilitySvc.getSyncVersion.mockResolvedValue('1.5.0'); + const mockExportedKey = new ArrayBuffer(32); + const mockImportKey = jest.fn().mockResolvedValue('imported-key'); + const mockDeriveKey = jest.fn().mockResolvedValue('derived-key'); + const mockExportKey = jest.fn().mockResolvedValue(mockExportedKey); + Object.defineProperty(global, 'crypto', { + value: { + subtle: { + importKey: mockImportKey, + deriveKey: mockDeriveKey, + exportKey: mockExportKey + }, + getRandomValues: (arr: Uint8Array) => arr + }, + configurable: true + }); + + const result = await cryptoSvc.getPasswordHash('mypassword', 'salt123'); + + expect(mockImportKey).toBeCalled(); + expect(mockDeriveKey).toBeCalled(); + expect(mockExportKey).toBeCalled(); + expect(typeof result).toBe('string'); + }); + + test('encryptData: Calls crypto.subtle.encrypt with correct algorithm', async () => { + const mockEncrypted = new ArrayBuffer(32); + const mockImportKey = jest.fn().mockResolvedValue('key'); + const mockEncrypt = jest.fn().mockResolvedValue(mockEncrypted); + const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(global, 'crypto'); + Object.defineProperty(global, 'crypto', { + value: { + subtle: { + importKey: mockImportKey, + encrypt: mockEncrypt + }, + getRandomValues: (arr: Uint8Array) => arr + }, + configurable: true + }); + mockUtilitySvc.checkSyncCredentialsExist.mockResolvedValue({ + id: 'test-id', + password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' + }); + + const result = await cryptoSvc.encryptData('test data'); + + expect(mockImportKey).toBeCalled(); + expect(mockEncrypt).toBeCalledWith( + expect.objectContaining({ name: 'AES-GCM' }), + 'key', + expect.anything() + ); + expect(typeof result).toBe('string'); + + if (originalCryptoDescriptor) { + Object.defineProperty(global, 'crypto', originalCryptoDescriptor); + } else { + delete (global as any).crypto; + } + }); + + test('decryptData: Calls crypto.subtle.decrypt with correct algorithm', async () => { + const mockDecrypted = new TextEncoder().encode('decrypted data').buffer; + const mockImportKey = jest.fn().mockResolvedValue('key'); + const mockDecrypt = jest.fn().mockResolvedValue(mockDecrypted); + const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(global, 'crypto'); + Object.defineProperty(global, 'crypto', { + value: { + subtle: { + importKey: mockImportKey, + decrypt: mockDecrypt + }, + getRandomValues: (arr: Uint8Array) => arr + }, + configurable: true + }); + mockUtilitySvc.checkSyncCredentialsExist.mockResolvedValue({ + id: 'test-id', + password: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' + }); + // Create fake base64 data (16 byte IV + some encrypted bytes) + const fakeData = 'AAAAAAAAAAAAAAAAAAAAAA==AAAAAAAAAA=='; + const base64js = require('base64-js'); + const iv = new Uint8Array(16); + const encrypted = new Uint8Array(16); + const combined = new Uint8Array(32); + combined.set(iv, 0); + combined.set(encrypted, 16); + const encodedData = base64js.fromByteArray(combined); + + const result = await cryptoSvc.decryptData(encodedData); + + expect(mockImportKey).toBeCalled(); + expect(mockDecrypt).toBeCalledWith( + expect.objectContaining({ name: 'AES-GCM' }), + 'key', + expect.any(ArrayBuffer) + ); + expect(result).toBe('decrypted data'); + + if (originalCryptoDescriptor) { + Object.defineProperty(global, 'crypto', originalCryptoDescriptor); + } else { + delete (global as any).crypto; + } + }); +}); diff --git a/src/modules/shared/errors/errors.spec.ts b/src/modules/shared/errors/errors.spec.ts new file mode 100644 index 000000000..0523c9efe --- /dev/null +++ b/src/modules/shared/errors/errors.spec.ts @@ -0,0 +1,112 @@ +import { + BaseError, + ArgumentError, + BookmarkNotFoundError, + HttpRequestFailedError, + InvalidCredentialsError, + NetworkConnectionError, + SyncFailedError, + FailedRestoreDataError +} from './errors'; + +describe('Errors', () => { + afterEach(() => jest.restoreAllMocks()); + + test('BaseError: Creates error with message', () => { + const error = new BaseError('test message'); + + expect(error.message).toBe('test message'); + expect(error.logged).toBe(false); + expect(error).toBeInstanceOf(Error); + }); + + test('BaseError: Creates error with message from error param when no message provided', () => { + const innerError = new Error('inner error message'); + const error = new BaseError(undefined, innerError); + + expect(error.message).toBe('inner error message'); + }); + + test('BaseError: Uses error param stack trace when provided', () => { + const innerError = new Error('inner'); + const error = new BaseError(undefined, innerError); + + expect(error.stack).toContain('BaseError'); + }); + + test('BaseError: Stack trace includes error class name', () => { + const error = new BaseError('test'); + + expect(error.stack).toContain('BaseError'); + }); + + test('BaseError: logged property defaults to false', () => { + const error = new BaseError(); + + expect(error.logged).toBe(false); + }); + + test('ArgumentError: Is instance of BaseError', () => { + const error = new ArgumentError('bad argument'); + + expect(error).toBeInstanceOf(BaseError); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('bad argument'); + }); + + test('BookmarkNotFoundError: Is instance of BaseError', () => { + const error = new BookmarkNotFoundError(); + + expect(error).toBeInstanceOf(BaseError); + }); + + test('HttpRequestFailedError: Stores message', () => { + const error = new HttpRequestFailedError('status: 500'); + + expect(error.message).toBe('status: 500'); + expect(error).toBeInstanceOf(BaseError); + }); + + test('InvalidCredentialsError: Can wrap inner error', () => { + const innerError = new Error('decrypt failed'); + const error = new InvalidCredentialsError(undefined, innerError); + + expect(error).toBeInstanceOf(BaseError); + expect(error.message).toBe('decrypt failed'); + }); + + test('NetworkConnectionError: Is instance of BaseError', () => { + const error = new NetworkConnectionError(); + + expect(error).toBeInstanceOf(BaseError); + }); + + test('SyncFailedError: Is instance of BaseError', () => { + const error = new SyncFailedError('sync failed'); + + expect(error).toBeInstanceOf(BaseError); + expect(error.message).toBe('sync failed'); + }); + + test('FailedRestoreDataError: Stores message', () => { + const error = new FailedRestoreDataError('bad data'); + + expect(error.message).toBe('bad data'); + }); + + test('Error classes have correct logged default', () => { + const errors = [ + new ArgumentError(), + new BookmarkNotFoundError(), + new HttpRequestFailedError(), + new InvalidCredentialsError(), + new NetworkConnectionError(), + new SyncFailedError(), + new FailedRestoreDataError() + ]; + + errors.forEach((error) => { + expect(error.logged).toBe(false); + }); + }); +}); diff --git a/src/modules/shared/metadata/get-metadata.spec.ts b/src/modules/shared/metadata/get-metadata.spec.ts new file mode 100644 index 000000000..15f001997 --- /dev/null +++ b/src/modules/shared/metadata/get-metadata.spec.ts @@ -0,0 +1,136 @@ +import { getMetadata } from './get-metadata'; + +describe('getMetadata', () => { + afterEach(() => jest.restoreAllMocks()); + + test('Returns title from og:title meta tag', () => { + const html = ` + + + Page Title + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.title).toBe('OG Title'); + expect(result.url).toBe('https://example.com'); + }); + + test('Returns title from document title when no meta tags', () => { + const html = ` + + Page Title + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.title).toBe('Page Title'); + }); + + test('Returns description from og:description meta tag', () => { + const html = ` + + + + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.description).toBe('OG Description'); + }); + + test('Returns description from default description meta tag', () => { + const html = ` + + + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.description).toBe('Default Description'); + }); + + test('Returns description from twitter:description meta tag', () => { + const html = ` + + + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.description).toBe('Twitter Description'); + }); + + test('Returns keywords from meta keywords tag', () => { + const html = ` + + + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.tags).toBe('javascript,testing,web'); + }); + + test('Returns undefined description when no description meta tags exist', () => { + const html = ` + + Title + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.description).toBeUndefined(); + }); + + test('Returns undefined tags when no keywords meta tags exist', () => { + const html = ` + + Title + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.tags).toBeUndefined(); + }); + + test('Returns title from twitter:title meta tag', () => { + const html = ` + + + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.title).toBe('Twitter Title'); + }); + + test('Decodes HTML entities in title', () => { + const html = ` + + Hello & World + + `; + + const result = getMetadata('https://example.com', html); + + expect(result.title).toBe('Hello & World'); + }); + + test('URL is passed through to result', () => { + const html = 'Test'; + + const result = getMetadata('https://test.example.com/page', html); + + expect(result.url).toBe('https://test.example.com/page'); + }); +}); diff --git a/src/modules/shared/settings/settings.service.spec.ts b/src/modules/shared/settings/settings.service.spec.ts new file mode 100644 index 000000000..9001c6d8a --- /dev/null +++ b/src/modules/shared/settings/settings.service.spec.ts @@ -0,0 +1,171 @@ +import '../../../test/mock-angular'; +import { StoreKey } from '../store/store.enum'; +import { SettingsService } from './settings.service'; + +describe('SettingsService', () => { + let settingsSvc: SettingsService; + const mockLogSvc = { logInfo: jest.fn() } as any; + const mockStoreSvc = { get: jest.fn(), set: jest.fn() } as any; + + beforeEach(() => { + settingsSvc = new SettingsService(mockLogSvc, mockStoreSvc); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('all: Returns all settings from store', async () => { + const testSettings = { + [StoreKey.AlternateSearchBarPosition]: false, + [StoreKey.AutoFetchMetadata]: true, + [StoreKey.CheckForAppUpdates]: true, + [StoreKey.DarkModeEnabled]: false, + [StoreKey.DefaultToFolderView]: false, + [StoreKey.SyncBookmarksToolbar]: false, + [StoreKey.TelemetryEnabled]: true + }; + mockStoreSvc.get.mockResolvedValue(testSettings); + + const result = await settingsSvc.all(); + + expect(result).toStrictEqual(testSettings); + }); + + test('alternateSearchBarPosition: Gets value from store when no argument provided', async () => { + mockStoreSvc.get.mockResolvedValue(true); + + const result = await settingsSvc.alternateSearchBarPosition(); + + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.AlternateSearchBarPosition); + expect(result).toBe(true); + }); + + test('alternateSearchBarPosition: Sets value in store when argument provided', async () => { + mockStoreSvc.set.mockResolvedValue(); + + const result = await settingsSvc.alternateSearchBarPosition(true); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.AlternateSearchBarPosition, true); + expect(result).toBe(true); + }); + + test('autoFetchMetadata: Gets value from store when no argument provided', async () => { + mockStoreSvc.get.mockResolvedValue(false); + + const result = await settingsSvc.autoFetchMetadata(); + + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.AutoFetchMetadata); + expect(result).toBe(false); + }); + + test('autoFetchMetadata: Sets value in store when argument provided', async () => { + mockStoreSvc.set.mockResolvedValue(); + + const result = await settingsSvc.autoFetchMetadata(true); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.AutoFetchMetadata, true); + expect(result).toBe(true); + }); + + test('checkForAppUpdates: Gets value from store when no argument provided', async () => { + mockStoreSvc.get.mockResolvedValue(true); + + const result = await settingsSvc.checkForAppUpdates(); + + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.CheckForAppUpdates); + expect(result).toBe(true); + }); + + test('checkForAppUpdates: Sets value in store when argument provided', async () => { + mockStoreSvc.set.mockResolvedValue(); + + const result = await settingsSvc.checkForAppUpdates(false); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.CheckForAppUpdates, false); + expect(result).toBe(false); + }); + + test('darkModeEnabled: Gets value from store and updates darkMode property', async () => { + mockStoreSvc.get.mockResolvedValue(true); + + const result = await settingsSvc.darkModeEnabled(); + + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.DarkModeEnabled); + expect(result).toBe(true); + expect(settingsSvc.darkMode).toBe(true); + }); + + test('darkModeEnabled: Sets value in store and updates darkMode property', async () => { + mockStoreSvc.set.mockResolvedValue(); + + const result = await settingsSvc.darkModeEnabled(true); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.DarkModeEnabled, true); + expect(result).toBe(true); + expect(settingsSvc.darkMode).toBe(true); + }); + + test('defaultToFolderView: Gets value from store when no argument provided', async () => { + mockStoreSvc.get.mockResolvedValue(false); + + const result = await settingsSvc.defaultToFolderView(); + + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.DefaultToFolderView); + expect(result).toBe(false); + }); + + test('defaultToFolderView: Sets value in store when argument provided', async () => { + mockStoreSvc.set.mockResolvedValue(); + + const result = await settingsSvc.defaultToFolderView(true); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.DefaultToFolderView, true); + expect(result).toBe(true); + }); + + test('syncBookmarksToolbar: Gets value from store when no argument provided', async () => { + mockStoreSvc.get.mockResolvedValue(false); + + const result = await settingsSvc.syncBookmarksToolbar(); + + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.SyncBookmarksToolbar); + expect(result).toBe(false); + }); + + test('syncBookmarksToolbar: Sets value in store when argument provided', async () => { + mockStoreSvc.set.mockResolvedValue(); + + const result = await settingsSvc.syncBookmarksToolbar(true); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.SyncBookmarksToolbar, true); + expect(result).toBe(true); + }); + + test('telemetryEnabled: Gets value from store when no argument provided', async () => { + mockStoreSvc.get.mockResolvedValue(true); + + const result = await settingsSvc.telemetryEnabled(); + + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.TelemetryEnabled); + expect(result).toBe(true); + }); + + test('telemetryEnabled: Sets value in store when argument provided', async () => { + mockStoreSvc.set.mockResolvedValue(); + + const result = await settingsSvc.telemetryEnabled(false); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.TelemetryEnabled, false); + expect(result).toBe(false); + }); + + test('darkModeEnabled: Logs message when setting value', async () => { + mockStoreSvc.set.mockResolvedValue(); + + await settingsSvc.darkModeEnabled(true); + + expect(mockLogSvc.logInfo).toBeCalled(); + }); +}); diff --git a/src/modules/shared/sync/sync.service.spec.ts b/src/modules/shared/sync/sync.service.spec.ts new file mode 100644 index 000000000..6acf69555 --- /dev/null +++ b/src/modules/shared/sync/sync.service.spec.ts @@ -0,0 +1,246 @@ +import '../../../test/mock-angular'; +import { $q } from '../../../test/mock-services'; +import { + BookmarkMappingNotFoundError, + BookmarkNotFoundError, + ContainerChangedError, + DataOutOfSyncError, + FailedCreateNativeBookmarksError, + FailedGetNativeBookmarksError, + FailedRemoveNativeBookmarksError, + IncompleteSyncInfoError, + NativeBookmarkNotFoundError, + SyncDisabledError, + SyncNotFoundError, + SyncUncommittedError, + SyncVersionNotSupportedError, + TooManyRequestsError +} from '../errors/errors'; +import { SyncService } from './sync.service'; + +describe('SyncService', () => { + let syncSvc: SyncService; + const mock$exceptionHandler = jest.fn(); + const mock$timeout = jest.fn((fn: Function) => fn()) as any; + const mockBookmarkHelperSvc = { + getCachedBookmarks: jest.fn(), + updateCachedBookmarks: jest.fn() + } as any; + const mockBookmarkSyncProviderSvc = { + disable: jest.fn(), + enable: jest.fn(), + processSync: jest.fn(), + handleUpdateRemoteFailed: jest.fn() + } as any; + const mockCryptoSvc = { encryptData: jest.fn() } as any; + const mockLogSvc = { logInfo: jest.fn(), logWarning: jest.fn(), logError: jest.fn() } as any; + const mockNetworkSvc = { isNetworkConnectionError: jest.fn() } as any; + const mockPlatformSvc = { + stopSyncUpdateChecks: jest.fn().mockResolvedValue(undefined), + startSyncUpdateChecks: jest.fn().mockResolvedValue(undefined), + refreshNativeInterface: jest.fn().mockResolvedValue(undefined), + queueLocalResync: jest.fn().mockResolvedValue(undefined), + getAppVersion: jest.fn().mockResolvedValue('1.6.0') + } as any; + const mockStoreSvc = { + get: jest.fn(), + set: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined) + } as any; + const mockUtilitySvc = { + isSyncEnabled: jest.fn(), + getApiService: jest.fn(), + checkSyncCredentialsExist: jest.fn(), + compareVersions: jest.fn(), + getUniqueishId: jest.fn().mockReturnValue('test-id'), + asyncWhile: jest.fn() + } as any; + + beforeEach(() => { + syncSvc = new SyncService( + mock$exceptionHandler, + $q, + mock$timeout, + mockBookmarkHelperSvc, + mockBookmarkSyncProviderSvc, + mockCryptoSvc, + mockLogSvc, + mockNetworkSvc, + mockPlatformSvc, + mockStoreSvc, + mockUtilitySvc + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + test('constructor: Registers sync providers', () => { + expect(syncSvc.providers).toContain(mockBookmarkSyncProviderSvc); + }); + + test('constructor: Initializes empty sync queue', () => { + expect(syncSvc.syncQueue).toStrictEqual([]); + }); + + test('getCurrentSync: Returns current sync', () => { + const testSync = { type: 'local' } as any; + syncSvc.currentSync = testSync; + + expect(syncSvc.getCurrentSync()).toBe(testSync); + }); + + test('getSyncQueueLength: Returns queue length', () => { + syncSvc.syncQueue = [{} as any, {} as any]; + + expect(syncSvc.getSyncQueueLength()).toBe(2); + }); + + test('getSyncQueueLength: Returns 0 for empty queue', () => { + expect(syncSvc.getSyncQueueLength()).toBe(0); + }); + + // checkIfDisableSyncOnError tests + test('checkIfDisableSyncOnError: Returns true for IncompleteSyncInfoError', () => { + expect(syncSvc.checkIfDisableSyncOnError(new IncompleteSyncInfoError())).toBe(true); + }); + + test('checkIfDisableSyncOnError: Returns true for SyncNotFoundError', () => { + expect(syncSvc.checkIfDisableSyncOnError(new SyncNotFoundError())).toBe(true); + }); + + test('checkIfDisableSyncOnError: Returns true for SyncVersionNotSupportedError', () => { + expect(syncSvc.checkIfDisableSyncOnError(new SyncVersionNotSupportedError())).toBe(true); + }); + + test('checkIfDisableSyncOnError: Returns true for TooManyRequestsError', () => { + expect(syncSvc.checkIfDisableSyncOnError(new TooManyRequestsError())).toBe(true); + }); + + test('checkIfDisableSyncOnError: Returns false for generic Error', () => { + expect(syncSvc.checkIfDisableSyncOnError(new Error())).toBe(false); + }); + + test('checkIfDisableSyncOnError: Returns falsy for null', () => { + expect(syncSvc.checkIfDisableSyncOnError(null as any)).toBeFalsy(); + }); + + // checkIfRefreshSyncedDataOnError tests + test('checkIfRefreshSyncedDataOnError: Returns true for BookmarkMappingNotFoundError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new BookmarkMappingNotFoundError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns true for ContainerChangedError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new ContainerChangedError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns true for DataOutOfSyncError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new DataOutOfSyncError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns true for FailedCreateNativeBookmarksError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new FailedCreateNativeBookmarksError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns true for FailedGetNativeBookmarksError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new FailedGetNativeBookmarksError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns true for FailedRemoveNativeBookmarksError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new FailedRemoveNativeBookmarksError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns true for NativeBookmarkNotFoundError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new NativeBookmarkNotFoundError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns true for BookmarkNotFoundError', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new BookmarkNotFoundError())).toBe(true); + }); + + test('checkIfRefreshSyncedDataOnError: Returns false for generic Error', () => { + expect(syncSvc.checkIfRefreshSyncedDataOnError(new Error())).toBe(false); + }); + + // shouldDisplayDefaultPageOnError tests + test('shouldDisplayDefaultPageOnError: Returns true for IncompleteSyncInfoError', () => { + expect(syncSvc.shouldDisplayDefaultPageOnError(new IncompleteSyncInfoError())).toBe(true); + }); + + test('shouldDisplayDefaultPageOnError: Returns true for SyncUncommittedError', () => { + expect(syncSvc.shouldDisplayDefaultPageOnError(new SyncUncommittedError())).toBe(true); + }); + + test('shouldDisplayDefaultPageOnError: Returns false for generic Error', () => { + expect(syncSvc.shouldDisplayDefaultPageOnError(new Error())).toBe(false); + }); + + // checkSyncExists tests + test('checkSyncExists: Throws SyncDisabledError when sync not enabled', async () => { + mockUtilitySvc.isSyncEnabled.mockResolvedValue(false); + + await expect(syncSvc.checkSyncExists()).rejects.toThrow(SyncDisabledError); + }); + + test('checkSyncExists: Returns true when last updated succeeds', async () => { + mockUtilitySvc.isSyncEnabled.mockResolvedValue(true); + const mockApiSvc = { getBookmarksLastUpdated: jest.fn().mockResolvedValue({ lastUpdated: '2023-01-01' }) }; + mockUtilitySvc.getApiService.mockResolvedValue(mockApiSvc); + + const result = await syncSvc.checkSyncExists(); + + expect(result).toBe(true); + }); + + test('checkSyncExists: Returns false when SyncNotFoundError thrown', async () => { + mockUtilitySvc.isSyncEnabled.mockResolvedValue(true); + const mockApiSvc = { getBookmarksLastUpdated: jest.fn().mockRejectedValue(new SyncNotFoundError()) }; + mockUtilitySvc.getApiService.mockResolvedValue(mockApiSvc); + mockBookmarkHelperSvc.getCachedBookmarks.mockResolvedValue([]); + mockStoreSvc.get.mockResolvedValue({ lastUpdated: '2023-01-01', syncInfo: { id: 'test', password: 'pass', version: '1.5.0' } }); + + const result = await syncSvc.checkSyncExists(); + + expect(result).toBe(false); + }); + + // executeSync tests + test('executeSync: Throws SyncDisabledError when sync is not enabled', async () => { + mockUtilitySvc.isSyncEnabled.mockResolvedValue(false); + + await expect(syncSvc.executeSync()).rejects.toThrow(SyncDisabledError); + }); + + // disableSync tests + test('disableSync: Returns early when sync is not enabled', async () => { + mockUtilitySvc.isSyncEnabled.mockResolvedValue(false); + + await syncSvc.disableSync(); + + // When sync is not enabled, storeSvc.remove should not be called (no cleanup needed) + expect(mockStoreSvc.remove).not.toBeCalled(); + }); + + // processSyncQueue tests + test('processSyncQueue: Resolves immediately when queue is empty', async () => { + syncSvc.syncQueue = []; + syncSvc.currentSync = undefined; + + await syncSvc.processSyncQueue(); + + // Should not attempt to stop update checks when nothing to process + expect(mockPlatformSvc.stopSyncUpdateChecks).not.toBeCalled(); + }); + + test('processSyncQueue: Resolves immediately when currentSync is set', async () => { + syncSvc.currentSync = { type: 'local' } as any; + syncSvc.syncQueue = [{ type: 'remote' } as any]; + + await syncSvc.processSyncQueue(); + + // Should not attempt to stop update checks when sync already in progress + expect(mockPlatformSvc.stopSyncUpdateChecks).not.toBeCalled(); + }); +}); diff --git a/src/modules/shared/upgrade/upgrade.service.spec.ts b/src/modules/shared/upgrade/upgrade.service.spec.ts new file mode 100644 index 000000000..bff31c79b --- /dev/null +++ b/src/modules/shared/upgrade/upgrade.service.spec.ts @@ -0,0 +1,107 @@ +import '../../../test/mock-angular'; +import { $q } from '../../../test/mock-services'; +import { SyncVersionNotSupportedError, UpgradeFailedError } from '../errors/errors'; +import { StoreKey } from '../store/store.enum'; +import { UpgradeService } from './upgrade.service'; + +describe('UpgradeService', () => { + let upgradeSvc: UpgradeService; + const mockLogSvc = { logInfo: jest.fn(), logError: jest.fn() } as any; + const mockPlatformSvc = { disableSync: jest.fn() } as any; + const mockStoreSvc = { get: jest.fn(), set: jest.fn() } as any; + const mockUtilitySvc = { + compareVersions: jest.fn(), + asyncWhile: jest.fn() + } as any; + const mockV160UpgradeProviderSvc = { + upgradeApp: jest.fn(), + upgradeBookmarks: jest.fn() + } as any; + + beforeEach(() => { + upgradeSvc = new UpgradeService( + $q, + mockLogSvc, + mockPlatformSvc, + mockStoreSvc, + mockUtilitySvc, + mockV160UpgradeProviderSvc + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('constructor: Initializes upgrade map with v1.6.0 provider', () => { + expect(upgradeSvc.upgradeMap.has('1.6.0')).toBe(true); + expect(upgradeSvc.upgradeMap.get('1.6.0')).toBe(mockV160UpgradeProviderSvc); + }); + + test('getLastUpgradeVersion: Returns version from store', async () => { + mockStoreSvc.get.mockResolvedValue('1.5.0'); + + const result = await upgradeSvc.getLastUpgradeVersion(); + + expect(result).toBe('1.5.0'); + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.LastUpgradeVersion); + }); + + test('setLastUpgradeVersion: Sets version in store', async () => { + mockStoreSvc.set.mockResolvedValue(); + + await upgradeSvc.setLastUpgradeVersion('1.6.0'); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.LastUpgradeVersion, '1.6.0'); + }); + + test('checkIfUpgradeRequired: Returns true when no previous upgrade version', async () => { + mockStoreSvc.get.mockResolvedValue(undefined); + + const result = await upgradeSvc.checkIfUpgradeRequired('1.6.0'); + + expect(result).toBe(true); + }); + + test('checkIfUpgradeRequired: Returns true when last version is less than current', async () => { + mockStoreSvc.get.mockResolvedValue('1.5.0'); + mockUtilitySvc.compareVersions.mockReturnValue(true); + + const result = await upgradeSvc.checkIfUpgradeRequired('1.6.0'); + + expect(result).toBe(true); + }); + + test('checkIfUpgradeRequired: Returns false when versions are equal', async () => { + mockStoreSvc.get.mockResolvedValue('1.6.0'); + mockUtilitySvc.compareVersions.mockReturnValue(false); + + const result = await upgradeSvc.checkIfUpgradeRequired('1.6.0'); + + expect(result).toBe(false); + }); + + test('upgrade: Throws UpgradeFailedError when target version is undefined', () => { + expect(() => upgradeSvc.upgrade(undefined as any)).toThrow(UpgradeFailedError); + }); + + test('upgradeBookmarks: Returns empty bookmarks unchanged', async () => { + const result = await upgradeSvc.upgradeBookmarks('1.6.0', '1.5.0', []); + + expect(result).toStrictEqual([]); + }); + + test('upgradeBookmarks: Throws UpgradeFailedError when target version is undefined', () => { + const bookmarks = [{ id: 1, title: 'Test', url: 'https://test.com' }]; + + expect(() => upgradeSvc.upgradeBookmarks(undefined as any, '1.0.0', bookmarks)).toThrow(UpgradeFailedError); + }); + + test('upgradeBookmarks: Throws SyncVersionNotSupportedError when sync version is greater', () => { + const bookmarks = [{ id: 1, title: 'Test', url: 'https://test.com' }]; + mockUtilitySvc.compareVersions.mockReturnValue(true); + + expect(() => upgradeSvc.upgradeBookmarks('1.5.0', '2.0.0', bookmarks)).toThrow(SyncVersionNotSupportedError); + }); +}); diff --git a/src/modules/shared/utility/utility.service.spec.ts b/src/modules/shared/utility/utility.service.spec.ts new file mode 100644 index 000000000..80fa7e334 --- /dev/null +++ b/src/modules/shared/utility/utility.service.spec.ts @@ -0,0 +1,420 @@ +import '../../../test/mock-angular'; + +jest.mock('xregexp', () => { + return { + default: (pattern: string, flags: string) => new RegExp(pattern, flags + 'u'), + __esModule: true + }; +}); + +jest.mock('detect-browser', () => ({ + detect: () => ({ name: 'chrome', version: '100.0' }) +})); + +import { $q } from '../../../test/mock-services'; +import { StoreKey } from '../store/store.enum'; +import { StoreService } from '../store/store.service'; +import { LogService } from '../log/log.service'; +import { NetworkService } from '../network/network.service'; +import { UtilityService } from './utility.service'; + +describe('UtilityService', () => { + let utilitySvc: UtilityService; + const mock$exceptionHandler = jest.fn(); + const mock$http = { get: jest.fn() } as any; + const mock$injector = { get: jest.fn(), annotate: jest.fn(), has: jest.fn(), instantiate: jest.fn(), invoke: jest.fn(), loadNewModules: jest.fn(), modules: {}, strictDi: false } as any; + const mock$location = { path: jest.fn() } as any; + const mock$rootScope = { $broadcast: jest.fn() } as any; + const mockLogSvc = { logInfo: jest.fn(), logWarning: jest.fn() } as any; + const mockNetworkSvc = { isNetworkConnected: jest.fn() } as any; + const mockStoreSvc = { get: jest.fn(), set: jest.fn() } as any; + + beforeEach(() => { + utilitySvc = new UtilityService( + mock$exceptionHandler, + mock$http, + mock$injector, + mock$location, + $q, + mock$rootScope, + mockLogSvc, + mockNetworkSvc, + mockStoreSvc + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('filterFalsyValues: Removes falsy values from string array', () => { + const result = utilitySvc.filterFalsyValues(['hello', '', 'world', '', 'test']); + + expect(result).toStrictEqual(['hello', 'world', 'test']); + }); + + test('filterFalsyValues: Returns empty array when all values are falsy', () => { + const result = utilitySvc.filterFalsyValues(['', '', '']); + + expect(result).toStrictEqual([]); + }); + + test('getDateTimeString: Returns empty string when no date provided', () => { + const result = utilitySvc.getDateTimeString(undefined as any); + + expect(result).toBe(''); + }); + + test('getDateTimeString: Returns formatted date string', () => { + const testDate = new Date(2023, 0, 15, 10, 30, 45); + + const result = utilitySvc.getDateTimeString(testDate); + + expect(result).toBe('20230115103045'); + }); + + test('getDateTimeString: Pads single digit values with zero', () => { + const testDate = new Date(2023, 0, 5, 3, 5, 9); + + const result = utilitySvc.getDateTimeString(testDate); + + expect(result).toBe('20230105030509'); + }); + + test('getSemVerAlignedVersion: Strips v prefix from version', () => { + const result = utilitySvc.getSemVerAlignedVersion('v1.2.3'); + + expect(result).toBe('1.2.3'); + }); + + test('getSemVerAlignedVersion: Returns version without v prefix as is', () => { + const result = utilitySvc.getSemVerAlignedVersion('1.2.3'); + + expect(result).toBe('1.2.3'); + }); + + test('getSemVerAlignedVersion: Strips extra build number', () => { + const result = utilitySvc.getSemVerAlignedVersion('v1.2.3.4'); + + expect(result).toBe('1.2.3'); + }); + + test('compareVersions: Returns true when first version is greater', () => { + const result = utilitySvc.compareVersions('1.2.0', '1.1.0', '>'); + + expect(result).toBe(true); + }); + + test('compareVersions: Returns false when first version is not greater', () => { + const result = utilitySvc.compareVersions('1.0.0', '1.1.0', '>'); + + expect(result).toBe(false); + }); + + test('compareVersions: Returns true for equal versions with equality operator', () => { + const result = utilitySvc.compareVersions('1.2.3', '1.2.3', '='); + + expect(result).toBe(true); + }); + + test('getTagArrayFromText: Returns undefined when text is undefined', () => { + const result = utilitySvc.getTagArrayFromText(undefined as any); + + expect(result).toBeUndefined(); + }); + + test('getTagArrayFromText: Splits tags by comma', () => { + const result = utilitySvc.getTagArrayFromText('javascript,typescript,testing'); + + expect(result).toStrictEqual(['javascript', 'testing', 'typescript']); + }); + + test('getTagArrayFromText: Splits tags by semicolon', () => { + const result = utilitySvc.getTagArrayFromText('javascript;typescript;testing'); + + expect(result).toStrictEqual(['javascript', 'testing', 'typescript']); + }); + + test('getTagArrayFromText: Filters out tags shorter than minimum length', () => { + const result = utilitySvc.getTagArrayFromText('js,typescript,go'); + + expect(result).toStrictEqual(['typescript']); + }); + + test('getTagArrayFromText: Trims whitespace from tags', () => { + const result = utilitySvc.getTagArrayFromText(' javascript , typescript '); + + expect(result).toStrictEqual(['javascript', 'typescript']); + }); + + test('isMobilePlatform: Returns true for Android platform', () => { + const result = utilitySvc.isMobilePlatform('android'); + + expect(result).toBe(true); + }); + + test('isMobilePlatform: Returns false for non-Android platform', () => { + const result = utilitySvc.isMobilePlatform('chromium'); + + expect(result).toBe(false); + }); + + test('isTextInput: Returns true for INPUT element', () => { + const element = document.createElement('input'); + + const result = utilitySvc.isTextInput(element); + + expect(result).toBe(true); + }); + + test('isTextInput: Returns true for TEXTAREA element', () => { + const element = document.createElement('textarea'); + + const result = utilitySvc.isTextInput(element); + + expect(result).toBe(true); + }); + + test('isTextInput: Returns false for DIV element', () => { + const element = document.createElement('div'); + + const result = utilitySvc.isTextInput(element); + + expect(result).toBe(false); + }); + + test('parseUrl: Parses URL correctly', () => { + const result = utilitySvc.parseUrl('https://example.com:8080/path?key=value#hash'); + + expect(result.protocol).toBe('https:'); + expect(result.hostname).toBe('example.com'); + expect(result.port).toBe('8080'); + expect(result.pathname).toBe('/path'); + expect(result.hash).toBe('#hash'); + expect(result.searchObject.key).toBe('value'); + }); + + test('sortWords: Sorts words alphabetically and removes duplicates', () => { + const result = utilitySvc.sortWords(['banana', 'apple', 'cherry', 'apple']); + + expect(result).toStrictEqual(['apple', 'banana', 'cherry']); + }); + + test('splitTextIntoWords: Returns empty array for undefined text', () => { + const result = utilitySvc.splitTextIntoWords(undefined as any, 'en'); + + expect(result).toStrictEqual([]); + }); + + test('splitTextIntoWords: Splits text into lowercase words', () => { + const result = utilitySvc.splitTextIntoWords('Hello World Test', 'en'); + + expect(result).toStrictEqual(['hello', 'world', 'test']); + }); + + test('splitTextIntoWords: Removes quotes and splits into words', () => { + const result = utilitySvc.splitTextIntoWords('"hello" \'world\' "test"', 'en'); + + expect(result).toStrictEqual(['hello', 'world', 'test']); + }); + + test('stringsAreEquivalent: Returns true for equivalent strings ignoring case', () => { + const result = utilitySvc.stringsAreEquivalent('Hello', 'hello'); + + expect(result).toBe(true); + }); + + test('stringsAreEquivalent: Returns false for different strings', () => { + const result = utilitySvc.stringsAreEquivalent('Hello', 'World'); + + expect(result).toBe(false); + }); + + test('stringsAreEquivalent: Returns true for empty strings by default', () => { + const result = utilitySvc.stringsAreEquivalent(); + + expect(result).toBe(true); + }); + + test('stripTags: Removes HTML tags from input', () => { + const result = utilitySvc.stripTags('

Hello World

'); + + expect(result).toBe('Hello World'); + }); + + test('stripTags: Returns falsy input as is', () => { + const result = utilitySvc.stripTags(''); + + expect(result).toBe(''); + }); + + test('syncIdIsValid: Returns false for empty syncId', () => { + const result = utilitySvc.syncIdIsValid(''); + + expect(result).toBe(false); + }); + + test('syncIdIsValid: Returns false for null syncId', () => { + const result = utilitySvc.syncIdIsValid(null as any); + + expect(result).toBe(false); + }); + + test('syncIdIsValid: Returns true for valid 32-char hex syncId', () => { + const result = utilitySvc.syncIdIsValid('a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'); + + expect(result).toBe(true); + }); + + test('syncIdIsValid: Returns false for invalid syncId', () => { + const result = utilitySvc.syncIdIsValid('invalid'); + + expect(result).toBe(false); + }); + + test('trimToNearestWord: Returns empty string for falsy text', () => { + const result = utilitySvc.trimToNearestWord('', 10); + + expect(result).toBe(''); + }); + + test('trimToNearestWord: Returns full text when within limit', () => { + const result = utilitySvc.trimToNearestWord('Hello World', 100); + + expect(result).toBe('Hello World'); + }); + + test('trimToNearestWord: Trims to nearest word when over limit', () => { + const result = utilitySvc.trimToNearestWord('Hello World Test String', 12); + + expect(result).toBe('Hello World\u2026'); + }); + + test('checkSyncCredentialsExist: Returns sync info when id and password exist', async () => { + const testSyncInfo = { id: 'test-id', password: 'test-password' }; + mockStoreSvc.get.mockResolvedValue(testSyncInfo); + + const result = await utilitySvc.checkSyncCredentialsExist(); + + expect(result).toStrictEqual(testSyncInfo); + }); + + test('checkSyncCredentialsExist: Throws IncompleteSyncInfoError when id is missing', async () => { + mockStoreSvc.get.mockResolvedValue({ password: 'test-password' }); + + await expect(utilitySvc.checkSyncCredentialsExist()).rejects.toThrow(); + }); + + test('checkSyncCredentialsExist: Throws IncompleteSyncInfoError when password is missing', async () => { + mockStoreSvc.get.mockResolvedValue({ id: 'test-id' }); + + await expect(utilitySvc.checkSyncCredentialsExist()).rejects.toThrow(); + }); + + test('isSyncEnabled: Returns value from store', async () => { + mockStoreSvc.get.mockResolvedValue(true); + + const result = await utilitySvc.isSyncEnabled(); + + expect(result).toBe(true); + expect(mockStoreSvc.get).toBeCalledWith(StoreKey.SyncEnabled); + }); + + test('getSyncVersion: Returns version from sync info', async () => { + mockStoreSvc.get.mockResolvedValue({ version: '1.5.0' }); + + const result = await utilitySvc.getSyncVersion(); + + expect(result).toBe('1.5.0'); + }); + + test('getSyncVersion: Returns undefined when no sync info', async () => { + mockStoreSvc.get.mockResolvedValue(undefined); + + const result = await utilitySvc.getSyncVersion(); + + expect(result).toBeUndefined(); + }); + + test('broadcastEvent: Broadcasts event on rootScope', () => { + utilitySvc.broadcastEvent('testEvent' as any, ['data']); + + expect(mock$rootScope.$broadcast).toBeCalledWith('testEvent', ['data']); + }); + + test('checkCurrentRoute: Returns true when current path starts with route', () => { + mock$location.path.mockReturnValue('/login/step1'); + + const result = utilitySvc.checkCurrentRoute('/login' as any); + + expect(result).toBe(true); + }); + + test('checkCurrentRoute: Returns false when current path does not match route', () => { + mock$location.path.mockReturnValue('/settings'); + + const result = utilitySvc.checkCurrentRoute('/login' as any); + + expect(result).toBe(false); + }); + + test('getCurrentApiServiceType: Returns xBrowserSync service type', async () => { + const result = await utilitySvc.getCurrentApiServiceType(); + + expect(result).toBe('xbrowsersync'); + }); + + test('getInstallationId: Returns existing installation id from store', async () => { + mockStoreSvc.get.mockResolvedValue('existing-id'); + + const result = await utilitySvc.getInstallationId(); + + expect(result).toBe('existing-id'); + }); + + test('getInstallationId: Generates and stores new id when not in store', async () => { + mockStoreSvc.get.mockResolvedValue(undefined); + mockStoreSvc.set.mockResolvedValue(); + + const result = await utilitySvc.getInstallationId(); + + expect(result).toBeTruthy(); + expect(mockStoreSvc.set).toBeCalled(); + }); + + test('uuidv4: Generates a valid UUID v4 string', () => { + const result = utilitySvc.uuidv4(); + + expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + test('getUniqueishId: Returns a non-empty string', () => { + const result = utilitySvc.getUniqueishId(); + + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + test('checkForNewVersion: Returns empty string when not connected', async () => { + mockNetworkSvc.isNetworkConnected.mockReturnValue(false); + + const result = await utilitySvc.checkForNewVersion('1.0.0'); + + expect(result).toBe(''); + }); + + test('asyncWhile: Executes action while condition is true', async () => { + let count = 0; + const condition = (data: number) => Promise.resolve(data < 3) as any; + const action = (data: number) => { + count += 1; + return Promise.resolve(data + 1) as any; + }; + + const result = await utilitySvc.asyncWhile(0, condition, action); + + expect(result).toBe(3); + expect(count).toBe(3); + }); +}); diff --git a/src/modules/shared/working/working.service.spec.ts b/src/modules/shared/working/working.service.spec.ts new file mode 100644 index 000000000..1030125e6 --- /dev/null +++ b/src/modules/shared/working/working.service.spec.ts @@ -0,0 +1,45 @@ +import { WorkingContext } from './working.enum'; +import { WorkingService } from './working.service'; + +describe('WorkingService', () => { + afterEach(() => jest.restoreAllMocks()); + + test('constructor: Initializes status as not activated', () => { + const workingSvc = new WorkingService(); + + expect(workingSvc.status).toStrictEqual({ activated: false }); + }); + + test('hide: Sets status to not activated', () => { + const workingSvc = new WorkingService(); + workingSvc.status = { activated: true, context: WorkingContext.Syncing }; + + workingSvc.hide(); + + expect(workingSvc.status).toStrictEqual({ activated: false }); + }); + + test('show: Sets status to activated without context', () => { + const workingSvc = new WorkingService(); + + workingSvc.show(); + + expect(workingSvc.status).toStrictEqual({ activated: true, context: undefined }); + }); + + test('show: Sets status to activated with context', () => { + const workingSvc = new WorkingService(); + + workingSvc.show(WorkingContext.Syncing); + + expect(workingSvc.status).toStrictEqual({ activated: true, context: WorkingContext.Syncing }); + }); + + test('show: Sets status to activated with Restoring context', () => { + const workingSvc = new WorkingService(); + + workingSvc.show(WorkingContext.Restoring); + + expect(workingSvc.status).toStrictEqual({ activated: true, context: WorkingContext.Restoring }); + }); +}); diff --git a/src/modules/webext/shared/bookmark-id-mapper/bookmark-id-mapper.service.spec.ts b/src/modules/webext/shared/bookmark-id-mapper/bookmark-id-mapper.service.spec.ts new file mode 100644 index 000000000..19b4a091c --- /dev/null +++ b/src/modules/webext/shared/bookmark-id-mapper/bookmark-id-mapper.service.spec.ts @@ -0,0 +1,158 @@ +import '../../../../test/mock-angular'; +import { $q } from '../../../../test/mock-services'; +import { BookmarkMappingNotFoundError } from '../../../shared/errors/errors'; +import { StoreKey } from '../../../shared/store/store.enum'; +import { BookmarkIdMapperService } from './bookmark-id-mapper.service'; + +describe('BookmarkIdMapperService', () => { + let idMapperSvc: BookmarkIdMapperService; + const mockStoreSvc = { get: jest.fn(), set: jest.fn(), remove: jest.fn() } as any; + + beforeEach(() => { + idMapperSvc = new BookmarkIdMapperService($q, mockStoreSvc); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('createMapping: Creates mapping object with syncedId and nativeId', () => { + const mapping = idMapperSvc.createMapping(42, 'native-123'); + + expect(mapping).toStrictEqual({ syncedId: 42, nativeId: 'native-123' }); + }); + + test('createMapping: Creates mapping with undefined nativeId', () => { + const mapping = idMapperSvc.createMapping(42); + + expect(mapping).toStrictEqual({ syncedId: 42, nativeId: undefined }); + }); + + test('clear: Removes bookmark id mappings from store', async () => { + mockStoreSvc.remove.mockResolvedValue(); + + await idMapperSvc.clear(); + + expect(mockStoreSvc.remove).toBeCalledWith(StoreKey.BookmarkIdMappings); + }); + + test('get: Finds mapping by nativeId', async () => { + const mappings = [ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' } + ]; + mockStoreSvc.get.mockResolvedValue(mappings); + + const result = await idMapperSvc.get('b'); + + expect(result).toStrictEqual({ syncedId: 2, nativeId: 'b' }); + }); + + test('get: Finds mapping by syncedId', async () => { + const mappings = [ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' } + ]; + mockStoreSvc.get.mockResolvedValue(mappings); + + const result = await idMapperSvc.get(null as any, 1); + + expect(result).toStrictEqual({ syncedId: 1, nativeId: 'a' }); + }); + + test('get: Returns undefined when mapping not found', async () => { + mockStoreSvc.get.mockResolvedValue([]); + + const result = await idMapperSvc.get('nonexistent'); + + expect(result).toBeUndefined(); + }); + + test('add: Adds single mapping to existing mappings', async () => { + const existingMappings = [{ syncedId: 1, nativeId: 'a' }]; + mockStoreSvc.get.mockResolvedValue(existingMappings); + mockStoreSvc.set.mockResolvedValue(); + + await idMapperSvc.add({ syncedId: 2, nativeId: 'b' }); + + expect(mockStoreSvc.set).toBeCalledWith( + StoreKey.BookmarkIdMappings, + expect.arrayContaining([ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' } + ]) + ); + }); + + test('add: Adds array of mappings', async () => { + mockStoreSvc.get.mockResolvedValue([]); + mockStoreSvc.set.mockResolvedValue(); + + await idMapperSvc.add([ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' } + ]); + + expect(mockStoreSvc.set).toBeCalled(); + }); + + test('set: Sorts mappings by syncedId and saves to store', async () => { + mockStoreSvc.set.mockResolvedValue(); + const unsortedMappings = [ + { syncedId: 3, nativeId: 'c' }, + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' } + ]; + + await idMapperSvc.set(unsortedMappings); + + expect(mockStoreSvc.set).toBeCalledWith(StoreKey.BookmarkIdMappings, [ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' }, + { syncedId: 3, nativeId: 'c' } + ]); + }); + + test('remove: Removes mapping by syncedId', async () => { + const mappings = [ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' }, + { syncedId: 3, nativeId: 'c' } + ]; + mockStoreSvc.get.mockResolvedValue(mappings); + mockStoreSvc.set.mockResolvedValue(); + + await idMapperSvc.remove(2); + + expect(mockStoreSvc.set).toBeCalledWith( + StoreKey.BookmarkIdMappings, + expect.arrayContaining([ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 3, nativeId: 'c' } + ]) + ); + }); + + test('remove: Throws BookmarkMappingNotFoundError when syncedId not found', async () => { + mockStoreSvc.get.mockResolvedValue([{ syncedId: 1, nativeId: 'a' }]); + + await expect(idMapperSvc.remove(999)).rejects.toThrow(BookmarkMappingNotFoundError); + }); + + test('remove: Removes mapping by nativeId', async () => { + const mappings = [ + { syncedId: 1, nativeId: 'a' }, + { syncedId: 2, nativeId: 'b' } + ]; + mockStoreSvc.get.mockResolvedValue(mappings); + mockStoreSvc.set.mockResolvedValue(); + + await idMapperSvc.remove(null as any, 'a'); + + expect(mockStoreSvc.set).toBeCalledWith( + StoreKey.BookmarkIdMappings, + [{ syncedId: 2, nativeId: 'b' }] + ); + }); +}); diff --git a/src/test/mock-angular.ts b/src/test/mock-angular.ts new file mode 100644 index 000000000..a3fde4131 --- /dev/null +++ b/src/test/mock-angular.ts @@ -0,0 +1,17 @@ +jest.mock('angular', () => ({ + default: { + isUndefined: (value: any) => typeof value === 'undefined', + isArray: (value: any) => Array.isArray(value), + isString: (value: any) => typeof value === 'string', + copy: (source: T, destination?: T): T => { + if (destination) { + // angular.copy(source, destination) mutates destination + Object.keys(destination).forEach((key) => delete (destination as any)[key]); + Object.assign(destination, JSON.parse(JSON.stringify(source))); + return destination; + } + return JSON.parse(JSON.stringify(source)); + } + }, + __esModule: true +}));