Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/old-meals-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/apps-engine': patch
'@rocket.chat/meteor': patch
---

Changes a strict behavior on reporting slash commands provided by apps
185 changes: 185 additions & 0 deletions apps/meteor/client/hooks/useAppSlashCommands.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import type { SlashCommand } from '@rocket.chat/core-typings';
import { mockAppRoot, type StreamControllerRef } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';

import { useAppSlashCommands } from './useAppSlashCommands';
import { slashCommands } from '../../app/utils/client/slashCommand';

const mockSlashCommands: SlashCommand[] = [
{
command: '/test',
description: 'Test command',
params: 'param1 param2',
clientOnly: false,
providesPreview: false,
appId: 'test-app-1',
permission: undefined,
},
{
command: '/weather',
description: 'Get weather information',
params: 'city',
clientOnly: false,
providesPreview: true,
appId: 'weather-app',
permission: undefined,
},
];

const mockApiResponse = {
commands: mockSlashCommands,
total: mockSlashCommands.length,
};

describe('useAppSlashCommands', () => {
let mockGetSlashCommands: jest.Mock;

beforeEach(() => {
mockGetSlashCommands = jest.fn().mockResolvedValue(mockApiResponse);

slashCommands.commands = {};
});

afterEach(() => {
jest.clearAllMocks();
});

it('should not fetch data when user ID is not available', () => {
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).build(),
});

expect(mockGetSlashCommands).not.toHaveBeenCalled();
expect(slashCommands.commands).toEqual({});
});

it('should fetch slash commands when user ID is available', async () => {
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).withJohnDoe().build(),
});

await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
});

it('should handle command/removed event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};

renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});

expect(streamRef.controller).toBeDefined();

await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});

streamRef.controller?.emit('apps', [['command/removed', ['/test']]]);

expect(slashCommands.commands['/test']).toBeUndefined();
expect(slashCommands.commands['/weather']).toBeDefined();
});

it('should handle command/added event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};

renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});

expect(streamRef.controller).toBeDefined();

await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});

mockGetSlashCommands.mockResolvedValue({
commands: [
...mockSlashCommands,
{
command: '/newcommand',
description: 'New command',
params: 'param1 param2',
clientOnly: false,
},
],
total: mockSlashCommands.length + 1,
});

streamRef.controller?.emit('apps', [['command/added', ['/newcommand']]]);

await waitFor(() => {
expect(slashCommands.commands['/newcommand']).toBeDefined();
});

expect(slashCommands.commands['/test']).toBeDefined();
expect(slashCommands.commands['/weather']).toBeDefined();
});

it('should handle command/updated event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};

renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});

expect(streamRef.controller).toBeDefined();

await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});

streamRef.controller?.emit('apps', [['command/updated', ['/test']]]);

expect(slashCommands.commands['/test']).toBeUndefined();
expect(slashCommands.commands['/weather']).toBeDefined();
});

it('should ignore command/disabled event', async () => {
const streamRef: StreamControllerRef<'apps'> = {};

renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});

expect(streamRef.controller).toBeDefined();

await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});

streamRef.controller?.emit('apps', [['command/disabled', ['/test']]]);

expect(slashCommands.commands['/test']).toBeDefined();
expect(slashCommands.commands['/weather']).toBeDefined();
});

it('should not set up stream listener when user ID is not available', () => {
const streamRef: StreamControllerRef<'apps'> = {};

renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withStream('apps', streamRef).build(),
});

expect(streamRef.controller).toBeDefined();
expect(streamRef.controller?.has('apps')).toBe(false);
});
});
2 changes: 1 addition & 1 deletion apps/meteor/client/hooks/useAppSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const useAppSlashCommands = () => {
return;
}
return apps('apps', ([key, [command]]) => {
if (['command/added', 'command/updated', 'command/disabled', 'command/removed'].includes(key)) {
if (['command/added', 'command/updated', 'command/removed'].includes(key)) {
if (typeof command === 'string') {
delete slashCommands.commands[command];
}
Expand Down
15 changes: 11 additions & 4 deletions packages/apps-engine/src/server/AppManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface IAppManagerDeps {

interface IPurgeAppConfigOpts {
keepScheduledJobs?: boolean;
keepSlashcommands?: boolean;
}

export class AppManager {
Expand Down Expand Up @@ -491,7 +492,7 @@ export class AppManager {
await app.call(AppMethod.ONDISABLE).catch((e) => console.warn('Error while disabling:', e));
}

await this.purgeAppConfig(app, { keepScheduledJobs: true });
await this.purgeAppConfig(app, { keepScheduledJobs: true, keepSlashcommands: true });

await app.setStatus(status, silent);

Expand Down Expand Up @@ -825,6 +826,7 @@ export class AppManager {
}
})();

// We don't keep slashcommands here as the update could potentially not provide the same list
await this.purgeAppConfig(app, { keepScheduledJobs: true });

this.apps.set(app.getID(), app);
Expand Down Expand Up @@ -1049,6 +1051,8 @@ export class AppManager {
await app.call(AppMethod.INITIALIZE);
await app.setStatus(AppStatus.INITIALIZED, silenceStatus);

await this.commandManager.registerCommands(app.getID());

result = true;
} catch (e) {
let status = AppStatus.ERROR_DISABLED;
Expand Down Expand Up @@ -1085,9 +1089,13 @@ export class AppManager {
if (!opts.keepScheduledJobs) {
await this.schedulerManager.cleanUp(app.getID());
}

if (!opts.keepSlashcommands) {
await this.commandManager.unregisterCommands(app.getID());
}

this.listenerManager.unregisterListeners(app);
this.listenerManager.lockEssentialEvents(app);
await this.commandManager.unregisterCommands(app.getID());
this.externalComponentManager.unregisterExternalComponents(app.getID());
await this.apiManager.unregisterApis(app.getID());
this.accessorManager.purifyApp(app.getID());
Expand Down Expand Up @@ -1161,15 +1169,14 @@ export class AppManager {
}

if (enable) {
await this.commandManager.registerCommands(app.getID());
this.externalComponentManager.registerExternalComponents(app.getID());
await this.apiManager.registerApis(app.getID());
this.listenerManager.registerListeners(app);
this.listenerManager.releaseEssentialEvents(app);
this.videoConfProviderManager.registerProviders(app.getID());
await this.outboundCommunicationProviderManager.registerProviders(app.getID());
} else {
await this.purgeAppConfig(app);
await this.purgeAppConfig(app, { keepScheduledJobs: true, keepSlashcommands: true });
}

if (saveToDb) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,12 @@ export class AppSlashCommandManager {

const app = this.manager.getOneById(this.touchedCommandsToApps.get(cmd));

if (!app || AppStatusUtils.isDisabled(await app.getStatus())) {
// Just in case someone decides to do something they shouldn't
// let's ensure the app actually exists
return;
if (!app) {
throw new Error('App not found');
}

if (!AppStatusUtils.isEnabled(await app.getStatus())) {
throw new Error('App not enabled');
}

const appCmd = this.retrieveCommandInfo(cmd, app.getID());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ export class AppSlashCommandManagerTestFixture {
failedItems.set('failure', asm);
(ascm as any).providedCommands.set('failMePlease', failedItems);
(ascm as any).touchedCommandsToApps.set('failure', 'failMePlease');
await Expect(() => ascm.executeCommand('failure', context)).not.toThrowAsync();
await Expect(() => ascm.executeCommand('failure', context)).toThrowAsync();

AppSlashCommandManagerTestFixture.doThrow = true;
await Expect(() => ascm.executeCommand('command', context)).not.toThrowAsync();
Expand Down
Loading