diff --git a/.env.example b/.env.example index a3d6972..aa999c4 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ VITE_PAYMENTS_API_URL= +VITE_MAIL_API_URL= VITE_DRIVE_API_URL= VITE_MAGIC_IV= VITE_MAGIC_SALT= diff --git a/package-lock.json b/package-lock.json index 0fb66fc..3013754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.15.1", + "@internxt/sdk": "^1.15.7", "@internxt/ui": "^0.1.11", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", @@ -1204,20 +1204,13 @@ "license": "MIT" }, "node_modules/@internxt/sdk": { - "version": "1.15.1", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@internxt/sdk/-/sdk-1.15.7.tgz", + "integrity": "sha512-Gwvgs72cpZI7ud9Gs8dk4kizghIBZrVBVPST6wUU6JbzD4b9Sc6HnRHJ3sVO2F9oFkx2u3M3QgcksDnr9Tpk8w==", "license": "MIT", "dependencies": { - "axios": "1.13.5", - "internxt-crypto": "0.0.13" - } - }, - "node_modules/@internxt/sdk/node_modules/axios": { - "version": "1.13.5", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "axios": "1.13.6", + "internxt-crypto": "1.0.2" } }, "node_modules/@internxt/ui": { @@ -1282,8 +1275,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", "license": "MIT", "dependencies": { "@noble/hashes": "2.0.1" @@ -1307,6 +1314,8 @@ }, "node_modules/@noble/post-quantum": { "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", + "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", "license": "MIT", "dependencies": { "@noble/curves": "~2.0.0", @@ -3156,6 +3165,8 @@ }, "node_modules/@scure/base": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -3163,6 +3174,8 @@ }, "node_modules/@scure/bip39": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", "license": "MIT", "dependencies": { "@noble/hashes": "2.0.1", @@ -6129,6 +6142,8 @@ }, "node_modules/flexsearch": { "version": "0.8.212", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.8.212.tgz", + "integrity": "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==", "funding": [ { "type": "github", @@ -6421,6 +6436,8 @@ }, "node_modules/hash-wasm": { "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", "license": "MIT" }, "node_modules/hash.js": { @@ -6568,6 +6585,8 @@ }, "node_modules/idb": { "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", "license": "ISC" }, "node_modules/ieee754": { @@ -6633,8 +6652,12 @@ "license": "ISC" }, "node_modules/internxt-crypto": { - "version": "0.0.13", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.0.2.tgz", + "integrity": "sha512-F9PuXci0eU1wlgDwqEbGR7hVDNS0MX8VNh/W+pdpR4ZsEsjRDBrOD2g1DvViR2woCxPiu1AW9Wwekpw2YVKfnA==", "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@noble/post-quantum": "^0.5.2", "@scure/bip39": "^2.0.1", @@ -9384,6 +9407,8 @@ }, "node_modules/uuid": { "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index a882583..130ff89 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.15.1", + "@internxt/sdk": "^1.15.7", "@internxt/ui": "^0.1.11", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", diff --git a/sonar-project.properties b/sonar-project.properties index c6a7f1a..720d78b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,7 +4,7 @@ sonar.projectName=mail-web sonar.sources=src sonar.tests=src sonar.test.inclusions=src/**/*.test.ts,src/**/*.test.tsx -sonar.coverage.exclusions=src/**/*.tsx,**/*.errors.ts,types/**,src/test-utils/**,**/constants.ts +sonar.coverage.exclusions=src/**/*.tsx,src/errors/**,**/*.errors.ts,types/**,src/test-utils/**,**/constants.ts sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.exclusions=**/*.json,**/assets/**,**/*.css,test-utils/** sonar.cpd.exclusions=**/*.json,**/assets/**,**/*.css \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index a444e2f..f7bf7c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { routes } from './routes'; import { NavigationService } from './services/navigation'; import { Activity, useEffect } from 'react'; import { useAppDispatch } from './store/hooks'; -import { initializeUserThunk } from './store/slices/user/thunks'; +import { initializeUserThunk, refreshAvatarThunk } from './store/slices/user/thunks'; import { Toaster } from 'react-hot-toast'; import { ActionDialog, useActionDialog } from './context/dialog-manager'; import { ComposeMessageDialog } from './components/compose-message'; @@ -19,7 +19,9 @@ function App() { const isComposeMessageDialogOpen = isDialogOpen(ActionDialog.ComposeMessage); useEffect(() => { - dispatch(initializeUserThunk()); + dispatch(initializeUserThunk()) + .unwrap() + .then(() => dispatch(refreshAvatarThunk())); }, []); return ( diff --git a/src/services/config/config.errors.ts b/src/errors/config/index.ts similarity index 100% rename from src/services/config/config.errors.ts rename to src/errors/config/index.ts diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..d9ff61b --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,5 @@ +export * from './config'; +export * from './navigation'; +export * from './oauth'; +export * from './storage'; +export * from './shared'; diff --git a/src/services/navigation/navigation.errors.ts b/src/errors/navigation/index.ts similarity index 100% rename from src/services/navigation/navigation.errors.ts rename to src/errors/navigation/index.ts diff --git a/src/services/oauth/errors/oauth.errors.ts b/src/errors/oauth/index.ts similarity index 78% rename from src/services/oauth/errors/oauth.errors.ts rename to src/errors/oauth/index.ts index 3747b9a..753569b 100644 --- a/src/services/oauth/errors/oauth.errors.ts +++ b/src/errors/oauth/index.ts @@ -24,9 +24,7 @@ export class AuthTimeoutError extends Error { export class OpenAuthPopupError extends Error { constructor() { - super( - 'Failed to open authentication popup. Please check your popup blocker settings.', - ); + super('Failed to open authentication popup. Please check your popup blocker settings.'); Object.setPrototypeOf(this, OpenAuthPopupError.prototype); } @@ -34,9 +32,7 @@ export class OpenAuthPopupError extends Error { export class WebAuthProcessingError extends Error { constructor(cause?: Error) { - super( - `Web authentication processing failed: ${cause instanceof Error ? cause.message : 'Unknown error'}`, - ); + super(`Web authentication processing failed: ${cause instanceof Error ? cause.message : 'Unknown error'}`); Object.setPrototypeOf(this, WebAuthProcessingError.prototype); } diff --git a/src/errors/shared/index.ts b/src/errors/shared/index.ts new file mode 100644 index 0000000..ed5d32c --- /dev/null +++ b/src/errors/shared/index.ts @@ -0,0 +1,7 @@ +export class UserNotFoundError extends Error { + constructor() { + super('User not found'); + + Object.setPrototypeOf(this, UserNotFoundError.prototype); + } +} diff --git a/src/store/queries/errors/storage.errors.ts b/src/errors/storage/index.ts similarity index 100% rename from src/store/queries/errors/storage.errors.ts rename to src/errors/storage/index.ts diff --git a/src/main.tsx b/src/main.tsx index 7251293..c80734d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,7 +11,7 @@ import { ThemeProvider } from './context/theme/ThemeProvider.tsx'; import { LiveChatLoaderProvider } from 'react-live-chat-loader'; import { ConfigService } from './services/config/index.ts'; -store.dispatch(userActions.initialize()); +store.dispatch(userActions.hydrate()); createRoot(document.getElementById('root')!).render( @@ -21,11 +21,11 @@ createRoot(document.getElementById('root')!).render( > - - - - - + + + + + diff --git a/src/services/config/config.service.test.ts b/src/services/config/config.service.test.ts index ec46747..210c832 100644 --- a/src/services/config/config.service.test.ts +++ b/src/services/config/config.service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, vi, beforeEach, test, afterEach } from 'vitest'; -import { VariableNotFoundError } from './config.errors'; +import { VariableNotFoundError } from '@/errors'; import notificationsService, { ToastType } from '../notifications'; import { ConfigService } from '.'; diff --git a/src/services/config/index.ts b/src/services/config/index.ts index 116545b..937daba 100644 --- a/src/services/config/index.ts +++ b/src/services/config/index.ts @@ -1,6 +1,6 @@ import type { Translate } from '@/i18n'; import notificationsService, { ToastType } from '../notifications'; -import { VariableNotFoundError } from './config.errors'; +import { VariableNotFoundError } from '@/errors'; import { APP_VERSION_MATCHERS, PLATFORM_MATCHERS, diff --git a/src/services/navigation/index.ts b/src/services/navigation/index.ts index ad9fcb0..eae65a1 100644 --- a/src/services/navigation/index.ts +++ b/src/services/navigation/index.ts @@ -1,6 +1,6 @@ import { getRouteConfig, getViewByPath, type AppView } from '@/routes/paths'; import type { NavigateFunction, RouterNavigateOptions } from 'react-router-dom'; -import { NavigationNotInitializedError } from './navigation.errors'; +import { NavigationNotInitializedError } from '@/errors'; export class NavigationService { static readonly instance = new NavigationService(); diff --git a/src/services/navigation/navigation.service.test.ts b/src/services/navigation/navigation.service.test.ts index de0e740..3b311d3 100644 --- a/src/services/navigation/navigation.service.test.ts +++ b/src/services/navigation/navigation.service.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, test, vi, beforeEach } from 'vitest'; import { NavigationService } from './index'; -import { NavigationNotInitializedError } from './navigation.errors'; +import { NavigationNotInitializedError } from '@/errors'; import { AppView } from '@/routes/paths'; import type { NavigateFunction } from 'react-router-dom'; diff --git a/src/services/oauth/oauth.service.test.ts b/src/services/oauth/oauth.service.test.ts index a861ad5..a50b94a 100644 --- a/src/services/oauth/oauth.service.test.ts +++ b/src/services/oauth/oauth.service.test.ts @@ -5,7 +5,7 @@ import { OauthService } from './oauth.service'; import { UserService } from '../user/user.service'; import { LocalStorageService } from '../local-storage'; import { WEB_AUTH_CONFIG, WEB_AUTH_MESSAGE_TYPES, type WebAuthMessage, type WebAuthParams } from '@/types/oauth'; -import { AuthCancelledByUserError, MissingAuthParamsToken, WebAuthProcessingError } from './errors/oauth.errors'; +import { AuthCancelledByUserError, MissingAuthParamsToken, WebAuthProcessingError } from '@/errors'; import { getMockedUser } from '@/test-utils/fixtures'; vi.mock('../config', () => ({ diff --git a/src/services/oauth/oauth.service.ts b/src/services/oauth/oauth.service.ts index 8026549..833e2b8 100644 --- a/src/services/oauth/oauth.service.ts +++ b/src/services/oauth/oauth.service.ts @@ -14,7 +14,7 @@ import { MissingAuthParamsToken, OpenAuthPopupError, WebAuthProcessingError, -} from './errors/oauth.errors'; +} from '@/errors'; import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import { LocalStorageService } from '../local-storage'; diff --git a/src/services/sdk/index.ts b/src/services/sdk/index.ts index c51dfb2..4187d7b 100644 --- a/src/services/sdk/index.ts +++ b/src/services/sdk/index.ts @@ -1,4 +1,4 @@ -import { Auth, Drive } from '@internxt/sdk'; +import { Auth, Drive, Mail } from '@internxt/sdk'; import type { ApiSecurity, AppDetails } from '@internxt/sdk/dist/shared'; import packageJson from '../../../package.json'; import { ConfigService } from '../config'; @@ -58,33 +58,46 @@ export class SdkManager { }); const appDetails = SdkManager.getAppDetails(); - return Auth.client(this.apiUrl, appDetails, apiSecurity); + return Auth.client(this.driveApiUrl, appDetails, apiSecurity); } getUsers(): Drive.Users { const apiSecurity = this.getNewTokenApiSecurity(); const appDetails = SdkManager.getAppDetails(); - return Drive.Users.client(this.apiUrl, appDetails, apiSecurity); + return Drive.Users.client(this.driveApiUrl, appDetails, apiSecurity); } getStorage(): Drive.Storage { const apiSecurity = this.getNewTokenApiSecurity(); const appDetails = SdkManager.getAppDetails(); - return Drive.Storage.client(this.apiUrl, appDetails, apiSecurity); + return Drive.Storage.client(this.driveApiUrl, appDetails, apiSecurity); } getPayments(): Drive.Payments { - const paymentsApi = ConfigService.instance.getVariable('PAYMENTS_API_URL'); + const apiSecurity = this.getNewTokenApiSecurity(); + const appDetails = SdkManager.getAppDetails(); + + return Drive.Payments.client(this.paymentsApiUrl, appDetails, apiSecurity); + } + getMail(): Mail { const apiSecurity = this.getNewTokenApiSecurity(); const appDetails = SdkManager.getAppDetails(); - return Drive.Payments.client(paymentsApi, appDetails, apiSecurity); + return Mail.client(this.mailApiUrl, appDetails, apiSecurity); } - get apiUrl(): string { + get driveApiUrl(): string { return ConfigService.instance.getVariable('DRIVE_API_URL'); } + + get mailApiUrl(): string { + return ConfigService.instance.getVariable('MAIL_API_URL'); + } + + get paymentsApiUrl(): string { + return ConfigService.instance.getVariable('PAYMENTS_API_URL'); + } } diff --git a/src/services/sdk/mail/index.ts b/src/services/sdk/mail/index.ts new file mode 100644 index 0000000..769bcc1 --- /dev/null +++ b/src/services/sdk/mail/index.ts @@ -0,0 +1,39 @@ +import type { EmailListResponse, ListEmailsQuery, MailboxResponse, UpdateEmailRequest } from '@internxt/sdk'; +import { SdkManager } from '..'; + +export class MailService { + public static readonly instance: MailService = new MailService(); + + get client() { + return SdkManager.instance.getMail(); + } + + /** + * Returns a list of all mailboxes and their properties. + * + * @returns A promise that resolves with an array of MailboxResponse objects. + */ + async getMailboxesInfo(): Promise { + return this.client.getMailboxes(); + } + + /** + * Returns a list of emails in the given folder. + * + * @param {ListEmailsQuery} query - The query parameters + * @returns A promise that resolves with an EmailListResponse object + */ + async listFolder(query?: ListEmailsQuery): Promise { + return this.client.listEmails(query); + } + + /** + * Updates the status of a specific email. + * @param {string} emailId - The ID of the email to update + * @param {UpdateEmailRequest} status - The new status of the email + * @returns A promise that resolves when the update operation is complete + */ + async updateEmailStatus(emailId: string, status: UpdateEmailRequest): Promise { + return this.client.updateEmail(emailId, status); + } +} diff --git a/src/services/sdk/mail/mail.service.test.ts b/src/services/sdk/mail/mail.service.test.ts new file mode 100644 index 0000000..177ee45 --- /dev/null +++ b/src/services/sdk/mail/mail.service.test.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest'; +import { SdkManager } from '..'; +import { MailService } from '.'; +import { getMockedMails, getMockedMailBoxes } from '@/test-utils/fixtures'; + +describe('Mail Service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Get mailboxes info', () => { + test('When fetching mailboxes, then all mailboxes should be returned', async () => { + const mockedMailboxes = getMockedMailBoxes(); + + const mockMailClient = { + getMailboxes: vi.fn().mockResolvedValue(mockedMailboxes), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + const result = await MailService.instance.getMailboxesInfo(); + + expect(result).toStrictEqual(mockedMailboxes); + }); + + test('When fetching mailboxes fails, then an error should be thrown', async () => { + const unexpectedError = new Error('Unexpected error'); + + const mockMailClient = { + getMailboxes: vi.fn().mockRejectedValue(unexpectedError), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + await expect(MailService.instance.getMailboxesInfo()).rejects.toThrow(unexpectedError); + }); + }); + + describe('List folder', () => { + test('When listing a folder, then the emails should be returned', async () => { + const mockedEmails = getMockedMails(5); + const query = { mailboxId: 'inbox', limit: 10, offset: 0 }; + + const mockMailClient = { + listEmails: vi.fn().mockResolvedValue(mockedEmails), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + const result = await MailService.instance.listFolder(query); + + expect(result).toStrictEqual(mockedEmails); + expect(mockMailClient.listEmails).toHaveBeenCalledWith(query); + }); + + test('When listing a folder without query params, then all emails should be returned', async () => { + const mockedEmails = getMockedMails(); + + const mockMailClient = { + listEmails: vi.fn().mockResolvedValue(mockedEmails), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + const result = await MailService.instance.listFolder(); + + expect(result).toStrictEqual(mockedEmails); + expect(mockMailClient.listEmails).toHaveBeenCalledWith(undefined); + }); + + test('When listing a folder fails, then an error should be thrown', async () => { + const unexpectedError = new Error('Unexpected error'); + + const mockMailClient = { + listEmails: vi.fn().mockRejectedValue(unexpectedError), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + await expect(MailService.instance.listFolder()).rejects.toThrow(unexpectedError); + }); + }); + + describe('Update email status', () => { + test('When updating an email status, then the client should be called with the correct params', async () => { + const emailId = 'email-123'; + const status = { isRead: true }; + + const mockMailClient = { + updateEmail: vi.fn().mockResolvedValue(undefined), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + await MailService.instance.updateEmailStatus(emailId, status); + + expect(mockMailClient.updateEmail).toHaveBeenCalledWith(emailId, status); + }); + + test('When updating an email status fails, then an error should be thrown', async () => { + const unexpectedError = new Error('Unexpected error'); + + const mockMailClient = { + updateEmail: vi.fn().mockRejectedValue(unexpectedError), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + await expect(MailService.instance.updateEmailStatus('email-123', { isRead: true })).rejects.toThrow( + unexpectedError, + ); + }); + }); +}); diff --git a/src/services/sdk/sdk.service.test.ts b/src/services/sdk/sdk.service.test.ts index c2d592d..1e31ee8 100644 --- a/src/services/sdk/sdk.service.test.ts +++ b/src/services/sdk/sdk.service.test.ts @@ -1,4 +1,4 @@ -import { Auth, Drive } from '@internxt/sdk'; +import { Auth, Drive, Mail } from '@internxt/sdk'; import { beforeEach, describe, expect, test, vi, afterEach } from 'vitest'; import { SdkManager } from '.'; import { ConfigService } from '../config'; @@ -49,8 +49,22 @@ vi.mock('@internxt/sdk', () => ({ })), }, }, + Mail: { + client: vi.fn().mockImplementation((baseUrl, appDetails, security) => ({ + baseUrl, + appDetails, + security, + unauthorizedCallback: vi.fn(), + })), + }, })); +const config: Record = { + DRIVE_API_URL: 'https://api-drive.internxt.com', + PAYMENTS_API_URL: 'https://api-payments.internxt.com', + MAIL_API_URL: 'https://api-mail.internxt.com', +}; + describe('SDK Manager', () => { beforeEach(() => { SdkManager.clean(); @@ -58,10 +72,6 @@ describe('SDK Manager', () => { vi.clearAllMocks(); vi.spyOn(ConfigService.instance, 'getVariable').mockImplementation((key: string) => { - const config: Record = { - DRIVE_API_URL: 'https://api-drive.internxt.com', - PAYMENTS_API_URL: 'https://api-payments.internxt.com', - }; return config[key] || ''; }); @@ -143,7 +153,7 @@ describe('SDK Manager', () => { expect(authClient).toBeDefined(); expect(Auth.client).toHaveBeenCalledWith( - 'https://api-drive.internxt.com', + config.DRIVE_API_URL, expect.objectContaining({ clientName: 'mail-web', clientVersion: expect.any(String), @@ -157,7 +167,7 @@ describe('SDK Manager', () => { expect(authClient).toBeDefined(); expect(Auth.client).toHaveBeenCalledWith( - 'https://api-drive.internxt.com', + config.DRIVE_API_URL, expect.objectContaining({ clientName: 'mail-web', }), @@ -173,7 +183,7 @@ describe('SDK Manager', () => { expect(usersClient).toBeDefined(); expect(LocalStorageService.instance.getToken).toHaveBeenCalled(); expect(Drive.Users.client).toHaveBeenCalledWith( - 'https://api-drive.internxt.com', + config.DRIVE_API_URL, expect.objectContaining({ clientName: 'mail-web', clientVersion: expect.any(String), @@ -203,7 +213,7 @@ describe('SDK Manager', () => { expect(storageClient).toBeDefined(); expect(LocalStorageService.instance.getToken).toHaveBeenCalled(); expect(Drive.Storage.client).toHaveBeenCalledWith( - 'https://api-drive.internxt.com', + config.DRIVE_API_URL, expect.objectContaining({ clientName: 'mail-web', clientVersion: expect.any(String), @@ -233,7 +243,7 @@ describe('SDK Manager', () => { expect(paymentsClient).toBeDefined(); expect(LocalStorageService.instance.getToken).toHaveBeenCalled(); expect(Drive.Payments.client).toHaveBeenCalledWith( - 'https://api-payments.internxt.com', + config.PAYMENTS_API_URL, expect.objectContaining({ clientName: 'mail-web', clientVersion: expect.any(String), @@ -255,4 +265,34 @@ describe('SDK Manager', () => { expect(AuthService.instance.logOut).toHaveBeenCalled(); }); }); + + describe('Mail client creation', () => { + test('When creating Payments client, then it should use correct API URL and token', () => { + const paymentsClient = SdkManager.instance.getMail(); + + expect(paymentsClient).toBeDefined(); + expect(LocalStorageService.instance.getToken).toHaveBeenCalled(); + expect(Mail.client).toHaveBeenCalledWith( + config.MAIL_API_URL, + expect.objectContaining({ + clientName: 'mail-web', + clientVersion: expect.any(String), + }), + expect.objectContaining({ + token: 'mock-token', + unauthorizedCallback: expect.any(Function), + }), + ); + }); + + test('When Payments client receives unauthorized response, then logOut should be called', () => { + SdkManager.instance.getMail(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const securityArg = (Mail.client as any).mock.calls[0][2]; + securityArg.unauthorizedCallback(); + + expect(AuthService.instance.logOut).toHaveBeenCalled(); + }); + }); }); diff --git a/src/services/user/user.service.ts b/src/services/user/user.service.ts index 5c012b9..6da0ee1 100644 --- a/src/services/user/user.service.ts +++ b/src/services/user/user.service.ts @@ -25,4 +25,14 @@ export class UserService { const refreshResponse = await usersClient.refreshUser(); return refreshResponse; }; + + /** + * Refreshes user avatar + * @returns The refreshed user avatar + */ + public refreshUserAvatar = async () => { + const usersClient = SdkManager.instance.getUsers(); + const refreshResponse = await usersClient.refreshAvatarUser(); + return refreshResponse.avatar; + }; } diff --git a/src/store/queries/storage/storage.query.test.ts b/src/store/queries/storage/storage.query.test.ts index a1182d4..f3044b7 100644 --- a/src/store/queries/storage/storage.query.test.ts +++ b/src/store/queries/storage/storage.query.test.ts @@ -5,7 +5,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { storageQuery } from './storage.query'; import { StorageService } from '@/services/sdk/storage'; import { ErrorService } from '@/services/error'; -import { FetchStorageLimitError, FetchStorageUsageError } from '../errors/storage.errors'; +import { FetchStorageLimitError, FetchStorageUsageError } from '@/errors'; vi.mock('@/services/error', () => ({ ErrorService: { diff --git a/src/store/queries/storage/storage.query.ts b/src/store/queries/storage/storage.query.ts index 4d51b92..a38e942 100644 --- a/src/store/queries/storage/storage.query.ts +++ b/src/store/queries/storage/storage.query.ts @@ -1,6 +1,6 @@ import { StorageService } from '@/services/sdk/storage'; import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react'; -import { FetchStorageLimitError, FetchStorageUsageError } from '../errors/storage.errors'; +import { FetchStorageLimitError, FetchStorageUsageError } from '@/errors'; import { ErrorService } from '@/services/error'; export const storageQuery = createApi({ diff --git a/src/store/slices/user/index.ts b/src/store/slices/user/index.ts index 3b49e28..65516cc 100644 --- a/src/store/slices/user/index.ts +++ b/src/store/slices/user/index.ts @@ -30,7 +30,7 @@ export const userSlice = createSlice({ name: 'user', initialState: initialUserState, reducers: { - initialize: (state: UserState) => { + hydrate: (state: UserState) => { state.user = LocalStorageService.instance.getUser() || undefined; state.isAuthenticated = !!state.user; }, @@ -65,7 +65,7 @@ export const userSlice = createSlice({ state.isInitializing = false; }) .addCase(initializeUserThunk.rejected, (state, action) => { - const errorMsg = action.payload ? action.payload : ''; + const errorMsg = action.error.message ? action.error.message : ''; state.isInitializing = false; notificationsService.show({ @@ -82,7 +82,7 @@ export const userSlice = createSlice({ }, }); -export const { initialize, resetState, setIsUserInitialized } = userSlice.actions; +export const { hydrate, resetState, setIsUserInitialized } = userSlice.actions; export const userActions = userSlice.actions; export default userSlice.reducer; diff --git a/src/store/slices/user/thunks/index.ts b/src/store/slices/user/thunks/index.ts index dd02299..468ccb2 100644 --- a/src/store/slices/user/thunks/index.ts +++ b/src/store/slices/user/thunks/index.ts @@ -1,2 +1,3 @@ export { initializeUserThunk } from './initializeUserThunk'; export { logoutThunk } from './logOutThunk'; +export { refreshAvatarThunk } from './refreshAvatarThunk'; diff --git a/src/store/slices/user/thunks/refreshAvatarThunk/index.ts b/src/store/slices/user/thunks/refreshAvatarThunk/index.ts new file mode 100644 index 0000000..68b0351 --- /dev/null +++ b/src/store/slices/user/thunks/refreshAvatarThunk/index.ts @@ -0,0 +1,22 @@ +import { UserService } from '@/services/user/user.service'; +import type { RootState } from '@/store'; +import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { userActions } from '../..'; + +export const refreshAvatarThunk = createAsyncThunk( + 'user/refreshAvatar', + async (_, { getState, dispatch }) => { + const user = getState().user.user; + if (!user) return undefined; + + const userAvatar = await UserService.instance.refreshUserAvatar(); + + dispatch( + userActions.setUser({ + ...user, + avatar: userAvatar, + }), + ); + }, +); diff --git a/src/store/slices/user/thunks/refreshAvatarThunk/refreshAvatarThunk.test.ts b/src/store/slices/user/thunks/refreshAvatarThunk/refreshAvatarThunk.test.ts new file mode 100644 index 0000000..27d5a83 --- /dev/null +++ b/src/store/slices/user/thunks/refreshAvatarThunk/refreshAvatarThunk.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, vi, beforeEach, test } from 'vitest'; + +import { NavigationService } from '@/services/navigation'; +import { createTestStore } from '@/test-utils/createTestStore'; + +import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { refreshAvatarThunk } from '.'; +import { UserService } from '@/services/user/user.service'; +import { getMockedUser } from '@/test-utils/fixtures'; + +describe('Refresh User Avatar Thunk', () => { + beforeEach(() => { + vi.restoreAllMocks(); + NavigationService.instance.init(vi.fn()); + }); + + test('When refreshing the avatar, then it should be fetched correctly', async () => { + const mockedAvatar = getMockedUser().avatar; + vi.spyOn(UserService.instance, 'refreshUserAvatar').mockResolvedValue(mockedAvatar); + const store = createTestStore({ + user: { + user: { name: 'Test', uuid: '123', email: 'test@example' } as UserSettings, + }, + }); + + await store.dispatch(refreshAvatarThunk()); + + expect(store.getState().user.user?.avatar).toBe(mockedAvatar); + }); + + test('When there is no user to update the avatar, then the user state should not be modified', async () => { + vi.spyOn(UserService.instance, 'refreshUserAvatar').mockResolvedValue(null); + const store = createTestStore({ + user: { + user: undefined, + }, + }); + + const result = await store.dispatch(refreshAvatarThunk()); + + expect(result.payload).toBeUndefined(); + expect(store.getState().user.user).toBeUndefined(); + }); +}); diff --git a/src/store/slices/user/userSlice.test.ts b/src/store/slices/user/userSlice.test.ts index bc33e9f..a14efcb 100644 --- a/src/store/slices/user/userSlice.test.ts +++ b/src/store/slices/user/userSlice.test.ts @@ -24,7 +24,7 @@ describe('User slice', () => { test('When the user is saved in the local storage, then it should be authenticated', () => { vi.spyOn(LocalStorageService.instance, 'getUser').mockReturnValue(mockedUser); - const state = userReducer(initialUserState, userActions.initialize()); + const state = userReducer(initialUserState, userActions.hydrate()); expect(state.user).toStrictEqual(mockedUser); expect(state.isAuthenticated).toBeTruthy(); @@ -33,7 +33,7 @@ describe('User slice', () => { test('When the user is not saved in the local storage, then it should not be authenticated', () => { vi.spyOn(LocalStorageService.instance, 'getUser').mockReturnValue(null); - const state = userReducer(initialUserState, userActions.initialize()); + const state = userReducer(initialUserState, userActions.hydrate()); expect(state.user).toBeUndefined(); expect(state.isAuthenticated).toBeFalsy(); diff --git a/vite.config.ts b/vite.config.ts index 5ecf052..08921b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,4 +28,7 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + optimizeDeps: { + include: ['@internxt/sdk'], + }, });