diff --git a/app/managers/apps_manager.test.ts b/app/managers/apps_manager.test.ts new file mode 100644 index 000000000..8efe67bd1 --- /dev/null +++ b/app/managers/apps_manager.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {of as of$} from 'rxjs'; + +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {getChannelById} from '@queries/servers/channel'; +import {getConfig} from '@queries/servers/system'; +import {logDebug} from '@utils/log'; + +import {AppsManager as AppsManagerClass} from './apps_manager'; + +import type ChannelModel from '@typings/database/models/servers/channel'; + +jest.mock('@database/manager', () => ({ + default: { + getServerDatabaseAndOperator: jest.fn(), + }, +})); +jest.mock('@managers/network_manager', () => ({ + default: { + getClient: jest.fn(), + }, +})); +jest.mock('@queries/servers/channel', () => ({ + getChannelById: jest.fn(() => ({id: 'channel_id', teamId: 'team_id'})), +})); +jest.mock('@queries/servers/system', () => ({ + getConfig: jest.fn(), + getCurrentUserId: jest.fn(() => 'user_id'), + getCurrentTeamId: jest.fn(() => 'team_id'), + getCurrentChannelId: jest.fn(() => 'channel_id'), +})); + +jest.mock('@utils/apps', () => ({ + validateBindings: jest.fn(() => []), +})); +jest.mock('@utils/log'); + +describe('AppsManager', () => { + const mockServerUrl = 'https://example.com'; + const mockChannelId = 'channel_id'; + const mockBindings: AppBinding[] = [ + { + location: 'test_location', + bindings: [{ + app_id: 'test_app_id', + location: 'sub_location', + label: 'Test Binding', + }], + app_id: 'test_app_id', + label: 'test_label', + }, + ]; + + let AppsManager: AppsManagerClass; + + beforeEach(() => { + jest.clearAllMocks(); + + const mockDatabase = { + database: {}, + operator: {}, + }; + DatabaseManager.getServerDatabaseAndOperator = jest.fn(); + (DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockReturnValue(mockDatabase); + + const mockClient = { + getAppsBindings: jest.fn().mockResolvedValue(mockBindings), + }; + NetworkManager.getClient = jest.fn(); + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + AppsManager = new AppsManagerClass(); + }); + + it('initializes with empty bindings', () => { + const bindings = AppsManager.getBindings(mockServerUrl); + expect(bindings).toEqual([]); + }); + + describe('apps enabled state', () => { + beforeEach(() => { + const mockConfig = { + FeatureFlagAppsEnabled: 'true', + } as ClientConfig; + jest.mocked(getConfig).mockResolvedValue(mockConfig); + }); + + it('should return true when apps are enabled', async () => { + const enabled = await AppsManager.isAppsEnabled(mockServerUrl); + expect(enabled).toBe(true); + }); + + it('should return false when database error occurs', async () => { + (DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockImplementation(() => { + throw new Error('Database error'); + }); + + const enabled = await AppsManager.isAppsEnabled(mockServerUrl); + expect(enabled).toBe(false); + }); + + it('should observe apps enabled state', async () => { + const mockObservable = of$(false); + jest.spyOn(AppsManager, 'observeIsAppsEnabled').mockReturnValue(mockObservable); + + const result = await new Promise((resolve) => { + AppsManager.observeIsAppsEnabled(mockServerUrl).subscribe((enabled) => { + resolve(enabled); + }); + }); + + expect(result).toBe(false); + }); + + it('should handle database error in observeIsAppsEnabled', async () => { + (DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockImplementation(() => { + throw new Error('Database error'); + }); + + const result = await new Promise((resolve) => { + AppsManager.observeIsAppsEnabled(mockServerUrl).subscribe((enabled) => { + resolve(enabled); + }); + }); + + expect(result).toBe(false); + }); + }); + + describe('bindings management', () => { + it('should fetch bindings successfully', async () => { + await AppsManager.fetchBindings(mockServerUrl, mockChannelId); + expect(NetworkManager.getClient).toHaveBeenCalledWith(mockServerUrl); + expect(NetworkManager.getClient(mockServerUrl).getAppsBindings).toHaveBeenCalled(); + }); + + it('should handle fetch bindings error', async () => { + const error = new Error('Network error'); + jest.mocked(NetworkManager.getClient(mockServerUrl).getAppsBindings).mockRejectedValue(error); + + await AppsManager.fetchBindings(mockServerUrl, mockChannelId); + expect(logDebug).toHaveBeenCalledWith('error on fetchBindings', expect.any(String)); + }); + + it('should fetch bindings successfully for DM channel and thread', async () => { + jest.mocked(getChannelById).mockResolvedValue({id: 'channel_id', teamId: ''} as ChannelModel); + + await AppsManager.fetchBindings(mockServerUrl, mockChannelId, true); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(mockServerUrl); + expect(NetworkManager.getClient(mockServerUrl).getAppsBindings).toHaveBeenCalled(); + }); + + it('should clear bindings', async () => { + await AppsManager.clearBindings(mockServerUrl); + const bindings = AppsManager.getBindings(mockServerUrl); + expect(bindings).toEqual([]); + }); + + it('should copy main bindings to thread', async () => { + const channelId = 'test_channel'; + await AppsManager.copyMainBindingsToThread(mockServerUrl, channelId); + const threadBindings = AppsManager.getBindings(mockServerUrl, undefined, true); + expect(threadBindings).toEqual([]); + }); + + it('should observe bindings changes', async () => { + const mockObservable = of$([]); + jest.spyOn(AppsManager, 'observeBindings').mockReturnValue(mockObservable); + + const result = await new Promise((resolve) => { + AppsManager.observeBindings(mockServerUrl).subscribe((bindings) => { + resolve(bindings); + }); + }); + + expect(result).toEqual([]); + }); + + it('should observe bindings with location filter', async () => { + const location = 'test_location'; + const result = await new Promise((resolve) => { + AppsManager.observeBindings(mockServerUrl, location).subscribe((bindings) => { + resolve(bindings); + }); + }); + + expect(result).toEqual([]); + }); + + it('should refresh app bindings successfully', async () => { + const mockConfig = {FeatureFlagAppsEnabled: 'true'} as ClientConfig; + jest.mocked(getConfig).mockResolvedValue(mockConfig); + + const mockClient = { + getAppsBindings: jest.fn().mockResolvedValue(mockBindings), + }; + (NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient); + + await AppsManager.refreshAppBindings(mockServerUrl); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(mockServerUrl); + expect(mockClient.getAppsBindings).toHaveBeenCalledWith('user_id', mockChannelId, 'team_id', undefined); + }); + + it('should handle error in refreshAppBindings', async () => { + const error = new Error('Refresh error'); + (DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockImplementation(() => { + throw error; + }); + + await AppsManager.refreshAppBindings(mockServerUrl); + + expect(logDebug).toHaveBeenCalledWith('Error refreshing apps', error); + }); + + it('should clear bindings when apps are disabled', async () => { + const mockConfig = {FeatureFlagAppsEnabled: 'false'} as ClientConfig; + jest.mocked(getConfig).mockResolvedValue(mockConfig); + + await AppsManager.refreshAppBindings(mockServerUrl); + + const bindings = AppsManager.getBindings(mockServerUrl); + expect(bindings).toEqual([]); + }); + }); + + describe('command forms', () => { + beforeEach(() => { + // Clear any existing forms before each test + AppsManager.removeServer(mockServerUrl); + }); + const mockForm: AppForm = { + title: 'Test Form', + fields: [], + }; + + it('should set and get command form', () => { + const key = 'test_key'; + + AppsManager.setCommandForm(mockServerUrl, key, mockForm); + const form = AppsManager.getCommandForm(mockServerUrl, key); + const nonExistentForm = AppsManager.getCommandForm(mockServerUrl, 'non_existent'); + + expect(form).toEqual(mockForm); + expect(nonExistentForm).toBeUndefined(); + }); + + it('should handle thread command forms separately', () => { + // Clear any existing forms + AppsManager.removeServer(mockServerUrl); + + // Set form for thread context only + AppsManager.setCommandForm(mockServerUrl, 'test_key', mockForm, true); + + // Get forms for both contexts + const threadForm = AppsManager.getCommandForm(mockServerUrl, 'test_key', true); + const regularForm = AppsManager.getCommandForm(mockServerUrl, 'test_key', false); + + // Verify thread form exists and regular form doesn't + expect(threadForm).toEqual(mockForm); + expect(regularForm).toBeUndefined(); + }); + }); + + describe('server management', () => { + it('should remove server data', () => { + AppsManager.setCommandForm(mockServerUrl, 'test_key', {title: 'Test'}); + AppsManager.removeServer(mockServerUrl); + + const form = AppsManager.getCommandForm(mockServerUrl, 'test_key'); + expect(form).toBeUndefined(); + }); + + it('should clear server data', async () => { + AppsManager.setCommandForm(mockServerUrl, 'test_key', {title: 'Test'}); + await AppsManager.clearServer(mockServerUrl); + + const form = AppsManager.getCommandForm(mockServerUrl, 'test_key'); + const bindings = AppsManager.getBindings(mockServerUrl); + + expect(form).toBeUndefined(); + expect(bindings).toEqual([]); + }); + }); +}); diff --git a/app/managers/apps_manager.ts b/app/managers/apps_manager.ts index 83b842277..30c122b53 100644 --- a/app/managers/apps_manager.ts +++ b/app/managers/apps_manager.ts @@ -17,7 +17,7 @@ const emptyBindings: AppBinding[] = []; const normalizeBindings = (bindings: AppBinding[]) => bindings.reduce((acc, v) => (v.bindings ? acc.concat(v.bindings) : acc), []); -class AppsManager { +export class AppsManager { private enabled: {[serverUrl: string]: BehaviorSubject} = {}; private bindings: {[serverUrl: string]: BehaviorSubject} = {}; @@ -174,9 +174,7 @@ class AppsManager { observeBindings = (serverUrl: string, location?: string, forThread = false) => { const isEnabled = this.observeIsAppsEnabled(serverUrl); - const bindings = forThread ? - this.getThreadsBindingsSubject(serverUrl).asObservable().pipe(switchMap(({bindings: bb}) => of$(bb))) : - this.getBindingsSubject(serverUrl).asObservable(); + const bindings = forThread ? this.getThreadsBindingsSubject(serverUrl).asObservable().pipe(switchMap(({bindings: bb}) => of$(bb))) : this.getBindingsSubject(serverUrl).asObservable(); return combineLatest([isEnabled, bindings]).pipe( switchMap(([e, bb]) => of$(e ? bb : emptyBindings)), @@ -189,9 +187,7 @@ class AppsManager { }; getBindings = (serverUrl: string, location?: string, forThread = false) => { - let bindings = forThread ? - this.getThreadsBindingsSubject(serverUrl).value.bindings : - this.getBindingsSubject(serverUrl).value; + let bindings = forThread ? this.getThreadsBindingsSubject(serverUrl).value.bindings : this.getBindingsSubject(serverUrl).value; if (location) { bindings = bindings.filter((b) => b.location === location); @@ -201,15 +197,11 @@ class AppsManager { }; getCommandForm = (serverUrl: string, key: string, forThread = false) => { - return forThread ? - this.threadCommandForms[serverUrl]?.[key] : - this.commandForms[serverUrl]?.[key]; + return forThread ? this.threadCommandForms[serverUrl]?.[key] : this.commandForms[serverUrl]?.[key]; }; setCommandForm = (serverUrl: string, key: string, form: AppForm, forThread = false) => { - const toStore = forThread ? - this.threadCommandForms : - this.commandForms; + const toStore = forThread ? this.threadCommandForms : this.commandForms; if (!toStore[serverUrl]) { toStore[serverUrl] = {}; }