Files
huly-platform/packages/integration-client/src/__tests__/client.test.ts
T
Artyom SavchenkoandGitHub 7e92c12073 UBERF-11414: Integrations (#9610)
* UBERF-11414: Use common integration kind

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Allow multiple gmail

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Merge with develop

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Refactor integration kinds

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Add integrations package

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Integrations

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Integration state and config

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Add integration events

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Refresh integration states

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Rename to integration-client

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Add filter button

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Add translations for filters

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Synced channels count

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Actions

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Add isLoading for connect

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Create connection and integration

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Use social id from hulygram

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Remove old integrations

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Gmail state

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Gmail state and disconnect all handler

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Add tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Formatting

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Gmail sync state

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix rush check

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix gmail tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix calendar actions and config

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Calendar state

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Access config

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Github integration state

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix svelte errors

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix formatting

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix typos

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix validate

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Fix translations

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-11414: Retry on network issues

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
2025-08-04 15:14:06 +07:00

551 lines
17 KiB
TypeScript

//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core'
import { Integration, IntegrationKey, type AccountClient } from '@hcengineering/account-client'
import { IntegrationClientImpl } from '../client'
import { IntegrationClient } from '../types'
// Mock the events module before importing anything else
const mockEvents = {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn()
}
jest.mock('../events', () => ({
getIntegrationEventBus: () => mockEvents
}))
// Mock account client
const mockAccountClient: jest.Mocked<AccountClient> = {
listIntegrations: jest.fn(),
getIntegration: jest.fn(),
createIntegration: jest.fn(),
updateIntegration: jest.fn(),
deleteIntegration: jest.fn()
} as any
const integrationKind: IntegrationKind = 'gmail' as IntegrationKind
const serviceName = 'gmail-service'
const testSocialId: PersonId = 'social-123' as PersonId
const testWorkspaceId: WorkspaceUuid = 'workspace-456' as WorkspaceUuid
describe('IntegrationClientImpl', () => {
let client: IntegrationClient
beforeEach(() => {
jest.clearAllMocks()
// Reset mock event handlers
mockEvents.on.mockReturnValue(() => {})
mockEvents.off.mockImplementation(() => {})
mockEvents.emit.mockImplementation(() => {})
client = new IntegrationClientImpl(mockAccountClient, integrationKind, serviceName)
})
describe('getIntegrations', () => {
it('should return integrations from account client', async () => {
const mockIntegrations: Integration[] = [
{
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: { test: true }
}
]
mockAccountClient.listIntegrations.mockResolvedValue(mockIntegrations)
const result = await client.getIntegrations()
expect(mockAccountClient.listIntegrations).toHaveBeenCalledWith({ kind: integrationKind })
expect(result).toEqual(mockIntegrations)
})
it('should return empty array when account client returns null', async () => {
mockAccountClient.listIntegrations.mockResolvedValue([])
const result = await client.getIntegrations()
expect(result).toEqual([])
})
})
describe('getConnection', () => {
it('should return integration if it is already a connection', async () => {
const connectionIntegration: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: { isConnection: true }
}
const result = await client.getConnection(connectionIntegration)
expect(result).toBe(connectionIntegration)
expect(mockAccountClient.getIntegration).not.toHaveBeenCalled()
})
it('should fetch connection for non-connection integration', async () => {
const integration: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: { test: true }
}
const connectionIntegration: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: { isConnection: true }
}
mockAccountClient.getIntegration.mockResolvedValue(connectionIntegration)
const result = await client.getConnection(integration)
expect(mockAccountClient.getIntegration).toHaveBeenCalledWith({
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null
})
expect(result).toBe(connectionIntegration)
})
it('should emit error event and rethrow when getIntegration fails', async () => {
const integration: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: { test: true }
}
const error = new Error('Connection failed')
mockAccountClient.getIntegration.mockRejectedValue(error)
await expect(client.getConnection(integration)).rejects.toThrow('Connection failed')
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:error',
expect.objectContaining({
operation: 'connect',
error: 'Connection failed',
socialId: testSocialId,
timestamp: expect.any(Number)
})
)
})
})
describe('integrate', () => {
it('should create new integration when none exists', async () => {
const connection: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: { _id: 'conn-123', other: 'data' }
}
mockAccountClient.getIntegration.mockResolvedValue(null)
mockAccountClient.createIntegration.mockResolvedValue(undefined)
const result = await client.integrate(connection, testWorkspaceId)
expect(mockAccountClient.createIntegration).toHaveBeenCalledWith(
expect.objectContaining({
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: expect.objectContaining({
connectionId: 'conn-123',
_id: expect.any(String),
other: 'data'
})
})
)
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:created',
expect.objectContaining({
integration: expect.any(Object),
timestamp: expect.any(Number)
})
)
expect(result).toEqual(
expect.objectContaining({
socialId: testSocialId,
workspaceUuid: testWorkspaceId
})
)
})
it('should return existing integration when it already exists', async () => {
const connection: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: { _id: 'conn-123' }
}
const existingIntegration: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: { existing: true }
}
mockAccountClient.getIntegration.mockResolvedValue(existingIntegration)
const result = await client.integrate(connection, testWorkspaceId)
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:updated',
expect.objectContaining({
integration: expect.any(Object),
timestamp: expect.any(Number)
})
)
expect(result).toBe(existingIntegration)
})
it('should emit error event when integration fails', async () => {
const connection: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: {}
}
const error = new Error('Integration failed')
mockAccountClient.getIntegration.mockRejectedValue(error)
await expect(client.integrate(connection, testWorkspaceId)).rejects.toThrow('Integration failed')
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:error',
expect.objectContaining({
operation: 'integrate',
error: 'Integration failed',
workspaceUuid: testWorkspaceId,
timestamp: expect.any(Number)
})
)
})
})
describe('connect', () => {
it('should create new connection when none exists', async () => {
mockAccountClient.getIntegration.mockResolvedValue(null)
mockAccountClient.createIntegration.mockResolvedValue(undefined)
const data = { test: 'data' }
const result = await client.connect(testSocialId, data)
expect(mockAccountClient.createIntegration).toHaveBeenCalledWith({
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data
})
expect(mockEvents.emit).toHaveBeenCalledWith(
'connection:created',
expect.objectContaining({
integration: expect.objectContaining({
socialId: testSocialId,
workspaceUuid: null
}),
timestamp: expect.any(Number)
})
)
expect(result).toEqual(
expect.objectContaining({
socialId: testSocialId,
workspaceUuid: null,
data
})
)
})
it('should return existing connection when it already exists', async () => {
const existingConnection: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: { existing: true }
}
mockAccountClient.getIntegration.mockResolvedValue(existingConnection)
const result = await client.connect(testSocialId)
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
expect(mockAccountClient.updateIntegration).not.toHaveBeenCalled()
expect(result).toBe(existingConnection)
})
it('should update existing connection when data is different', async () => {
const existingConnection: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: { existing: true, version: 1 }
}
const newData = { existing: true, version: 2, newField: 'value' }
const updatedConnection = {
...existingConnection,
data: newData
}
mockAccountClient.getIntegration.mockResolvedValue(existingConnection)
mockAccountClient.updateIntegration.mockResolvedValue(undefined)
const result = await client.connect(testSocialId, newData)
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
expect(mockAccountClient.updateIntegration).toHaveBeenCalledWith(updatedConnection)
expect(mockEvents.emit).toHaveBeenCalledWith(
'connection:updated',
expect.objectContaining({
integration: expect.objectContaining({
data: newData
}),
timestamp: expect.any(Number)
})
)
expect(result).toEqual(updatedConnection)
})
it('should not update existing connection when data is the same', async () => {
const existingData = { existing: true, version: 1 }
const existingConnection: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: existingData
}
mockAccountClient.getIntegration.mockResolvedValue(existingConnection)
const result = await client.connect(testSocialId, existingData)
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
expect(mockAccountClient.updateIntegration).not.toHaveBeenCalled()
expect(mockEvents.emit).not.toHaveBeenCalledWith('connection:updated', expect.any(Object))
expect(result).toBe(existingConnection)
})
it('should emit error event when connect fails', async () => {
const error = new Error('Connect failed')
mockAccountClient.getIntegration.mockRejectedValue(error)
await expect(client.connect(testSocialId)).rejects.toThrow('Connect failed')
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:error',
expect.objectContaining({
operation: 'connect',
error: 'Connect failed',
socialId: testSocialId,
timestamp: expect.any(Number)
})
)
})
})
describe('updateConfig', () => {
it('should update integration config', async () => {
const integrationKey: IntegrationKey = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId
}
const existingIntegration: Integration = {
...integrationKey,
data: { config: { old: true }, other: 'data' }
}
const newConfig = { new: true }
const refreshFn = jest.fn().mockResolvedValue(undefined)
mockAccountClient.getIntegration.mockResolvedValue(existingIntegration)
mockAccountClient.updateIntegration.mockResolvedValue(undefined)
await client.updateConfig(integrationKey, newConfig, refreshFn)
expect(mockAccountClient.updateIntegration).toHaveBeenCalledWith({
...existingIntegration,
data: {
config: newConfig,
other: 'data'
}
})
expect(refreshFn).toHaveBeenCalled()
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:updated',
expect.objectContaining({
integration: existingIntegration,
oldConfig: { old: true },
newConfig,
timestamp: expect.any(Number)
})
)
})
it('should throw error when integration not found', async () => {
const integrationKey: IntegrationKey = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId
}
mockAccountClient.getIntegration.mockResolvedValue(null)
await expect(client.updateConfig(integrationKey, {})).rejects.toThrow('Integration not found')
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:error',
expect.objectContaining({
operation: 'updateConfig',
integrationKey,
timestamp: expect.any(Number)
})
)
})
})
describe('removeIntegration', () => {
it('should return undefined when socialId is null', async () => {
const result = await client.removeIntegration(null, testWorkspaceId)
expect(result).toBeUndefined()
})
it('should remove integration and keep connection', async () => {
const integrations: Integration[] = [
{
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: {}
}
]
const remainingIntegrations: Integration[] = [
{
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null, // connection
data: {}
},
{
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: 'other-workspace' as WorkspaceUuid,
data: {}
}
]
mockAccountClient.listIntegrations
.mockResolvedValueOnce(integrations)
.mockResolvedValueOnce(remainingIntegrations)
const result = await client.removeIntegration(testSocialId, testWorkspaceId)
expect(mockAccountClient.deleteIntegration).toHaveBeenCalledWith({
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId
})
expect(result).toEqual({ connectionRemoved: false })
})
it('should remove integration and connection when it was the last one', async () => {
const integrations: Integration[] = [
{
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: {}
}
]
const remainingIntegrations: Integration[] = [
{
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null, // connection only
data: {}
}
]
mockAccountClient.listIntegrations
.mockResolvedValueOnce(integrations)
.mockResolvedValueOnce(remainingIntegrations)
.mockResolvedValueOnce([]) // after removing connection
const result = await client.removeIntegration(testSocialId, testWorkspaceId)
expect(result).toEqual({ connectionRemoved: true })
})
})
describe('removeConnection', () => {
it('should remove all integrations for the connection', async () => {
const connection: Integration = {
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: null,
data: {}
}
const integrations: Integration[] = [
connection,
{
socialId: testSocialId,
kind: integrationKind,
workspaceUuid: testWorkspaceId,
data: {}
}
]
mockAccountClient.listIntegrations.mockResolvedValue(integrations)
await client.removeConnection(connection)
expect(mockAccountClient.deleteIntegration).toHaveBeenCalledTimes(2)
expect(mockEvents.emit).toHaveBeenCalledWith(
'integration:deleted',
expect.objectContaining({
integration: expect.any(Object),
timestamp: expect.any(Number)
})
)
})
})
})