mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-31 12:19:47 +02:00
567 lines
16 KiB
TypeScript
567 lines
16 KiB
TypeScript
import {
|
|
AccountUuid,
|
|
PersonId,
|
|
WorkspaceUuid,
|
|
type SocialId,
|
|
type SocialIdType,
|
|
type MeasureContext
|
|
} from '@hcengineering/core'
|
|
|
|
import { GmailClient } from '../gmail'
|
|
import { ProjectCredentials, ProjectCredentialsData, Token, User } from '../types'
|
|
import { TokenStorage } from '../tokens'
|
|
import { SyncManager } from '../message/sync'
|
|
|
|
// Mock dependencies
|
|
const mockedGoogleAuth = {
|
|
generateAuthUrl: jest.fn().mockReturnValue('https://auth-url.com'),
|
|
getToken: jest.fn().mockResolvedValue({ tokens: { access_token: 'new-token' } }),
|
|
setCredentials: jest.fn(),
|
|
refreshAccessToken: jest.fn().mockResolvedValue({ credentials: { access_token: 'refreshed-token' } }),
|
|
revokeCredentials: jest.fn().mockResolvedValue({})
|
|
}
|
|
|
|
const mockedGmailUsers = {
|
|
getProfile: jest.fn().mockResolvedValue({ data: { emailAddress: 'test@example.com' } }),
|
|
messages: {
|
|
send: jest.fn().mockResolvedValue({}),
|
|
list: jest.fn().mockResolvedValue({ data: { messages: [] } })
|
|
},
|
|
watch: jest.fn().mockResolvedValue({}),
|
|
stop: jest.fn().mockResolvedValue({})
|
|
}
|
|
|
|
// Mock all imports before they're used
|
|
jest.mock('@hcengineering/core', () => {
|
|
return {
|
|
AccountUuid: String,
|
|
PersonId: String,
|
|
WorkspaceUuid: String,
|
|
// Provide missing properties
|
|
configUserAccountUuid: 'test-user-id',
|
|
core: {
|
|
space: {
|
|
Workspace: 'workspace'
|
|
}
|
|
},
|
|
TxOperations: jest.fn().mockImplementation(() => ({
|
|
findAll: jest.fn().mockResolvedValue([]),
|
|
findOne: jest.fn().mockResolvedValue(undefined),
|
|
createDoc: jest.fn().mockResolvedValue({}),
|
|
update: jest.fn().mockResolvedValue({}),
|
|
remove: jest.fn().mockResolvedValue({}),
|
|
updateDoc: jest.fn().mockResolvedValue({}),
|
|
tx: jest.fn().mockResolvedValue({})
|
|
})),
|
|
SocialIdType: {
|
|
Email: 'email'
|
|
}
|
|
}
|
|
})
|
|
|
|
jest.mock('@hcengineering/mail-common', () => ({
|
|
createMessages: jest.fn().mockResolvedValue(undefined)
|
|
}))
|
|
|
|
jest.mock('googleapis', () => ({
|
|
gmail_v1: {},
|
|
google: {
|
|
auth: {
|
|
OAuth2: jest.fn().mockImplementation(() => mockedGoogleAuth)
|
|
},
|
|
gmail: jest.fn().mockImplementation(() => ({
|
|
users: mockedGmailUsers
|
|
}))
|
|
}
|
|
}))
|
|
|
|
jest.mock('../tokens')
|
|
jest.mock('../message/adapter')
|
|
jest.mock('../message/sync')
|
|
jest.mock('../message/attachments')
|
|
jest.mock('@hcengineering/server-client', () => ({
|
|
getAccountClient: jest.fn()
|
|
}))
|
|
jest.mock('../accounts', () => ({
|
|
getOrCreateSocialId: jest.fn().mockResolvedValue({ _id: 'test-social-id' })
|
|
}))
|
|
jest.mock('../gmail/utils', () => ({
|
|
getEmail: jest.fn().mockResolvedValue('test@example.com')
|
|
}))
|
|
jest.mock('../integrations', () => ({
|
|
createIntegrationIfNotEsixts: jest.fn().mockResolvedValue({ _id: 'test-integration-id' }),
|
|
disableIntegration: jest.fn(),
|
|
removeIntegration: jest.fn()
|
|
}))
|
|
jest.mock('../utils', () => ({
|
|
addFooter: jest.fn().mockImplementation((content: string) => content),
|
|
isToken: jest.fn().mockImplementation((obj: any) => 'token' in obj),
|
|
serviceToken: jest.fn().mockReturnValue('service-token'),
|
|
getKvsClient: jest.fn().mockImplementation(() => ({
|
|
getValue: jest.fn(),
|
|
setValue: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
|
|
deleteKey: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
|
|
listKeys: jest.fn()
|
|
}))
|
|
}))
|
|
|
|
// Mock gmail module
|
|
jest.mock('@hcengineering/gmail', () => ({
|
|
integrationType: {
|
|
Gmail: 'gmail'
|
|
},
|
|
class: {
|
|
NewMessage: 'class.NewMessage'
|
|
}
|
|
}))
|
|
|
|
// Mock setting module
|
|
jest.mock('@hcengineering/setting', () => ({
|
|
class: {
|
|
Integration: 'class.Integration'
|
|
}
|
|
}))
|
|
|
|
// Mock config
|
|
jest.mock('../config', () => ({
|
|
WATCH_TOPIC_NAME: 'test-topic'
|
|
}))
|
|
|
|
jest.mock('@hcengineering/account-client', () => ({
|
|
getClient: jest.fn().mockImplementation(() => ({
|
|
getLoginInfoByToken: jest.fn().mockResolvedValue({
|
|
endpoint: 'wss://test-endpoint.com',
|
|
workspace: 'mockWorkspaceId',
|
|
token: 'test-token'
|
|
})
|
|
})),
|
|
isWorkspaceLoginInfo: jest.fn().mockImplementation(() => true)
|
|
}))
|
|
|
|
describe('GmailClient', () => {
|
|
const mockContext: MeasureContext = {
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
measure: jest.fn().mockImplementation((name: string, fn: () => any) => fn()),
|
|
with: jest.fn().mockImplementation((data: any, fn: () => any) => fn())
|
|
} as any
|
|
|
|
const mockWorkspaceId = 'test-workspace' as WorkspaceUuid
|
|
const mockUserId = 'test-user-id' as AccountUuid
|
|
|
|
// Fix the SocialId type issue
|
|
const mockSocialId: SocialId = {
|
|
_id: 'test-social-id' as PersonId,
|
|
value: 'test@example.com',
|
|
type: 'email' as SocialIdType,
|
|
key: 'email'
|
|
}
|
|
|
|
const credentialsData: ProjectCredentialsData = {
|
|
client_id: 'client-id',
|
|
client_secret: 'client-secret',
|
|
redirect_uris: ['https://redirect-uri.com'],
|
|
project_id: 'test-project',
|
|
auth_uri: 'https://auth-uri.com',
|
|
token_uri: 'https://token-uri.com',
|
|
auth_provider_x509_cert_url: 'https://cert-url.com'
|
|
}
|
|
const mockCredentials: ProjectCredentials = {
|
|
web: credentialsData
|
|
}
|
|
|
|
const mockUser: User = {
|
|
userId: mockUserId,
|
|
workspace: mockWorkspaceId,
|
|
socialId: mockSocialId,
|
|
email: 'test@example.com',
|
|
token: 'user-token'
|
|
}
|
|
|
|
const mockToken: Token = {
|
|
userId: mockUserId,
|
|
workspace: mockWorkspaceId,
|
|
socialId: mockSocialId,
|
|
token: 'test-token',
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
scope: 'test-scope',
|
|
token_type: 'Bearer',
|
|
expiry_date: 123456789
|
|
}
|
|
|
|
const mockClient = {
|
|
findAll: jest.fn().mockResolvedValue([]),
|
|
findOne: jest.fn().mockResolvedValue(undefined),
|
|
createDoc: jest.fn().mockResolvedValue({}),
|
|
update: jest.fn().mockResolvedValue({}),
|
|
remove: jest.fn().mockResolvedValue({}),
|
|
updateDoc: jest.fn().mockResolvedValue({}),
|
|
tx: jest.fn().mockResolvedValue({})
|
|
}
|
|
|
|
const mockWorkspaceClient = {
|
|
subscribeMessages: jest.fn().mockResolvedValue({}),
|
|
signoutBySocialId: jest.fn().mockResolvedValue({})
|
|
}
|
|
|
|
const mockStorageAdapter = {}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
// Fix unbound method errors by using the proper mock assignment approach
|
|
jest.spyOn(TokenStorage.prototype, 'getToken').mockResolvedValue(mockToken)
|
|
jest.spyOn(TokenStorage.prototype, 'saveToken').mockResolvedValue({} as any)
|
|
jest.spyOn(TokenStorage.prototype, 'deleteToken').mockResolvedValue()
|
|
jest.spyOn(SyncManager.prototype, 'sync').mockResolvedValue()
|
|
jest.spyOn(SyncManager.prototype, 'fullSync').mockResolvedValue()
|
|
})
|
|
|
|
it('should create a GmailClient instance', async () => {
|
|
const client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any
|
|
)
|
|
|
|
expect(client).toBeInstanceOf(GmailClient)
|
|
expect(TokenStorage).toHaveBeenCalled()
|
|
expect(mockContext.info).toHaveBeenCalledWith('Created gmail client', expect.any(Object))
|
|
})
|
|
|
|
it('should create a GmailClient instance with auth code', async () => {
|
|
const client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any,
|
|
'auth-code'
|
|
)
|
|
|
|
expect(client).toBeInstanceOf(GmailClient)
|
|
expect(mockedGoogleAuth.setCredentials).toHaveBeenCalled()
|
|
expect(mockContext.info).toHaveBeenCalledWith('Created gmail client', expect.any(Object))
|
|
})
|
|
|
|
it('should generate auth URL', async () => {
|
|
const client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any
|
|
)
|
|
|
|
const authUrl = client.getAuthUrl('https://callback-url.com')
|
|
expect(authUrl).toBe('https://auth-url.com')
|
|
})
|
|
|
|
it('should sign out and clean up resources', async () => {
|
|
const client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any
|
|
)
|
|
|
|
await client.signout()
|
|
|
|
// Fix unbound method reference by using the spy
|
|
expect(jest.spyOn(TokenStorage.prototype, 'deleteToken')).toHaveBeenCalledWith(mockSocialId._id)
|
|
expect(mockContext.info).toHaveBeenCalledWith('Deactivate gmail client', expect.any(Object))
|
|
})
|
|
|
|
it('should create a message', async () => {
|
|
const client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any
|
|
)
|
|
|
|
const newMessage = {
|
|
_id: 'test-message-id',
|
|
_class: 'class.NewMessage',
|
|
space: 'space',
|
|
status: 'new',
|
|
from: mockSocialId._id,
|
|
to: 'recipient@example.com',
|
|
subject: 'Test Subject',
|
|
content: '<p>Test content</p>'
|
|
}
|
|
|
|
// Mock internal TxOperations instance
|
|
const mockTxOperations = {
|
|
findAll: jest.fn().mockResolvedValue([]),
|
|
findOne: jest.fn().mockResolvedValue(undefined),
|
|
createDoc: jest.fn().mockResolvedValue({}),
|
|
update: jest.fn().mockResolvedValue({}),
|
|
remove: jest.fn().mockResolvedValue({}),
|
|
updateDoc: jest.fn().mockResolvedValue({}),
|
|
tx: jest.fn().mockResolvedValue({})
|
|
}
|
|
|
|
// Access and replace the private TxOperations instance
|
|
Object.defineProperty(client, 'client', {
|
|
value: mockTxOperations,
|
|
writable: true
|
|
})
|
|
|
|
await client.createMessage(newMessage as any)
|
|
|
|
expect(mockTxOperations.updateDoc).toHaveBeenCalledWith('class.NewMessage', 'space', 'test-message-id', {
|
|
status: 'sent'
|
|
})
|
|
})
|
|
|
|
it('should sync messages', async () => {
|
|
const client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any
|
|
)
|
|
|
|
await client.sync({ noNotify: true })
|
|
|
|
// Fix unbound method reference by using the spy
|
|
expect(jest.spyOn(SyncManager.prototype, 'sync')).toHaveBeenCalledWith(
|
|
mockSocialId._id,
|
|
{ noNotify: true },
|
|
'test@example.com'
|
|
)
|
|
})
|
|
|
|
it('should initialize integration', async () => {
|
|
const client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any
|
|
)
|
|
|
|
await client.initIntegration()
|
|
|
|
expect(mockContext.info).toHaveBeenCalledWith('Init integration', expect.any(Object))
|
|
})
|
|
|
|
describe('initIntegration', () => {
|
|
let client: GmailClient
|
|
let mockTxOperations: any
|
|
|
|
beforeEach(async () => {
|
|
mockTxOperations = {
|
|
findAll: jest.fn(),
|
|
findOne: jest.fn(),
|
|
createDoc: jest.fn(),
|
|
update: jest.fn(),
|
|
remove: jest.fn(),
|
|
updateDoc: jest.fn(),
|
|
tx: jest.fn()
|
|
}
|
|
|
|
client = await GmailClient.create(
|
|
mockContext as any,
|
|
mockCredentials,
|
|
mockUser,
|
|
mockClient as any,
|
|
mockWorkspaceClient as any,
|
|
mockWorkspaceId,
|
|
mockStorageAdapter as any
|
|
)
|
|
|
|
// Replace the private TxOperations instance
|
|
Object.defineProperty(client, 'client', {
|
|
value: mockTxOperations,
|
|
writable: true
|
|
})
|
|
|
|
// Mock the methods that are called with void in initIntegration
|
|
jest.spyOn(client, 'startSync' as any).mockResolvedValue(undefined)
|
|
jest.spyOn(client, 'getNewMessagesAfterAuth').mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('should use active integration if it exists', async () => {
|
|
const activeIntegration = {
|
|
_id: 'active-integration-1',
|
|
value: 'test@example.com',
|
|
disabled: false,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
mockTxOperations.findAll.mockResolvedValue([activeIntegration])
|
|
|
|
await client.initIntegration()
|
|
|
|
expect(mockTxOperations.findAll).toHaveBeenCalledWith('class.Integration', {
|
|
createdBy: mockSocialId._id,
|
|
type: 'gmail',
|
|
value: 'test@example.com'
|
|
})
|
|
|
|
// Should not update the active integration
|
|
expect(mockTxOperations.update).not.toHaveBeenCalled()
|
|
// Should not create new integration
|
|
expect(mockTxOperations.createDoc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should enable disabled integration if no active one exists', async () => {
|
|
const disabledIntegration = {
|
|
_id: 'disabled-integration-1',
|
|
value: 'test@example.com',
|
|
disabled: true,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
mockTxOperations.findAll.mockResolvedValue([disabledIntegration])
|
|
|
|
await client.initIntegration()
|
|
|
|
expect(mockTxOperations.update).toHaveBeenCalledWith(disabledIntegration, {
|
|
disabled: false
|
|
})
|
|
|
|
// Should not create new integration
|
|
expect(mockTxOperations.createDoc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should prefer active integration over disabled one', async () => {
|
|
const activeIntegration = {
|
|
_id: 'active-integration-1',
|
|
value: 'test@example.com',
|
|
disabled: false,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
const disabledIntegration = {
|
|
_id: 'disabled-integration-1',
|
|
value: 'test@example.com',
|
|
disabled: true,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
mockTxOperations.findAll.mockResolvedValue([disabledIntegration, activeIntegration])
|
|
|
|
await client.initIntegration()
|
|
|
|
// Should not update the active integration
|
|
expect(mockTxOperations.update).not.toHaveBeenCalledWith(activeIntegration, expect.any(Object))
|
|
// Should not create new integration
|
|
expect(mockTxOperations.createDoc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should disable multiple active integrations and keep one', async () => {
|
|
const activeIntegration1 = {
|
|
_id: 'active-integration-1',
|
|
value: 'test@example.com',
|
|
disabled: false,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
const activeIntegration2 = {
|
|
_id: 'active-integration-2',
|
|
value: 'test@example.com',
|
|
disabled: false,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
mockTxOperations.findAll.mockResolvedValue([activeIntegration1, activeIntegration2])
|
|
|
|
await client.initIntegration()
|
|
|
|
// Should disable one of the active integrations
|
|
expect(mockTxOperations.update).toHaveBeenCalledWith(activeIntegration2, {
|
|
disabled: true
|
|
})
|
|
|
|
// Should warn about multiple active integrations
|
|
expect(mockContext.warn).toHaveBeenCalledWith(
|
|
'Found several active integrations for the same email, disabling one',
|
|
{
|
|
email: 'test@example.com',
|
|
integrationId: 'active-integration-2'
|
|
}
|
|
)
|
|
|
|
// Should not create new integration
|
|
expect(mockTxOperations.createDoc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle mixed active and disabled integrations correctly', async () => {
|
|
const activeIntegration = {
|
|
_id: 'active-integration-1',
|
|
value: 'test@example.com',
|
|
disabled: false,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
const disabledIntegration1 = {
|
|
_id: 'disabled-integration-1',
|
|
value: 'test@example.com',
|
|
disabled: true,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
const disabledIntegration2 = {
|
|
_id: 'disabled-integration-2',
|
|
value: 'test@example.com',
|
|
disabled: true,
|
|
type: 'gmail',
|
|
createdBy: mockSocialId._id
|
|
}
|
|
|
|
mockTxOperations.findAll.mockResolvedValue([disabledIntegration1, activeIntegration, disabledIntegration2])
|
|
|
|
await client.initIntegration()
|
|
|
|
// Should not update any integration (disabled ones should remain disabled, active one should remain active)
|
|
expect(mockTxOperations.update).not.toHaveBeenCalled()
|
|
|
|
// Should not create new integration
|
|
expect(mockTxOperations.createDoc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
mockTxOperations.findAll.mockRejectedValue(new Error('Database error'))
|
|
|
|
await client.initIntegration()
|
|
|
|
expect(mockContext.info).toHaveBeenCalledWith('Failed to start message sync', {
|
|
workspaceUuid: mockUser.workspace,
|
|
userId: mockUser.userId,
|
|
error: 'Database error'
|
|
})
|
|
})
|
|
})
|
|
})
|