snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { Path } from '@rocket.chat/rest-typings';
import type { CallbackHandler, Response } from 'supertest';
import supertest from 'supertest';
import { adminUsername, adminPassword } from './user';
export const apiUrl = process.env.TEST_API_URL || 'http://localhost:3000';
export const request = supertest(apiUrl);
const prefix = '/api/v1/';
export function wait(cb: () => void, time: number) {
return () => setTimeout(cb, time);
}
const privateChannelName = `private-channel-test-${Date.now()}` as const;
const username = 'user.test';
const email = `${username}@rocket.chat`;
export const apiUsername = `api${username}-${Date.now()}` as const;
export const apiEmail = `api${email}-${Date.now()}` as const;
export const apiPrivateChannelName = `api${privateChannelName}-${Date.now()}` as const;
const roleNameUsers = `role-name-test-users-${Date.now()}` as const;
const roleNameSubscriptions = `role-name-test-subscriptions-${Date.now()}` as const;
const roleScopeUsers = 'Users' as const;
const roleScopeSubscriptions = 'Subscriptions' as const;
const roleDescription = `role-description-test-${Date.now()}` as const;
export const apiRoleNameUsers = `api${roleNameUsers}` as const;
export const apiRoleNameSubscriptions = `api${roleNameSubscriptions}` as const;
export const apiRoleScopeUsers = `${roleScopeUsers}` as const;
export const apiRoleScopeSubscriptions = `${roleScopeSubscriptions}` as const;
export const apiRoleDescription = `api${roleDescription}` as const;
export const reservedWords = ['admin', 'administrator', 'system', 'user'] as const;
export const credentials: Credentials = {
'X-Auth-Token': undefined,
'X-User-Id': undefined,
} as unknown as Credentials; // FIXME
export type PathWithoutPrefix<TPath> = TPath extends `/v1/${infer U}` ? U : never;
export function api<TPath extends PathWithoutPrefix<Path>>(path: TPath) {
return `${prefix}${path}` as const;
}
export function methodCall<TMethodName extends string>(methodName: TMethodName) {
return api(`method.call/${methodName}`);
}
export function methodCallAnon<TMethodName extends string>(methodName: TMethodName) {
return api(`method.callAnon/${methodName}`);
}
export function log(res: Response) {
console.log((res as { req?: any }).req.path); // FIXME
console.log({
body: res.body,
headers: res.headers,
});
}
let instanceId: string | undefined;
export function getCredentials(done?: CallbackHandler) {
void request
.post(api('login'))
.send({
user: adminUsername,
password: adminPassword,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
credentials['X-Auth-Token'] = res.body.data.authToken;
credentials['X-User-Id'] = res.body.data.userId;
instanceId = res.headers['x-instance-id'];
})
.end(done);
}
export function getInstanceId() {
return instanceId;
}

View File

@@ -0,0 +1,669 @@
# Test App Packages
Includes pre-built app packages that are designed to test specific APIs exposed by the Apps-engine.
DISCLAIMER: App related source code shown here are for testing purposes only, and don't reflect best practices or recommended implementation.
## How to use
In your tests, add a `before` step and call the `installLocalTestPackage` function, passing the path of your desired package. For instance:
```javascript
import { appImplementsIPreFileUpload } from '../../data/apps/app-packages';
import { installLocalTestPackage } from '../../data/apps/helper';
describe('My tests', () => {
before(async () => {
await installLocalTestPackage(appImplementsIPreFileUpload);
});
});
```
### Available apps
#### IPreFileUpload handler
File name: `file-upload-test_0.0.1.zip`
An app that handles the `IPreFileUpload` event. If the file name starts with `"test-should-reject"`, the app will prevent the upload from happening. The error message will contain the contents of the uploaded file as evidence that the app could successfully read them.
<details>
<summary>App source code</summary>
```typescript
import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { FileUploadNotAllowedException } from '@rocket.chat/apps-engine/definition/exceptions';
import { IPreFileUpload } from '@rocket.chat/apps-engine/definition/uploads';
import { IFileUploadContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext';
export class TestIPreFileUpload extends App implements IPreFileUpload {
public async executePreFileUpload(
context: IFileUploadContext,
read: IRead,
http: IHttp,
persis: IPersistence,
modify: IModify,
): Promise<void> {
if (context.file.name.startsWith('test-should-reject')) {
console.log('[executePreFileUpload] Rejecting file which name starts with "test-should-reject"');
throw new FileUploadNotAllowedException(`Test file rejected ${context.content.toString()}`);
}
console.log('[executePreFileUpload] Did not reject file');
}
}
```
</details>
#### API Parameter Test
File name: `api-parameter-test_0.0.1.zip`
An app that provides a public API endpoint with URL parameters. The endpoint path is `/api/:param1/:param2/test` and returns the values of both parameters in the response.
**Response format:**
- Content-Type: `text/plain`
- Body: `Param1: <param1_value>, Param2: <param2_value>`
- Status: 200 OK
<details>
<summary>App source code</summary>
**APIParameterTestApp.ts**
```typescript
import { IAppAccessors, IConfigurationExtend, IEnvironmentRead, ILogger } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { TestEndpoint } from './TestEndpoint';
export class APIParameterTestApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [new TestEndpoint(this)],
});
}
}
```
**TestEndpoint.ts**
```typescript
import { HttpStatusCode, IModify, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
export class TestEndpoint extends ApiEndpoint {
public path = 'api/:param1/:param2/test';
public async get(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify): Promise<IApiResponse> {
return {
content: `Param1: ${request.params.param1}, Param2: ${request.params.param2}`,
status: HttpStatusCode.OK,
headers: {
'Content-Type': 'text/plain',
},
};
}
}
```
</details>
#### External ID Test (resolveVisitor API)
File name: `external-id-test_0.0.1.zip`
An app that tests the `ILivechatCreator.resolveVisitor()` and `ILivechatUpdater.updateVisitorExternalId()` APIs for resolving and updating livechat visitors by external identifiers. This is used to test the WhatsApp BSUID (Business Scoped User ID) support and progressive visitor enrichment.
**Endpoints:**
1. `POST /api/apps/public/:appId/resolve-visitor` - Resolve visitor by externalId with phone/email fallback
2. `POST /api/apps/public/:appId/update-external-id` - Update visitor's externalId for this app
**Request body (resolve-visitor):**
```json
{
"externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } },
"phone": "+1234567890"
}
```
**Request body (update-external-id):**
```json
{
"visitorId": "visitor-123",
"externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } }
}
```
**Response:**
- Returns the visitor if found/updated
- Returns `{ visitor: null }` if not found
<details>
<summary>App source code</summary>
**ExternalIdTestApp.ts**
```typescript
import {
IAppAccessors,
IConfigurationExtend,
ILogger,
} from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { ResolveVisitorEndpoint } from './ResolveVisitorEndpoint';
import { UpdateExternalIdEndpoint } from './UpdateExternalIdEndpoint';
export class ExternalIdTestApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
public override async extendConfiguration(configuration: IConfigurationExtend): Promise<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [new ResolveVisitorEndpoint(this), new UpdateExternalIdEndpoint(this)],
});
}
}
```
**ResolveVisitorEndpoint.ts**
```typescript
import {
HttpStatusCode,
IHttp,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
export class ResolveVisitorEndpoint extends ApiEndpoint {
public override path = 'resolve-visitor';
public async post(
request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
modify: IModify,
_http: IHttp,
_persistence: IPersistence,
): Promise<IApiResponse> {
const { externalId, phone, email } = request.content;
let contactData: { phone: string } | { email: string } | undefined;
if (phone) {
contactData = { phone };
} else if (email) {
contactData = { email };
}
const visitor = await modify.getCreator().getLivechatCreator().resolveVisitor(externalId, contactData);
return {
status: HttpStatusCode.OK,
content: { visitor: visitor || null },
};
}
}
```
**UpdateExternalIdEndpoint.ts**
```typescript
import {
HttpStatusCode,
IHttp,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
export class UpdateExternalIdEndpoint extends ApiEndpoint {
public override path = 'update-external-id';
public async post(
request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
modify: IModify,
_http: IHttp,
_persistence: IPersistence,
): Promise<IApiResponse> {
const { visitorId, externalId } = request.content;
if (!visitorId || !externalId) {
return {
status: HttpStatusCode.BAD_REQUEST,
content: { error: 'visitorId and externalId are required' },
};
}
const visitor = await modify.getUpdater().getLivechatUpdater().updateVisitorExternalId(visitorId, externalId);
return {
status: HttpStatusCode.OK,
content: { visitor: visitor || null },
};
}
}
```
</details>
#### Nested Requests simulation
File name: `nested-requests_0.0.1.zip`
An app that simulates a "nested request" scenario. It listens for `IPostMessageSent` events, and provides a slashcommand that sends a message. Executing the slashcommand will create a scenario where the event handler for `IPostMessageSent` will be triggered by the slashcommand executor handler.
This situation used to cause logs for the originating handler (the slashcommand executor, in this case) to disappear, and only the logs for the nested request (`IPostMessageSent` handler, in this case) would be persisted to the database.
<details>
<summary>App source code</summary>
```typescript
export class NestedRequestsApp extends App implements IPostMessageSent {
public async executePostMessageSent(
message: IMessage,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<void> {
this.getLogger().debug('executed_post_message_sent', message.id);
}
protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise<void> {
configuration.slashCommands.provideSlashCommand(
new (class implements ISlashCommand {
public command = 'nest';
public i18nParamsExample = 'nest';
public i18nDescription = 'nest';
public providesPreview = false;
constructor(private readonly app: IApp) {}
public async executor(
context: SlashCommandContext,
_read: IRead,
modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<void> {
const [execId] = context.getArguments();
this.app.getLogger().debug('slashcommand_triggered', execId);
const mb = modify.getCreator().startMessage().setText(`nested_test_message ${execId}`).setRoom(context.getRoom());
const id = await modify.getCreator().finish(mb);
this.app.getLogger().debug('slashcommand_message_sent', id);
}
})(this),
);
}
}
```
</details>
#### Update Status Test
File name: `update-status-test_0.0.1.zip`
An app that provides two public API endpoints to test the `updateStatus` and `updateStatusText` bridge methods. A `username` parameter is required to specify the target user.
**Endpoints:**
- `POST /update-status` — Calls `updateStatus(user, statusText, status)`. Expects `{ username: string, status: string, statusText?: string }`.
- `POST /update-status-text` — Calls `updateStatusText(user, statusText)`. Expects `{ username: string, statusText: string }`.
<details>
<summary>App source code</summary>
**UpdateStatusTestApp.ts**
```typescript
import {
IAppAccessors,
IConfigurationExtend,
ILogger,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { UpdateStatusEndpoint } from './endpoints/UpdateStatusEndpoint';
import { UpdateStatusTextEndpoint } from './endpoints/UpdateStatusTextEndpoint';
export class UpdateStatusTestApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
protected async extendConfiguration(configuration: IConfigurationExtend): Promise<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [
new UpdateStatusEndpoint(this),
new UpdateStatusTextEndpoint(this),
],
});
}
}
```
**endpoints/UpdateStatusEndpoint.ts**
```typescript
import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
import { IUser } from '@rocket.chat/apps-engine/definition/users';
export class UpdateStatusEndpoint extends ApiEndpoint {
public path = 'update-status';
public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise<IApiResponse> {
const { status, statusText = '', username } = request.content || {};
if (!status) {
return { status: 400, content: 'status is required' };
}
if (!username) {
return { status: 400, content: 'username is required' };
}
const user = await read.getUserReader().getByUsername(username) as IUser;
if (!user) {
return { status: 404, content: 'User not found' };
}
await modify.getUpdater().getUserUpdater().updateStatus(user, statusText, status);
return this.success(JSON.stringify({ status, statusText }));
}
}
```
**endpoints/UpdateStatusTextEndpoint.ts**
```typescript
import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
import { IUser } from '@rocket.chat/apps-engine/definition/users';
export class UpdateStatusTextEndpoint extends ApiEndpoint {
public path = 'update-status-text';
public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise<IApiResponse> {
const { statusText, username } = request.content || {};
if (typeof statusText !== 'string') {
return { status: 400, content: 'statusText is required' };
}
if (!username) {
return { status: 400, content: 'username is required' };
}
const user = await read.getUserReader().getByUsername(username) as IUser;
if (!user) {
return { status: 404, content: 'User not found' };
}
await modify.getUpdater().getUserUpdater().updateStatusText(user, statusText);
return this.success(JSON.stringify({ statusText }));
}
}
```
</details>
#### Message Reaction Test
File name: `message-updater-test_0.0.1.zip`
An app used to test the message reaction updates. Provides a `/msg-update` slashcommand that takes an action of `'add' | 'remove'` and a message id, then adds or removes a reaction in the target message accordingly using the App's bot user.
<details>
<summary>App source code</summary>
```typescript
export class MessageUpdaterTestApp extends App {
protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise<void> {
await configuration.slashCommands.provideSlashCommand(new class UpdateCommand implements ISlashCommand {
command = 'msg-update';
i18nDescription = 'msg-update';
i18nParamsExample = 'msg-update';
providesPreview = false;
constructor(private readonly app: App) { }
public async executor(context: SlashCommandContext, read: IRead, modify: IModify, _http: IHttp, _persis: IPersistence) {
const [action, msgId] = context.getArguments() as ['add' | 'remove', string];
const user = await read.getUserReader().getAppUser();
if (!user) {
this.app.getLogger().error(`Couldn't find app user`);
return;
}
if (action === 'add') {
await modify.getUpdater().getMessageUpdater().addReaction(msgId, user.id, ':+1:');
this.app.getLogger().debug(`Added reaction 👍 to message ${msgId}`);
} else {
await modify.getUpdater().getMessageUpdater().removeReaction(msgId, user.id, ':+1:');
this.app.getLogger().debug(`Removed reaction 👍 from message ${msgId}`);
}
}
}(this));
}
}
```
#### UIKit Room Context Test
File name: `uikit-room-test_0.0.1.zip`
An app that validates room context propagation and interaction data in UIKit interactions across all surfaces (message, modal, contextual bar). It provides a slash command (`open-uikit-room-test-modal`) with three actions:
- `modal` - Opens a modal with a button and submit action
- `ctx` - Opens a contextual bar with a button and submit action
- `message` - Sends a message with a button
It also takes a second argument with a random "seed" used to identify the current execution context.
Each interaction handler (`executeBlockActionHandler`, `executeViewSubmitHandler`, `executeViewClosedHandler`) logs detailed data including room ID, user name, view id, block value, triggerId, actionId, and container type. These logs are used by Playwright e2e tests to verify that interaction data is correctly propagated.
<details>
<summary>App source code</summary>
```typescript
import {
IAppAccessors,
IConfigurationExtend,
IEnvironmentRead,
IHttp,
ILogger,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import {
IUIKitInteractionHandler,
IUIKitResponse,
UIKitBlockInteractionContext,
UIKitViewCloseInteractionContext,
UIKitViewSubmitInteractionContext,
} from '@rocket.chat/apps-engine/definition/uikit';
import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata';
import { UIKitSurfaceType } from '@rocket.chat/apps-engine/definition/uikit';
export class UiKitRoomTestApp extends App implements IUIKitInteractionHandler {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
async [AppMethod.UIKIT_BLOCK_ACTION](
context: UIKitBlockInteractionContext,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<IUIKitResponse> {
const data = context.getInteractionData();
this.getLogger().debug('block_action_room', data.room ? data.room.id : 'no-room');
this.getLogger().debug('block_action_user', data.user ? data.user.username : 'no-user');
this.getLogger().debug('block_action_triggerId', data.triggerId || 'no-triggerId');
this.getLogger().debug('block_action_actionId', data.actionId || 'no-actionId');
this.getLogger().debug('block_action_value', data.value);
this.getLogger().debug('block_action_container', data.container ? data.container.type : 'no-container');
return context.getInteractionResponder().successResponse();
}
async [AppMethod.UIKIT_VIEW_SUBMIT](
context: UIKitViewSubmitInteractionContext,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<IUIKitResponse> {
const data = context.getInteractionData();
this.getLogger().debug('view_submit_room', data.room ? data.room.id : 'no-room');
this.getLogger().debug('view_submit_user', data.user ? data.user.username : 'no-user');
this.getLogger().debug('view_submit_id', data.view.id);
this.getLogger().debug('view_submit_triggerId', data.triggerId || 'no-triggerId');
this.getLogger().debug('view_submit_actionId', data.actionId || 'no-actionId');
return context.getInteractionResponder().successResponse();
}
async [AppMethod.UIKIT_VIEW_CLOSE](
context: UIKitViewCloseInteractionContext,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<IUIKitResponse> {
const data = context.getInteractionData();
this.getLogger().debug('view_closed_room', data.room ? data.room.id : 'no-room');
this.getLogger().debug('view_closed_user', data.user ? data.user.username : 'no-user');
this.getLogger().debug('view_closed_id', data.view.id);
this.getLogger().debug('view_closed_triggerId', data.triggerId || 'no-triggerId');
this.getLogger().debug('view_closed_actionId', data.actionId || 'no-actionId');
return context.getInteractionResponder().successResponse();
}
protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise<void> {
await configuration.slashCommands.provideSlashCommand({
command: 'open-uikit-room-test-modal',
i18nDescription: '',
i18nParamsExample: '',
providesPreview: false,
executor: async (context, _read, modify, _http, _persis) => {
const [action, seed] = context.getArguments();
const triggerId = context.getTriggerId();
if (!triggerId) {
this.getLogger().error('No triggerId provided to slash command');
return;
}
if (action === 'modal') {
await modify.getUiController().openSurfaceView(
{
id: `modal-${seed}`,
title: { type: 'plain_text', text: 'UIKit Room Test Modal' },
type: UIKitSurfaceType.MODAL,
blocks: [
{
type: 'section',
text: { type: 'plain_text', text: 'This modal tests room context in UIKit interactions.' },
},
{
type: 'actions',
elements: [{ type: 'button', value: seed, actionId: 'modal-button', text: { type: 'plain_text', text: 'Click!' } }],
},
],
submit: {
type: 'button',
actionId: `modal-submit-${seed}`,
text: { type: 'plain_text', text: 'Submit' },
},
},
{ triggerId },
context.getSender(),
);
}
if (action === 'ctx') {
await modify.getUiController().openSurfaceView(
{
id: `ctx-${seed}`,
title: { type: 'plain_text', text: 'UIKit Room Test Contextual Bar' },
type: UIKitSurfaceType.CONTEXTUAL_BAR,
blocks: [
{
type: 'section',
text: { type: 'plain_text', text: 'This contextual bar tests room context.' },
},
{
type: 'actions',
elements: [{ type: 'button', value: seed, actionId: 'ctx-button', text: { type: 'plain_text', text: 'Click!' } }],
},
],
submit: {
type: 'button',
actionId: `ctxbar-submit-${seed}`,
text: { type: 'plain_text', text: 'Submit' },
},
},
{ triggerId },
context.getSender(),
);
}
if (action === 'message') {
const msg = modify
.getCreator()
.startMessage()
.setRoom(context.getRoom())
.setBlocks([
{
type: 'section',
text: { type: 'plain_text', text: 'This message tests room context.' },
},
{
type: 'actions',
elements: [{ type: 'button', value: seed, actionId: 'msg-button', text: { type: 'plain_text', text: 'Click!' } }],
},
]);
await modify.getCreator().finish(msg);
}
},
});
}
}
```
</details>

View File

@@ -0,0 +1,25 @@
import * as path from 'path';
export const appImplementsIPreFileUpload = path.resolve(__dirname, './file-upload-test_0.0.1.zip');
export const appAPIParameterTest = path.resolve(__dirname, './api-parameter-test_0.0.1.zip');
export const appCausingNestedRequests = path.resolve(__dirname, './nested-requests_0.0.1.zip');
export const appUiKitRoomTest = path.resolve(__dirname, './uikit-room-test_0.0.1.zip');
export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test_0.0.1.zip');
export const appUpdateTest = path.resolve(__dirname, './app-update-test_0.0.1.zip');
// Same app id as appUpdateTest, but its `onEnable()` throws so the update applies yet the app ends up disabled.
export const appUpdateTestFaulty = path.resolve(__dirname, './app-update-test-faulty_0.0.2.zip');
// Same app id as appUpdateTest, but the package is missing its main class file, so the update fails to apply.
export const appUpdateTestBroken = path.resolve(__dirname, './app-update-test-broken_0.0.3.zip');
export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0.1.zip');
export const messageReactionTest = path.resolve(__dirname, './message-updater-test_0.0.1.zip');
export const appPresenceStateTest = path.resolve(__dirname, './presence-state-test_0.0.1.zip');

View File

@@ -0,0 +1,16 @@
import type { Path } from '@rocket.chat/rest-typings';
export const APP_URL = 'https://github.com/RocketChat/Apps.RocketChat.Tester/raw/master/dist/appsrocketchattester_0.3.0.zip?raw=true';
export const APP_NAME = 'Apps.RocketChat.Tester';
type PathWithoutPrefix<TPath> = TPath extends `/apps${infer U}` ? U : never;
export function apps(path?: ''): `/api/apps`;
export function apps<TPath extends PathWithoutPrefix<Path>>(path: TPath): `/api/apps${TPath}`;
export function apps(path = '') {
return `/api/apps${path}` as const;
}
export function installedApps() {
return `/api/apps/installed` as const;
}

View File

@@ -0,0 +1,54 @@
import type { App } from '@rocket.chat/core-typings';
import { request, credentials } from '../api-data';
import { apps, APP_URL, installedApps } from './apps-data';
const getApps = () =>
new Promise<App[]>((resolve) => {
void request
.get(installedApps())
.set(credentials)
.end((_err, res) => {
resolve(res.body.apps);
});
});
const removeAppById = (id: App['id']) =>
new Promise((resolve) => {
void request
.delete(apps(`/${id}`))
.set(credentials)
.end(resolve);
});
export const cleanupApps = async () => {
const apps = await getApps();
await Promise.all(apps.map((testApp) => removeAppById(testApp.id)));
};
export const installTestApp = () =>
new Promise<App>((resolve) => {
void request
.post(apps())
.set(credentials)
.send({
url: APP_URL,
})
.end((_err, res) => {
resolve(res.body.app);
});
});
export const installLocalTestPackage = (path: string) =>
new Promise<App>((resolve, reject) => {
void request
.post(apps())
.set(credentials)
.attach('app', path)
.end((err, res) => {
if (err) {
return reject(err);
}
return resolve(res.body.app);
});
});

View File

@@ -0,0 +1,123 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
import { api, credentials, request } from './api-data';
export const sendSimpleMessage = ({
roomId,
text = 'test message',
tmid,
userCredentials = credentials,
}: {
roomId: IRoom['_id'];
text?: string;
tmid?: IMessage['_id'];
userCredentials?: Credentials;
}) => {
if (!roomId) {
throw new Error('"roomId" is required in "sendSimpleMessage" test helper');
}
const message: {
rid: IRoom['_id'];
text: string;
tmid?: IMessage['_id'];
} = {
rid: roomId,
text,
};
if (tmid) {
message.tmid = tmid;
}
return request.post(api('chat.sendMessage')).set(userCredentials).send({ message });
};
export const sendMessage = ({
message,
requestCredentials,
}: {
message: { rid: IRoom['_id']; msg: string } & Partial<Omit<IMessage, 'rid' | 'msg'>>;
requestCredentials?: Credentials;
}) => {
return request
.post(api('chat.sendMessage'))
.set(requestCredentials ?? credentials)
.send({ message });
};
export const starMessage = ({ messageId, requestCredentials }: { messageId: IMessage['_id']; requestCredentials?: Credentials }) => {
return request
.post(api('chat.starMessage'))
.set(requestCredentials ?? credentials)
.send({ messageId });
};
export const pinMessage = ({
messageId,
requestCredentials,
unpin = false,
}: {
messageId: IMessage['_id'];
requestCredentials?: Credentials;
unpin?: boolean;
}) => {
return request
.post(api(unpin ? 'chat.unPinMessage' : 'chat.pinMessage'))
.set(requestCredentials ?? credentials)
.send({ messageId });
};
export const deleteMessage = ({ roomId, msgId }: { roomId: IRoom['_id']; msgId: IMessage['_id'] }) => {
if (!roomId) {
throw new Error('"roomId" is required in "deleteMessage" test helper');
}
if (!msgId) {
throw new Error('"msgId" is required in "deleteMessage" test helper');
}
return request.post(api('chat.delete')).set(credentials).send({
roomId,
msgId,
});
};
export const getMessageById = ({ msgId }: { msgId: IMessage['_id'] }) => {
if (!msgId) {
throw new Error('"msgId" is required in "getMessageById" test helper');
}
return new Promise<IMessage>((resolve) => {
void request
.get(api(`chat.getMessage`))
.query({ msgId })
.set(credentials)
.end((_err, res) => {
resolve(res.body.message);
});
});
};
export const followMessage = ({ msgId, requestCredentials }: { msgId: IMessage['_id']; requestCredentials?: Credentials }) => {
return request
.post(api('chat.followMessage'))
.set(requestCredentials ?? credentials)
.send({ mid: msgId });
};
export const updateMessage = ({
msgId,
requestCredentials,
updatedMessage,
roomId,
}: {
msgId: IMessage['_id'];
requestCredentials?: Credentials;
updatedMessage: string;
roomId?: IRoom['_id'];
}) => {
return request
.post(api('chat.update'))
.set(requestCredentials ?? credentials)
.send({ msgId, text: updatedMessage, roomId });
};

View File

@@ -0,0 +1 @@
export const CI_MAX_ROOMS_PER_GUEST = 10;

View File

@@ -0,0 +1,170 @@
import * as fs from 'fs';
import type { IMessage } from '@rocket.chat/core-typings';
import { api } from './api-data';
import type { IRequestConfig } from './users.helper';
/**
* Uploads a file to Rocket.Chat using the two-step process (rooms.media then rooms.mediaConfirm).
*
* @param roomId - The room ID where the file will be uploaded
* @param filePath - Path to the file to upload
* @param description - Description for the file
* @param config - Request configuration with credentials and request instance
* @param message - Optional message text to include with the file
* @returns Promise resolving to the message response
*/
export async function uploadFileToRC(
roomId: string,
filePath: string,
description: string,
config: IRequestConfig,
message = '',
): Promise<{ message: IMessage }> {
const requestInstance = config.request;
const credentialsInstance = config.credentials;
// Step 1: Upload file to rooms.media/:rid
const mediaResponse = await requestInstance
.post(api(`rooms.media/${roomId}`))
.set(credentialsInstance)
.attach('file', filePath)
.expect('Content-Type', 'application/json')
.expect(200);
if (!mediaResponse.body.success || !mediaResponse.body.file?._id) {
throw new Error(`File upload failed: ${JSON.stringify(mediaResponse.body)}`);
}
const fileId = mediaResponse.body.file._id;
// Step 2: Confirm and send message with rooms.mediaConfirm/:rid/:fileId
const confirmResponse = await requestInstance
.post(api(`rooms.mediaConfirm/${roomId}/${fileId}`))
.set(credentialsInstance)
.send({
msg: message,
description,
})
.expect('Content-Type', 'application/json')
.expect(200);
if (!confirmResponse.body.success || !confirmResponse.body.message) {
throw new Error(`File confirmation failed: ${JSON.stringify(confirmResponse.body)}`);
}
return confirmResponse.body;
}
/**
* Gets the list of files for a room.
*
* @param roomId - The room ID
* @param config - Request configuration
* @param options - Optional query parameters (name for filtering, count, offset)
* @returns Promise resolving to the files list response
*/
export async function getFilesList(
roomId: string,
config: IRequestConfig,
options: { name?: string; count?: number; offset?: number } = {},
): Promise<{
files: Array<{
_id: string;
name: string;
size: number;
type: string;
rid: string;
userId: string;
path?: string;
url?: string;
uploadedAt?: string;
federation?: {
mrid?: string;
mxcUri?: string;
serverName?: string;
mediaId?: string;
};
}>;
count: number;
offset: number;
total: number;
success: boolean;
}> {
const requestInstance = config.request;
const credentialsInstance = config.credentials;
const queryParams: Record<string, string> = {
roomId,
count: String(options.count || 10),
offset: String(options.offset || 0),
sort: JSON.stringify({ uploadedAt: -1 }),
};
if (options.name) {
queryParams.name = options.name;
}
const response = await requestInstance
.get(api('groups.files'))
.set(credentialsInstance)
.query(queryParams)
.expect('Content-Type', 'application/json')
.expect(200);
if (!response.body.success) {
throw new Error(`Failed to get files list: ${JSON.stringify(response.body)}`);
}
return response.body;
}
/**
* Downloads a file and verifies it matches the original file using binary comparison.
*
* @param fileUrl - The URL to download the file from (relative path like /file-upload/...)
* @param originalFilePath - Path to the original file to compare against
* @param config - Request configuration
* @returns Promise resolving to true if files match byte-by-byte
*/
export async function downloadFileAndVerifyBinary(fileUrl: string, originalFilePath: string, config: IRequestConfig): Promise<boolean> {
const requestInstance = config.request;
const credentialsInstance = config.credentials;
const response = await requestInstance.get(fileUrl).set(credentialsInstance).expect(200);
// Handle different response types:
// - For text/plain, supertest parses as JSON (resulting in {}), so use response.text
// - For binary files, response.body might be a Buffer
// - For other text types, response.text contains the content
let downloadedBuffer: Buffer;
if (Buffer.isBuffer(response.body)) {
// Binary file - response.body is already a Buffer
downloadedBuffer = response.body;
} else if (response.text !== undefined) {
// Text response (including text/plain) - use response.text to avoid JSON parsing
// Convert to Buffer using binary encoding to preserve exact bytes
downloadedBuffer = Buffer.from(response.text, 'binary');
} else if (typeof response.body === 'string') {
// Fallback: if body is a string, convert to buffer
downloadedBuffer = Buffer.from(response.body, 'binary');
} else {
// If body is an object (like {} from JSON parsing), this is an error
throw new Error(
`Failed to get file content. Response body type: ${typeof response.body}. ` +
`This usually means supertest parsed a text/plain response as JSON. ` +
`Response text available: ${response.text !== undefined ? 'yes' : 'no'}`,
);
}
// Read the original file
const originalBuffer = fs.readFileSync(originalFilePath);
// Compare buffers byte-by-byte
if (downloadedBuffer.length !== originalBuffer.length) {
return false;
}
return downloadedBuffer.equals(originalBuffer);
}

View File

@@ -0,0 +1,22 @@
import { api, credentials, request } from './api-data';
export const createGroup = ({ name }: { name: string }) => {
if (!name) {
throw new Error('"name" is required in "createGroup" test helper');
}
return request.post(api('groups.create')).set(credentials).send({ name });
};
export const deleteGroup = ({ groupId, roomName }: { groupId?: string; roomName?: string }) => {
if (!groupId && !roomName) {
throw new Error('"groupId" or "roomName" is required in "deleteGroup" test helper');
}
return request
.post(api('groups.delete'))
.set(credentials)
.send({
...(groupId && { groupId }),
...(roomName && { roomName }),
});
};

View File

@@ -0,0 +1,48 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IIntegration } from '@rocket.chat/core-typings';
import type { IntegrationsCreateProps } from '@rocket.chat/rest-typings';
import { api, credentials, request } from './api-data';
export const createIntegration = (integration: IntegrationsCreateProps, userCredentials: Credentials) =>
new Promise<IIntegration>((resolve, reject) => {
void request
.post(api('integrations.create'))
.set(userCredentials)
.send(integration)
.end((err, res) => {
if (err) {
reject(err);
return;
}
if (!res.body.success) {
reject(res.body);
return;
}
resolve(res.body.integration);
});
});
export const removeIntegration = (integrationId: IIntegration['_id'], type: 'incoming' | 'outgoing') =>
new Promise<void>((resolve) => {
void request
.post(api('integrations.remove'))
.set(credentials)
.send({
type: `webhook-${type}`,
integrationId,
})
.end((err, res) => {
if (err) {
console.warn(err);
}
if (!res.body.success) {
console.warn(res.body);
}
resolve();
});
});

View File

@@ -0,0 +1,3 @@
export const imgURL = './public/images/logo/1024x1024.png';
export const soundURL = './public/sounds/beep.mp3';
export const tiffURL = './tests/mocks/files/sample-tiff.tiff';

View File

@@ -0,0 +1,209 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import type { POSTLivechatBusinessHoursSaveParams } from '@rocket.chat/rest-typings';
import moment from 'moment';
import { api, credentials, request } from '../api-data';
import { updateEESetting, updateSetting } from '../permissions.helper';
type ISaveBhApiWorkHour = Omit<ILivechatBusinessHour, '_id' | 'ts' | 'timezone'> & {
workHours: { day: string; start: string; finish: string; open: boolean }[];
} & { departmentsToApplyBusinessHour?: string } & { timezoneName: string };
export const saveBusinessHour = async (businessHour: POSTLivechatBusinessHoursSaveParams) => {
const { body } = await request.post(api('livechat/business-hours.save')).set(credentials).send(businessHour);
return body;
};
export const createCustomBusinessHour = async (departments: string[], open = true): Promise<ILivechatBusinessHour> => {
const name = `business-hour-${Date.now()}`;
const businessHour: POSTLivechatBusinessHoursSaveParams = {
name,
active: true,
type: LivechatBusinessHourTypes.CUSTOM,
workHours: getWorkHours(open),
timezoneName: 'Asia/Calcutta',
timezone: 'Asia/Calcutta',
departmentsToApplyBusinessHour: '',
daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
daysTime: [
{ day: 'Monday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Tuesday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Wednesday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Thursday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Friday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
],
};
if (departments.length) {
businessHour.departmentsToApplyBusinessHour = departments.join(',');
}
await saveBusinessHour(businessHour);
const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours();
const createdBusinessHour = existingBusinessHours.find((bh) => bh.name === name);
if (!createdBusinessHour) {
throw new Error('Could not create business hour');
}
return createdBusinessHour;
};
export const makeDefaultBusinessHourActiveAndClosed = async () => {
// enable settings
await updateSetting('Livechat_enable_business_hours', true);
await updateEESetting('Livechat_business_hour_type', 'Single');
// create business hours
const {
body: { businessHour },
} = await request.get(api('livechat/business-hour')).query({ type: 'default' }).set(credentials).send();
const { workHours } = businessHour;
// Remove properties not accepted by the endpoint schema
const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour;
const enabledBusinessHour = {
...cleanedBusinessHour,
timezoneName: 'America/Sao_Paulo',
timezone: 'America/Sao_Paulo',
departmentsToApplyBusinessHour: '',
daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
daysTime: workHours.map((workHour: { open: boolean; start: { time: string }; finish: { time: string }; day: string }) => {
return {
open: true,
start: { time: '00:00' },
finish: { time: '00:01' },
day: workHour.day,
};
}),
workHours: workHours.map((workHour: { open: boolean; start: string; finish: string; day: string; code?: number }) => {
workHour.open = true;
workHour.start = '00:00';
workHour.finish = '00:01'; // if a job runs between 00:00 and 00:01, then this test will fail :P
delete workHour.code;
return workHour;
}),
};
return request.post(api('livechat/business-hours.save')).set(credentials).send(enabledBusinessHour).expect(200);
};
export const disableDefaultBusinessHour = async () => {
// disable settings
await updateSetting('Livechat_enable_business_hours', false);
await updateEESetting('Livechat_business_hour_type', 'Single');
// create business hours
const {
body: { businessHour },
} = await request.get(api('livechat/business-hour')).query({ type: 'default' }).set(credentials).send();
const { workHours } = businessHour;
// Remove properties not accepted by the endpoint schema
const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour;
const disabledBusinessHour = {
...cleanedBusinessHour,
timezoneName: 'America/Sao_Paulo',
timezone: 'America/Sao_Paulo',
departmentsToApplyBusinessHour: '',
daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
daysTime: workHours.map((workHour: { open: boolean; start: { time: string }; finish: { time: string }; day: string }) => {
return {
open: false,
start: { time: '00:00' },
finish: { time: '23:59' },
day: workHour.day,
};
}),
workHours: workHours.map((workHour: { open: boolean; start: string; finish: string; day: string }) => {
workHour.open = false;
workHour.start = '00:00';
workHour.finish = '23:59';
return workHour;
}),
};
return request.post(api('livechat/business-hours.save')).set(credentials).send(disabledBusinessHour).expect(200);
};
const removeCustomBusinessHour = async (businessHourId: string) => {
return request
.post(api('livechat/business-hours.remove'))
.set(credentials)
.send({ _id: businessHourId, type: LivechatBusinessHourTypes.CUSTOM })
.expect(200);
};
const getAllCustomBusinessHours = async (): Promise<ILivechatBusinessHour[]> => {
const response = await request.get(api('livechat/business-hours')).set(credentials).expect(200);
return (response.body.businessHours || []).filter(
(businessHour: ILivechatBusinessHour) => businessHour.type === LivechatBusinessHourTypes.CUSTOM,
);
};
export const removeAllCustomBusinessHours = async () => {
const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours();
const promises = existingBusinessHours.map((businessHour) => removeCustomBusinessHour(businessHour._id));
await Promise.all(promises);
};
export const getDefaultBusinessHour = async (): Promise<ILivechatBusinessHour> => {
const response = await request
.get(api('livechat/business-hour'))
.set(credentials)
.query({ type: LivechatBusinessHourTypes.DEFAULT })
.expect(200);
return response.body.businessHour;
};
export const getCustomBusinessHourById = async (businessHourId: string): Promise<ILivechatBusinessHour> => {
const response = await request
.get(api('livechat/business-hour'))
.set(credentials)
.query({ type: LivechatBusinessHourTypes.CUSTOM, _id: businessHourId })
.expect(200);
return response.body.businessHour;
};
// TODO: Refactor logic so object passed is of the correct type for POST /livechat/business-hours.save. See: CORE-1552
export const openOrCloseBusinessHour = async (businessHour: ILivechatBusinessHour, open: boolean) => {
const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour;
const timezoneName = businessHour.timezone.name;
const enabledBusinessHour = {
...cleanedBusinessHour,
timezoneName,
timezone: timezoneName,
workHours: getWorkHours().map((workHour) => {
return {
...workHour,
open,
};
}),
departmentsToApplyBusinessHour: businessHour.departments?.map((department) => department._id).join(',') || '',
};
await saveBusinessHour(enabledBusinessHour as any);
};
export const getWorkHours = (open = true): ISaveBhApiWorkHour['workHours'] => {
const workHours: ISaveBhApiWorkHour['workHours'] = [];
for (let i = 0; i < 7; i++) {
workHours.push({
day: moment().day(i).format('dddd'),
start: '00:00',
finish: '23:59',
open,
});
}
return workHours;
};

View File

@@ -0,0 +1,25 @@
import { faker } from '@faker-js/faker';
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { api, credentials, request } from '../api-data';
import type { DummyResponse } from './utils';
export const createCannedResponse = (): Promise<Partial<IOmnichannelCannedResponse>> =>
new Promise((resolve, reject) => {
const response = {
shortcut: `${faker.lorem.word()}-${Date.now()}`,
scope: 'user',
tags: [faker.string.uuid()],
text: faker.lorem.sentence(),
};
return request
.post(api(`canned-responses`))
.set(credentials)
.send(response)
.end((_err: Error, _res: DummyResponse<boolean>) => {
if (_err) {
return reject(_err);
}
resolve(response);
});
});

View File

@@ -0,0 +1,49 @@
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
import type { Response } from 'supertest';
import { credentials, request, api } from '../api-data';
type ExtendedCustomField = Omit<ILivechatCustomField, '_id' | '_updatedAt'> & { field: string };
export const createCustomField = (customField: ExtendedCustomField): Promise<ILivechatCustomField> =>
new Promise((resolve, reject) => {
void request
.get(api(`livechat/custom-fields/${customField.label}`))
.set(credentials)
.send()
.end((err: Error, res: Response) => {
if (err) {
return reject(err);
}
if (res.body.customField !== null && res.body.customField !== undefined) {
resolve(res.body.customField);
} else {
void request
.post(api('livechat/custom-fields.save'))
.set(credentials)
.send({ customFieldId: null, customFieldData: customField })
.end((err: Error, res: Response): void => {
if (err) {
return reject(err);
}
resolve(res.body.customField);
});
}
});
});
export const deleteCustomField = (customFieldID: string) =>
new Promise((resolve, reject) => {
void request
.post(api('livechat/custom-fields.delete'))
.set(credentials)
.send({
customFieldId: customFieldID,
})
.end((err: Error, res: Response): void => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});

View File

@@ -0,0 +1,225 @@
import { faker } from '@faker-js/faker';
import type { Credentials } from '@rocket.chat/api-client';
import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { expect } from 'chai';
import { api, credentials, request } from '../api-data';
import { makeAgentAvailable } from './rooms';
import { createAgentAndReLogin, createAnAwayAgent, createAnOfflineAgent } from './users';
import type { WithRequiredProperty } from './utils';
const NewDepartmentData = ((): Partial<ILivechatDepartment> => ({
enabled: true,
name: `new department ${Random.id()}`,
description: 'created from api',
showOnRegistration: true,
email: faker.internet.email(),
showOnOfflineForm: true,
}))();
export const createDepartment = async (
departmentData?: Partial<ILivechatDepartment>,
agents?: { agentId: string; count?: string; order?: string }[],
): Promise<ILivechatDepartment> => {
const department = { ...NewDepartmentData, ...departmentData };
const response = await request
.post(api('livechat/department'))
.set(credentials)
.send({
department,
...(agents && { agents }),
})
.expect(200);
return response.body.department;
};
const updateDepartment = async (departmentId: string, departmentData: Partial<LivechatDepartmentDTO>): Promise<ILivechatDepartment> => {
const response = await request
.put(api(`livechat/department/${departmentId}`))
.set(credentials)
.send({
department: departmentData,
})
.expect(200);
return response.body.department;
};
export type OnlineAgent = {
user: WithRequiredProperty<IUser, 'username'>;
credentials: Credentials;
};
export const createDepartmentWithAnOnlineAgent = async (): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => {
const { user, credentials: agentCredentials } = await createAgentAndReLogin();
const department = await createDepartment();
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
await makeAgentAvailable(agentCredentials);
return {
department,
agent: {
credentials: agentCredentials,
user,
},
};
};
export const createDepartmentWith2OnlineAgents = async (): Promise<{
department: ILivechatDepartment;
agent1: OnlineAgent;
agent2: OnlineAgent;
}> => {
const [agent1, agent2] = await Promise.all([createAgentAndReLogin(), createAgentAndReLogin()]);
const department = await createDepartment(
{
enabled: true,
name: Random.id(),
showOnRegistration: true,
email: `${Random.id()}@example.com`,
showOnOfflineForm: true,
},
[{ agentId: agent1.user._id }, { agentId: agent2.user._id }],
);
await Promise.all([makeAgentAvailable(agent1.credentials), makeAgentAvailable(agent2.credentials)]);
return {
department,
agent1,
agent2,
};
};
export const createDepartmentWithAgent = async (agent: OnlineAgent): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => {
const { user, credentials } = agent;
const department = await createDepartment();
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
return {
department,
agent: {
credentials,
user,
},
};
};
export const addOrRemoveAgentFromDepartment = async (
departmentId: string,
agent: { agentId: string; username: string; count?: number; order?: number },
add: boolean,
) => {
const response = await request
.post(api(`livechat/department/${departmentId}/agents`))
.set(credentials)
.send({
...(add ? { upsert: [agent], remove: [] } : { remove: [agent], upsert: [] }),
});
if (response.status !== 200) {
throw new Error(`Failed to add or remove agent from department. Status code: ${response.status}\n${response.body}`);
}
};
export const createDepartmentWithAnOfflineAgent = async ({
allowReceiveForwardOffline = false,
fallbackForwardDepartment,
departmentsAllowedToForward,
}: {
allowReceiveForwardOffline?: boolean;
fallbackForwardDepartment?: string;
departmentsAllowedToForward?: string[];
}): Promise<{
department: ILivechatDepartment;
agent: {
credentials: Credentials;
user: WithRequiredProperty<IUser, 'username'>;
};
}> => {
const { user, credentials } = await createAnOfflineAgent();
const department = (await createDepartment({
allowReceiveForwardOffline,
fallbackForwardDepartment,
departmentsAllowedToForward,
})) as ILivechatDepartment;
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
return {
department,
agent: {
credentials,
user,
},
};
};
export const createDepartmentWithAnAwayAgent = async ({
allowReceiveForwardOffline = false,
fallbackForwardDepartment,
departmentsAllowedToForward,
}: {
allowReceiveForwardOffline?: boolean;
fallbackForwardDepartment?: string;
departmentsAllowedToForward?: string[];
}): Promise<{
department: ILivechatDepartment;
agent: {
credentials: Credentials;
user: WithRequiredProperty<IUser, 'username'>;
};
}> => {
const { user, credentials } = await createAnAwayAgent();
const department = (await createDepartment({
allowReceiveForwardOffline,
fallbackForwardDepartment,
departmentsAllowedToForward,
})) as ILivechatDepartment;
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
return {
department,
agent: {
credentials,
user,
},
};
};
export const archiveDepartment = async (departmentId: string): Promise<void> => {
await request
.post(api(`livechat/department/${departmentId}/archive`))
.set(credentials)
.expect(200);
};
export const disableDepartment = async (department: Omit<ILivechatDepartment, '_updatedAt'>): Promise<void> => {
department.enabled = false;
delete department._updatedAt;
const updatedDepartment = await updateDepartment(department._id, department);
expect(updatedDepartment.enabled).to.be.false;
};
export const deleteDepartment = async (departmentId: string): Promise<void> => {
await request
.delete(api(`livechat/department/${departmentId}`))
.set(credentials)
.expect(200);
};
export const getDepartmentById = async (departmentId: string): Promise<ILivechatDepartment> => {
const response = await request
.get(api(`livechat/department/${departmentId}`))
.set(credentials)
.expect(200);
return response.body.department;
};

View File

@@ -0,0 +1,31 @@
import { getCredentials, api, request, credentials } from '../api-data';
export const createEmailInbox = async (): Promise<{ _id: string }> => {
await new Promise((resolve) => getCredentials(resolve));
const { body } = await request
.post(api('email-inbox'))
.set(credentials)
.send({
name: 'test',
active: false,
email: `test${new Date().getTime()}@test.com`,
description: 'test',
senderInfo: 'test',
smtp: {
server: 'smtp.example.com',
port: 587,
username: 'xxxx',
password: 'xxxx',
secure: true,
},
imap: {
server: 'imap.example.com',
port: 993,
username: 'xxxx',
password: 'xxxx',
secure: true,
maxRetries: 10,
},
});
return body;
};

View File

@@ -0,0 +1,31 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { api, request } from '../api-data';
export const fetchAllInquiries = async (credentials: Credentials, department?: string): Promise<ILivechatInquiryRecord[]> => {
const inquiries: ILivechatInquiryRecord[] = [];
let hasMore = true;
let offset = 0;
while (hasMore) {
const { body } = (await request
.get(api('livechat/inquiries.queuedForUser'))
.set(credentials)
.query({
...(department && { department }),
offset,
})
.expect('Content-Type', 'application/json')
.expect(200)) as { body: PaginatedResult<{ inquiries: Array<ILivechatInquiryRecord> }> };
inquiries.push(...body.inquiries);
hasMore = body.total > inquiries.length;
offset += body.count;
}
return inquiries;
};

View File

@@ -0,0 +1,71 @@
import type { ILivechatPriority, IOmnichannelServiceLevelAgreements } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { generateRandomSLAData } from '../../e2e/utils/omnichannel/sla';
import { api, credentials, request } from '../api-data';
import type { DummyResponse } from './utils';
export const createSLA = (): Promise<Omit<IOmnichannelServiceLevelAgreements, '_updated'>> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/sla'))
.set(credentials)
.send(generateRandomSLAData())
.end((err: Error, res: DummyResponse<{ sla: Omit<IOmnichannelServiceLevelAgreements, '_updated'> }, 'unwrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body.sla);
});
});
};
export const deleteSLA = (id: string): Promise<void> => {
return new Promise((resolve, reject) => {
void request
.delete(api(`livechat/sla/${id}`))
.set(credentials)
.send()
.end((err: Error, _res: DummyResponse<void, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve();
});
});
};
export const bulkCreateSLA = (amount: number): Promise<Omit<IOmnichannelServiceLevelAgreements, '_updated'>[]> => {
const promises = [];
for (let i = 0; i < amount; i++) {
promises.push(createSLA());
}
return Promise.all(promises);
};
export const deleteAllSLA = async (): Promise<void> => {
const response = await request.get(api('livechat/sla')).set(credentials).expect('Content-Type', 'application/json').expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.sla).to.be.an('array');
const {
body: { sla },
} = response as { body: { sla: IOmnichannelServiceLevelAgreements[] } };
const promises = sla.map((slaObj) =>
request
.delete(api(`livechat/sla/${slaObj._id}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200),
);
await Promise.all(promises);
};
export const getRandomPriority = async (): Promise<ILivechatPriority> => {
const response = await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.priorities).to.be.an('array');
const {
body: { priorities },
} = response as { body: { priorities: ILivechatPriority[] } };
return priorities[Math.floor(Math.random() * priorities.length)];
};

View File

@@ -0,0 +1,494 @@
import { faker } from '@faker-js/faker';
import type { Credentials } from '@rocket.chat/api-client';
import type {
ILivechatInquiryRecord,
ILivechatAgent,
ILivechatDepartment,
ILivechatVisitor,
IMessage,
IOmnichannelRoom,
} from '@rocket.chat/core-typings';
import type { Response } from 'supertest';
import { api, credentials, methodCall, request } from '../api-data';
import { imgURL } from '../interactions';
import { getSettingValueById, restorePermissionToRoles, updateSetting } from '../permissions.helper';
import { adminUsername } from '../user';
import { getRandomVisitorToken } from './users';
import type { DummyResponse } from './utils';
import { sleep } from './utils';
export const createLivechatRoom = async (visitorToken: string, extraRoomParams?: Record<string, string>): Promise<IOmnichannelRoom> => {
const urlParams = new URLSearchParams();
urlParams.append('token', visitorToken);
if (extraRoomParams) {
for (const [key, value] of Object.entries(extraRoomParams)) {
urlParams.append(key, value);
}
}
const response = await request.get(api('livechat/room')).query(urlParams.toString()).set(credentials).expect(200);
return response.body.room;
};
export const createLivechatRoomWidget = async (
visitorToken: string,
extraRoomParams?: Record<string, string>,
): Promise<IOmnichannelRoom> => {
const urlParams = new URLSearchParams();
urlParams.append('token', visitorToken);
if (extraRoomParams) {
for (const [key, value] of Object.entries(extraRoomParams)) {
urlParams.append(key, value);
}
}
const response = await request
.get(api('livechat/room'))
.set('Cookie', [`rc_room_type=l`, `rc_is_widget=t`])
.query(urlParams.toString())
.set(credentials)
.expect(200);
return response.body.room;
};
export const createVisitorWithCustomData = async ({
department,
visitorName,
customPhone,
customFields,
customToken,
customEmail,
ignoreEmail = false,
ignorePhone = false,
}: {
department?: string;
customPhone?: string;
visitorName?: string;
customEmail?: string;
customFields?: { key: string; value: string; overwrite: boolean }[];
customToken?: string;
ignoreEmail?: boolean;
ignorePhone?: boolean;
}): Promise<ILivechatVisitor> => {
const token = customToken || getRandomVisitorToken();
const email = customEmail || `${token}@${token}.com`;
const phone = customPhone || `${Math.floor(Math.random() * 10000000000)}`;
try {
const res = await request.get(api(`livechat/visitor/${token}`));
if (res?.body?.visitor) {
return res.body.visitor as ILivechatVisitor;
}
} catch {
// Ignore errors from GET; we will create the visitor below.
}
const res = await request
.post(api('livechat/visitor'))
.set(credentials)
.send({
visitor: {
name: visitorName || `Visitor ${Date.now()}`,
token,
customFields: customFields || [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }],
...(department ? { department } : {}),
...(!ignoreEmail ? { email } : {}),
...(!ignorePhone ? { phone } : {}),
},
});
return res.body.visitor as ILivechatVisitor;
};
export const createVisitor = (
department?: string,
visitorName?: string,
customEmail?: string,
customPhone?: string,
): Promise<ILivechatVisitor> =>
new Promise((resolve, reject) => {
const token = getRandomVisitorToken();
const email = customEmail || `${token}@${token}.com`;
const phone = customPhone || `${Math.floor(Math.random() * 10000000000)}`;
void request.get(api(`livechat/visitor/${token}`)).end((err: Error, res: DummyResponse<ILivechatVisitor>) => {
if (!err && res && res.body && res.body.visitor) {
return resolve(res.body.visitor);
}
void request
.post(api('livechat/visitor'))
.set(credentials)
.send({
visitor: {
name: visitorName || `Visitor ${Date.now()}`,
email,
token,
phone,
customFields: [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }],
...(department ? { department } : {}),
},
})
.end((err: Error, res: DummyResponse<ILivechatVisitor>) => {
if (err) {
return reject(err);
}
resolve(res.body.visitor);
});
});
});
export const deleteVisitor = async (token: string): Promise<void> => {
await request.delete(api(`livechat/visitor/${token}`));
};
export const takeInquiry = async (inquiryId: string, agentCredentials?: Credentials): Promise<void> => {
const userId = agentCredentials ? agentCredentials['X-User-Id'] : credentials['X-User-Id'];
await request
.post(api('livechat/inquiries.take'))
.set(agentCredentials || credentials)
.send({ userId, inquiryId })
.expect(200);
};
export const fetchInquiry = (roomId: string): Promise<ILivechatInquiryRecord> => {
return new Promise((resolve, reject) => {
void request
.get(api('livechat/inquiries.getOne'))
.query({ roomId })
.set(credentials)
.end((err: Error, res: DummyResponse<ILivechatInquiryRecord>) => {
if (err) {
return reject(err);
}
resolve(res.body.inquiry);
});
});
};
export const createDepartment = (
agents?: { agentId: string; count?: number }[],
name?: string,
enabled = true,
opts: Record<string, any> = {},
departmentUnit?: { _id?: string },
userCredentials: Credentials = credentials,
): Promise<ILivechatDepartment> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/department'))
.set(userCredentials)
.send({
department: {
name: name || `Department ${Date.now()}`,
enabled,
showOnOfflineForm: true,
showOnRegistration: true,
email: 'a@b.com',
...opts,
},
agents,
departmentUnit,
})
.end((err: Error, res: DummyResponse<ILivechatDepartment>) => {
if (err) {
return reject(err);
}
resolve(res.body.department);
});
});
};
export const updateDepartment = ({
departmentId,
userCredentials,
agents,
name,
enabled = true,
opts = {},
departmentUnit,
}: {
departmentId: string;
userCredentials: Credentials;
agents?: { agentId: string }[];
name?: string;
enabled?: boolean;
opts?: Record<string, any>;
departmentUnit?: { _id?: string };
}): Promise<ILivechatDepartment> => {
return new Promise((resolve, reject) => {
void request
.put(api(`livechat/department/${departmentId}`))
.set(userCredentials)
.send({
department: {
name: name || `Department ${Date.now()}`,
enabled,
showOnOfflineForm: true,
showOnRegistration: true,
email: 'a@b.com',
...opts,
},
agents,
departmentUnit,
})
.end((err: Error, res: DummyResponse<ILivechatDepartment>) => {
if (err) {
return reject(err);
}
resolve(res.body.department);
});
});
};
export const deleteAgent = async (agentId: string = credentials['X-User-Id']): Promise<void> => {
await request
.delete(api(`livechat/users/agent/${agentId}`))
.set(credentials)
.expect(200);
};
export const createAgent = (overrideUsername?: string): Promise<ILivechatAgent> =>
new Promise((resolve, reject) => {
void request
.post(api('livechat/users/agent'))
.set(credentials)
.send({
username: overrideUsername || adminUsername,
})
.end((err: Error, res: DummyResponse<ILivechatAgent>) => {
if (err) {
return reject(err);
}
resolve(res.body.user);
});
});
export const createManager = (overrideUsername?: string): Promise<ILivechatAgent> =>
new Promise((resolve, reject) => {
void request
.post(api('livechat/users/manager'))
.set(credentials)
.send({
username: overrideUsername || adminUsername,
})
.end((err: Error, res: DummyResponse<ILivechatAgent>) => {
if (err) {
return reject(err);
}
resolve(res.body.user);
});
});
export const switchLivechatStatus = async (status: 'available' | 'not-available', overrideCredentials?: Credentials): Promise<void> => {
await request
.post(api('livechat/agent.status'))
.set(overrideCredentials || credentials)
.send({ status })
.expect(200);
};
export const makeAgentAvailable = async (overrideCredentials?: Credentials): Promise<Response> => {
await restorePermissionToRoles('view-l-room');
await request
.post(api('users.setStatus'))
.set(overrideCredentials || credentials)
.send({
message: '',
status: 'online',
});
return request
.post(api('livechat/agent.status'))
.set(overrideCredentials || credentials)
.send({
status: 'available',
});
};
export const makeAgentUnavailable = async (overrideCredentials?: Credentials): Promise<void> => {
await request
.post(api('users.setStatus'))
.set(overrideCredentials || credentials)
.send({ message: '', status: 'offline' })
.expect(200);
await request
.post(api('livechat/agent.status'))
.set(overrideCredentials || credentials)
.send({
status: 'not-available',
})
.expect(200);
};
export const getLivechatRoomInfo = (roomId: string): Promise<IOmnichannelRoom> => {
return new Promise((resolve /* , reject*/) => {
void request
.get(api('channels.info'))
.set(credentials)
.query({
roomId,
})
.end((_err: Error, res: DummyResponse<IOmnichannelRoom>) => {
resolve(res.body.channel);
});
});
};
/**
* @summary Sends message as visitor
*/
export const sendMessage = (roomId: string, message: string, visitorToken: string): Promise<IMessage> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/message'))
.set(credentials)
.send({
rid: roomId,
msg: message,
token: visitorToken,
})
.end((err: Error, res: DummyResponse<IMessage>) => {
if (err) {
return reject(err);
}
resolve(res.body.message);
});
});
};
export const uploadFile = (roomId: string, visitorToken: string): Promise<IMessage> => {
return new Promise((resolve, reject) => {
void request
.post(api(`livechat/upload/${roomId}`))
.set({ 'x-visitor-token': visitorToken, ...credentials })
.attach('file', imgURL)
.end((err: Error, res: DummyResponse<IMessage>) => {
if (err) {
return reject(err);
}
resolve(res.body as unknown as IMessage);
});
});
};
// Sends a message using sendMessage method from agent
export const sendAgentMessage = (roomId: string, msg?: string, userCredentials: Credentials = credentials): Promise<IMessage> => {
return new Promise((resolve, reject) => {
void request
.post(methodCall('sendMessage'))
.set(userCredentials)
.send({
message: JSON.stringify({
method: 'sendMessage',
params: [{ rid: roomId, msg: msg || faker.lorem.sentence() }],
id: 'id',
msg: 'method',
}),
})
.end((err: Error, res: any) => {
if (err) {
return reject(err);
}
resolve(JSON.parse(res.body.message).result);
});
});
};
export const fetchMessages = (roomId: string, visitorToken: string): Promise<IMessage[]> => {
return new Promise((resolve, reject) => {
void request
.get(api(`livechat/messages.history/${roomId}`))
.set(credentials)
.query({
token: visitorToken,
})
.end((err: Error, res: DummyResponse<IMessage[]>) => {
if (err) {
return reject(err);
}
if (!res.body.success) {
reject(res.body);
return;
}
resolve(res.body.messages);
});
});
};
export const closeOmnichannelRoom = async (roomId: string, tags?: string[]): Promise<void> => {
await request
.post(api('livechat/room.closeByUser'))
.set(credentials)
.send({ rid: roomId, ...(tags && { tags }), comment: faker.lorem.sentence() })
.expect(200);
};
export const bulkCreateLivechatRooms = async (
amount: number,
department?: string,
resolveRoomExtraParams?: (index: number) => Record<string, string> | undefined,
): Promise<IOmnichannelRoom[]> => {
const rooms: IOmnichannelRoom[] = [];
for (let i = 0; i < amount; i++) {
const visitor = await createVisitor(department);
const extraRoomParams = resolveRoomExtraParams ? resolveRoomExtraParams(i) : {};
const room = await createLivechatRoom(visitor.token, extraRoomParams);
rooms.push(room);
}
return rooms;
};
export const startANewLivechatRoomAndTakeIt = async ({
departmentId,
agent,
}: {
departmentId?: string;
agent?: Credentials;
} = {}): Promise<{ room: IOmnichannelRoom; visitor: ILivechatVisitor }> => {
const currentRoutingMethod = await getSettingValueById('Livechat_Routing_Method');
const routingMethodChanged = false;
if (currentRoutingMethod !== 'Manual_Selection') {
await updateSetting('Livechat_Routing_Method', 'Manual_Selection');
// wait for routing algorithm to stop
await sleep(1000);
}
const visitor = await createVisitor(departmentId);
const room = await createLivechatRoom(visitor.token);
const { _id: roomId } = room;
const inq = await fetchInquiry(roomId);
await takeInquiry(inq._id, agent);
await sendMessage(roomId, 'test message', visitor.token);
if (routingMethodChanged) {
await updateSetting('Livechat_Routing_Method', currentRoutingMethod);
// wait for routing algorithm to start
await sleep(1000);
}
return { room, visitor };
};
export const placeRoomOnHold = async (roomId: string): Promise<void> => {
await request.post(api('livechat/room.onHold')).set(credentials).send({ roomId }).expect(200);
};
export const moveBackToQueue = async (roomId: string, overrideCredentials?: Credentials): Promise<void> => {
await request
.post(api('livechat/inquiries.returnAsInquiry'))
.set(overrideCredentials || credentials)
.send({ roomId })
.expect('Content-Type', 'application/json')
.expect(200);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,50 @@
import { faker } from '@faker-js/faker';
import type { ILivechatTag, FindTagsResult } from '@rocket.chat/core-typings';
import { credentials, request, api } from '../api-data';
import type { DummyResponse } from './utils';
export const listTags = async (): Promise<FindTagsResult> => {
const { body } = await request.get(api('livechat/tags')).set(credentials).query({ viewAll: 'true' });
return body;
};
export const saveTags = async (departments: string[] = []): Promise<ILivechatTag> => {
const { body } = await request
.post(api('livechat/tags.save'))
.set(credentials)
.send({
tagData: {
name: faker.string.uuid(),
description: faker.lorem.sentence(),
},
...(departments.length > 0 && { tagDepartments: departments }),
});
return body;
};
export const removeAllTags = async (): Promise<boolean> => {
const tagsList = await listTags();
await Promise.all(tagsList.tags.map((tag) => removeTag(tag._id)));
const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(200);
return response.body.tags.length === 0;
};
export const removeTag = (id: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/tags.delete'))
.set(credentials)
.send({ id })
.end((err: Error, res: DummyResponse<string, 'wrapped'>) => {
if (err) {
return reject(err);
}
resolve(JSON.parse(res.body.success).result);
});
});
};

View File

@@ -0,0 +1,55 @@
import { faker } from '@faker-js/faker';
import type { ILivechatTrigger } from '@rocket.chat/core-typings';
import { api, credentials, request } from '../api-data';
import type { DummyResponse } from './utils';
export const createTrigger = (name: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/triggers'))
.set(credentials)
.send({
name,
description: faker.lorem.sentence(),
enabled: true,
runOnce: faker.datatype.boolean(),
actions: [
{
name: 'send-message',
params: {
msg: faker.lorem.sentence(),
name: faker.person.firstName(),
sender: faker.helpers.arrayElement(['queue', 'custom']),
},
},
],
conditions: [
{
name: faker.helpers.arrayElement(['time-on-site', 'page-url', 'chat-opened-by-visitor', 'after-guest-registration']),
value: faker.string.alpha(),
},
],
})
.end((err: Error, _res: DummyResponse<boolean, 'unwrapped'>) => {
if (err) {
return reject(err);
}
resolve(true);
});
});
};
export const fetchTriggers = (): Promise<ILivechatTrigger[]> => {
return new Promise((resolve, reject) => {
void request
.get(api('livechat/triggers'))
.set(credentials)
.end((err: Error, res: DummyResponse<ILivechatTrigger[], 'wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body.triggers);
});
});
};

View File

@@ -0,0 +1,85 @@
import { faker } from '@faker-js/faker';
import type { IOmnichannelBusinessUnit } from '@rocket.chat/core-typings';
import { methodCall, credentials, request, api } from '../api-data';
import type { DummyResponse } from './utils';
export const createMonitor = async (username: string): Promise<{ _id: string; username: string; role: string[] }> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/monitors.create'))
.set(credentials)
.send({ username })
.end((err: Error, res: DummyResponse<{ _id: string; username: string; role: string[] }, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});
};
export const createUnit = async (
monitorId: string,
username: string,
departmentIds: string[],
name?: string,
extraMonitor: { monitorId: string; username: string }[] = [],
): Promise<IOmnichannelBusinessUnit> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/units'))
.set(credentials)
.send({
unitData: {
name: name || `${faker.person.firstName()} ${faker.string.uuid()}`,
visibility: faker.helpers.arrayElement(['public', 'private']),
},
unitMonitors: [{ monitorId, username }, ...extraMonitor],
unitDepartments: departmentIds.map((departmentId) => ({ departmentId })),
})
.end((err: Error, res: DummyResponse<IOmnichannelBusinessUnit, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});
};
export const deleteUnit = async (unit: IOmnichannelBusinessUnit): Promise<IOmnichannelBusinessUnit> => {
return new Promise((resolve, reject) => {
void request
.post(methodCall(`livechat:removeUnit`))
.set(credentials)
.send({
message: JSON.stringify({
method: 'livechat:removeUnit',
params: [unit._id],
id: '101',
msg: 'method',
}),
})
.end((err: Error, res: DummyResponse<string, 'wrapped'>) => {
if (err) {
return reject(err);
}
resolve(JSON.parse(res.body.message).result);
});
});
};
export const getUnit = (unitId: string): Promise<IOmnichannelBusinessUnit> => {
return new Promise((resolve, reject) => {
void request
.get(api(`livechat/units/${unitId}`))
.set(credentials)
.end((err: Error, res: DummyResponse<IOmnichannelBusinessUnit, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});
};

View File

@@ -0,0 +1,128 @@
import { faker } from '@faker-js/faker';
import type { Credentials } from '@rocket.chat/api-client';
import { UserStatus, type ILivechatAgent, type IUser } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { api, credentials, request } from '../api-data';
import { password } from '../user';
import { createUser, login, setUserAway, setUserStatus } from '../users.helper';
import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms';
export const createBotAgent = async (): Promise<{
credentials: Credentials;
user: IUser;
}> => {
const agent = await createUser({
roles: ['bot'],
});
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
await makeAgentAvailable(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const getRandomVisitorToken = (): string => faker.string.alphanumeric(17);
export const getAgent = async (userId: string): Promise<ILivechatAgent> => {
const { body } = await request
.get(api(`livechat/users/agent/${userId}`))
.set(credentials)
.expect(200);
return body.user;
};
export const removeAgent = async (userId: string): Promise<void> => {
await request
.delete(api(`livechat/users/agent/${userId}`))
.set(credentials)
.expect(200);
};
export const createAgentAndReLogin = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const username = `user.test.${Random.id()}`;
const email = `${username}@rocket.chat`;
const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password });
const agent = body.user;
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const createAnOnlineAgent = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const { credentials: createdUserCredentials, user: agent } = await createAgentAndReLogin();
await makeAgentAvailable(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const createAnOfflineAgent = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const username = `user.test.${Date.now()}.offline`;
const email = `${username}.offline@rocket.chat`;
const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password });
const agent = body.user;
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
await makeAgentUnavailable(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const createAnAwayAgent = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const username = `user.test.${Date.now()}.away`;
const email = `${username}.offline@rocket.chat`;
const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password });
const agent = body.user;
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
await makeAgentAvailable(createdUserCredentials);
await setUserStatus(createdUserCredentials, UserStatus.AWAY);
await setUserAway(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const updateLivechatSettingsForUser = async (
agentId: string,
livechatSettings: Record<string, any>,
agentDepartments: string[] = [],
): Promise<void> => {
await request
.post(api('livechat/agents.saveInfo'))
.set(credentials)
.send({
agentId,
agentData: livechatSettings,
agentDepartments,
})
.expect('Content-Type', 'application/json')
.expect(200);
};

View File

@@ -0,0 +1,17 @@
export type DummyResponse<T, E = 'wrapped'> = E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T };
export type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const parseMethodResponse = (response: any) => {
if (response.message) {
return JSON.parse(response.message);
}
return {};
};

View File

@@ -0,0 +1,13 @@
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { api, credentials, request } from '../api-data';
export const getLivechatVisitorByToken = async (token: string): Promise<ILivechatVisitor> => {
const response = await request
.get(api(`livechat/visitor/${token}`))
.set(credentials)
.expect(200);
expect(response.body).to.have.property('visitor');
return response.body.visitor;
};

View File

@@ -0,0 +1,52 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { credentials, methodCall, request } from './api-data';
import type { IRequestConfig } from './users.helper';
type SendMessageParams = {
rid: IRoom['_id'];
msg: string;
config?: IRequestConfig;
};
/**
* Sends a text message to a room using the method.call/sendMessage DDP endpoint.
*
* This helper function allows sending messages to rooms (channels, groups, DMs)
* for federation testing scenarios using the DDP method format. It supports
* custom request configurations for cross-domain federation testing.
*
* @param rid - The unique identifier of the room
* @param msg - The message text to send
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the API response
*/
export const sendMessage = ({ rid, msg, config }: SendMessageParams) => {
if (!rid) {
throw new Error('"rid" is required in "sendMessage" test helper');
}
if (!msg) {
throw new Error('"msg" is required in "sendMessage" test helper');
}
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return requestInstance
.post(methodCall('sendMessage'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'sendMessage',
params: [
{
_id: `${Date.now()}-${Math.random()}`,
rid,
msg,
},
],
id: `${Date.now()}-${Math.random()}`,
msg: 'method',
}),
});
};

View File

@@ -0,0 +1,93 @@
import { MOCK_SERVER_URL } from '../e2e/config/constants';
type Decision = 'DECISION_PERMIT' | 'DECISION_DENY';
export const mockServerSet = async (method: string, path: string, body: unknown, statusCode = 200, times = 0): Promise<void> => {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/set`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method,
path,
response: { status_code: statusCode, body, times },
}),
});
if (!res.ok) {
throw new Error(`Failed to program mock-server: ${res.status} ${await res.text()}`);
}
};
export const mockServerSetMany = async (
mocks: Array<{ method: string; path: string; body: unknown; statusCode?: number; times?: number }>,
): Promise<void> => {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-many`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
mocks.map((m) => ({
method: m.method,
path: m.path,
response: { status_code: m.statusCode ?? 200, body: m.body, times: m.times ?? 0 },
})),
),
});
if (!res.ok) {
throw new Error(`Failed to program mock-server (set-many): ${res.status} ${await res.text()}`);
}
};
export const mockServerReset = async (): Promise<void> => {
await fetch(`${MOCK_SERVER_URL}/__mock/reset`, { method: 'DELETE' });
};
export const mockServerHealthy = async (): Promise<boolean> => {
try {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/health`);
return res.ok;
} catch {
return false;
}
};
export const seedDefaultMocks = async () => {
await mockServerSetMany([
{
method: 'GET',
path: '/healthz',
body: { status: 'SERVING' },
},
{
method: 'POST',
path: '/auth/realms/mock/protocol/openid-connect/token',
body: { access_token: 'mock-pdp-token', token_type: 'Bearer', expires_in: 3600 },
},
]);
};
export const seedGetDecisionBulk = async (
responses: Array<{ resourceDecisions: Array<{ decision: Decision; ephemeralResourceId?: string }> }>,
times = 0,
) => {
await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionResponses: responses }, 200, times);
};
export const seedGetEntitlements = async (fqnMap: Record<string, unknown>, times = 0) => {
await mockServerSet(
'POST',
'/authorization.v2.AuthorizationService/GetEntitlements',
{ entitlements: [{ actionsPerAttributeValueFqn: fqnMap }] },
200,
times,
);
};
export const seedBulkDecisionByEntity = async (permitValues: string[], defaultDecision: Decision = 'DECISION_DENY') => {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-bulk-decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permit_values: permitValues, default_decision: defaultDecision }),
});
if (!res.ok) {
throw new Error(`Failed to program mock-server (set-bulk-decision): ${res.status} ${await res.text()}`);
}
};

View File

@@ -0,0 +1,122 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { api, credentials, request } from './api-data';
import { omnichannelEEPermissions } from '../../ee/server/lib/omnichannel/permissions';
import { permissions } from '../../server/lib/authorization/constant/permissions';
import { IS_EE } from '../e2e/config/constants';
export const updatePermission = (permission: string, roles: string[]): Promise<void | Error> =>
new Promise((resolve, reject) => {
void request
.post(api('permissions.update'))
.set(credentials)
.send({ permissions: [{ _id: permission, roles }] })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
export const updateEEPermission = (permission: string, roles: string[]): Promise<void | Error> =>
IS_EE ? updatePermission(permission, roles) : Promise.resolve();
const updateManyPermissions = (permissions: { [key: string]: string[] }): Promise<void | Error> =>
new Promise((resolve, reject) => {
void request
.post(api('permissions.update'))
.set(credentials)
.send({ permissions: Object.keys(permissions).map((k) => ({ _id: k, roles: permissions[k] })) })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => setTimeout(() => (!err && resolve()) || reject(err), 100));
});
export const updateSetting = (setting: string, value: ISetting['value'], debounce = true): Promise<void | Error> =>
new Promise((resolve, reject) => {
void request
.post(`/api/v1/settings/${setting}`)
.set(credentials)
.send({ value })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => {
if (err) {
return reject(err);
}
if (debounce) {
setTimeout(resolve, 100);
return;
}
resolve();
});
});
export const getSettingValueById = async (setting: string): Promise<ISetting['value']> => {
const response = await request.get(`/api/v1/settings/${setting}`).set(credentials).expect('Content-Type', 'application/json').expect(200);
return response.body.value;
};
export const updateEESetting = (setting: string, value: ISetting['value']): Promise<void | Error> =>
IS_EE
? new Promise((resolve, reject) => {
void request
.post(`/api/v1/settings/${setting}`)
.set(credentials)
.send({ value })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => setTimeout(() => (!err && resolve()) || reject(err), 100));
})
: Promise.resolve();
export const removePermissions = async (perms: string[]) => {
await updateManyPermissions(Object.fromEntries(perms.map((name) => [name, []])));
};
export const addPermissions = async (perms: { [key: string]: string[] }) => {
await updateManyPermissions(perms);
};
type Permission = (typeof permissions)[number]['_id'];
export const removePermissionFromAllRoles = async (permission: Permission) => {
await updatePermission(permission, []);
};
const getPermissions = () => {
if (!IS_EE) {
return permissions;
}
return [...permissions, ...omnichannelEEPermissions];
};
export const restorePermissionToRoles = async (permission: Permission) => {
const defaultPermission = getPermissions().find((p) => p._id === permission);
if (!defaultPermission) {
throw new Error(`No default roles found for permission ${permission}`);
}
const mutableDefaultRoles: string[] = defaultPermission.roles.map((r) => r);
if (!IS_EE) {
const eeOnlyRoles = ['livechat-monitor'];
eeOnlyRoles.forEach((role) => {
const index = mutableDefaultRoles.indexOf(role);
if (index !== -1) {
mutableDefaultRoles.splice(index, 1);
}
});
}
await updatePermission(permission, mutableDefaultRoles);
};

View File

@@ -0,0 +1,41 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRole } from '@rocket.chat/core-typings';
import { api, credentials, request } from './api-data';
export const createCustomRole = async ({
name,
scope,
description,
credentials: customCredentials,
}: Pick<IRole, 'name' | 'scope' | 'description'> & { credentials?: Credentials }) => {
const response = await request
.post(api('roles.create'))
.set(customCredentials || credentials)
.send({ name, scope, description });
return response.body.role as IRole;
};
export const deleteCustomRole = async ({ roleId, credentials: customCredentials }: { roleId: IRole['_id']; credentials?: Credentials }) => {
const response = await request
.post(api('roles.delete'))
.set(customCredentials || credentials)
.send({ roleId });
return response.body.success as boolean;
};
export const assignRoleToUser = async ({
username,
roleId,
credentials: customCredentials,
}: {
username: string;
roleId: string;
credentials?: Credentials;
}) => {
const response = await request
.post(api('roles.addUserToRole'))
.set(customCredentials || credentials)
.send({ username, roleId });
return response.body.success as boolean;
};

View File

@@ -0,0 +1,539 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRoom, ISubscription, IUser, IMessage } from '@rocket.chat/core-typings';
import type { Endpoints } from '@rocket.chat/rest-typings';
import { api, credentials, methodCall, request } from './api-data';
import type { IRequestConfig } from './users.helper';
type CreateRoomParams = {
name?: IRoom['name'];
type: IRoom['t'];
username?: string;
members?: string[];
credentials?: Credentials;
readOnly?: boolean;
extraData?: Record<string, any>;
config?: IRequestConfig;
};
export const createRoom = ({
name,
type,
username,
members,
credentials: customCredentials,
extraData,
readOnly,
config,
}: CreateRoomParams) => {
if (!type) {
throw new Error('"type" is required in "createRoom.ts" test helper');
}
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || customCredentials || credentials;
if (type === 'd' && !username) {
throw new Error('To be able to create DM Room, you must provide the username');
}
const endpoints = {
c: 'channels.create',
p: 'groups.create',
d: 'im.create',
} as const;
const params = type === 'd' ? { username } : { name };
const roomType = endpoints[type as keyof typeof endpoints];
return requestInstance
.post(api(roomType))
.set(credentialsInstance)
.send({
...params,
...(members && { members }),
...(readOnly && { readOnly }),
...(extraData && { extraData }),
});
};
type ActionType = 'delete' | 'close' | 'addOwner' | 'removeOwner';
export type ActionRoomParams = {
action: ActionType;
type: Exclude<IRoom['t'], 'l'>;
roomId: IRoom['_id'];
overrideCredentials?: Credentials;
extraData?: Record<string, any>;
};
export function actionRoom({ action, type, roomId, overrideCredentials = credentials, extraData = {} }: ActionRoomParams) {
if (!type) {
throw new Error(`"type" is required in "${action}Room" test helper`);
}
if (!roomId) {
throw new Error(`"roomId" is required in "${action}Room" test helper`);
}
const endpoints = {
c: 'channels',
p: 'groups',
d: 'im',
} as const;
const path = `${endpoints[type]}.${action}` as const;
if (path === 'im.addOwner' || path === 'im.removeOwner') throw new Error(`invalid path ("${path}")`);
return new Promise((resolve) => {
void request
.post(api(path))
.set(overrideCredentials)
.send({
roomId,
...extraData,
})
.end(resolve);
});
}
export const deleteRoom = ({ type, roomId }: { type: ActionRoomParams['type']; roomId: IRoom['_id'] }) =>
actionRoom({ action: 'delete', type, roomId, overrideCredentials: credentials });
export const getSubscriptionByRoomId = (roomId: IRoom['_id'], userCredentials = credentials, req = request): Promise<ISubscription> =>
new Promise((resolve, reject) => {
void req
.get(api('subscriptions.getOne'))
.set(userCredentials)
.query({ roomId })
.end((err, res) => {
if (err) {
return reject(err);
}
if (!res.body?.subscription) {
return reject(new Error('Subscription not found'));
}
resolve(res.body.subscription);
});
});
/**
* Adds users to a room using the REST invite endpoints (channels.invite / groups.invite).
*
* This is the entrypoint the "Add users" UI uses. Supports both local and federated users.
* The REST endpoints accept a single invitee per call, so this issues one request per
* username (mirroring how the UI fans out the invites) and resolves to the array of responses.
*
* @param usernames - Array of usernames to add to the room
* @param rid - The unique identifier of the room
* @param type - Room type, selects the endpoint: 'c' -> channels.invite, 'p' -> groups.invite
* @param userCredentials - Optional credentials for the request (deprecated, use config instead)
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the array of REST invite responses (one per username)
*/
export const addUserToRoom = ({
usernames,
rid,
type = 'c',
userCredentials,
config,
}: {
usernames: string[];
rid: IRoom['_id'];
type?: 'c' | 'p';
userCredentials?: Credentials;
config?: IRequestConfig;
}) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || userCredentials || credentials;
const endpoint = type === 'p' ? 'groups.invite' : 'channels.invite';
// The REST invite endpoints accept a single invitee per call, so we issue one
// request per username (mirroring how the UI fans out the invites).
return Promise.all(
usernames.map((username) => requestInstance.post(api(endpoint)).set(credentialsInstance).send({ roomId: rid, username })),
);
};
/**
* Adds users to a room using the deprecated `addUsersToRoom` DDP method.
*
* The method is deprecated in favour of the REST invite endpoints (see {@link addUserToRoom}),
* but it is still a supported entrypoint. Prefer the REST helper for general test setup — this
* helper exists to keep dedicated coverage of the DDP-method entrypoint (e.g. federation invites).
*
* @param usernames - Array of usernames to add to the room
* @param rid - The unique identifier of the room
* @param userCredentials - Optional credentials for the request (deprecated, use config instead)
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the method call response
*/
export const addUserToRoomViaMethod = ({
usernames,
rid,
userCredentials,
config,
}: {
usernames: string[];
rid: IRoom['_id'];
userCredentials?: Credentials;
config?: IRequestConfig;
}) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || userCredentials || credentials;
return requestInstance
.post(methodCall('addUsersToRoom'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'addUsersToRoom',
params: [{ rid, users: usernames }],
id: 'id',
msg: 'method',
}),
});
};
/**
* Adds users to a room using the /invite slash command via method.call.
*
* Executes the /invite slash command using the DDP method call to add users to a room.
* This simulates the user experience of using slash commands in the UI.
* Supports both local and federated users, with proper error handling for federation restrictions.
*
* @param usernames - Array of usernames to add to the room
* @param rid - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the method call response
* @note The slash command expects parameters: { cmd: string, params: string, msg: IMessage, triggerId: string }
*/
// TODO(ddp-removal): swap /api/v1/method.call/slashCommand for
// POST /v1/commands.run. Same caveat as addUserToRoom: federation specs
// inspect the DDP-style `message` payload and need to be ported to the
// REST envelope first.
export const addUserToRoomSlashCommand = ({
usernames,
rid,
config,
}: {
usernames: string[];
rid: IRoom['_id'];
config?: IRequestConfig;
}) => {
if (!usernames || usernames.length === 0) {
throw new Error('"usernames" is required in "addUserToRoomSlashCommand" test helper');
}
if (!rid) {
throw new Error('"rid" is required in "addUserToRoomSlashCommand" test helper');
}
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return requestInstance
.post(methodCall('slashCommand'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'slashCommand',
params: [
{
cmd: 'invite',
params: usernames.join(' '),
msg: {
rid,
_id: `test-${Date.now()}`,
},
triggerId: `test-trigger-${Date.now()}`,
},
],
id: 'id',
msg: 'method',
}),
});
};
/**
* Retrieves detailed information about a room.
*
* Fetches comprehensive room metadata including federation status,
* member counts, and other room properties needed for federation testing.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to room information response
*/
export const getRoomInfo = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/rooms.info']['GET']>>((resolve) => {
void requestInstance
.get(api('rooms.info'))
.set(credentialsInstance)
.query({
roomId,
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Retrieves room members ordered by their role hierarchy.
*
* Gets the complete list of room members with their roles and permissions,
* ordered by importance. Essential for verifying federation member synchronization
* and role assignments across different Rocket.Chat instances.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to ordered member list response
*/
export const getRoomMembers = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/rooms.membersOrderedByRole']['GET']>>((resolve) => {
void requestInstance
.get(api('rooms.membersOrderedByRole'))
.set(credentialsInstance)
.query({
roomId,
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Finds a specific room member with configurable retry logic.
*
* Searches for a member in a room by username, with retry logic to handle
* eventual consistency in federated systems. This is crucial for federation
* testing where member synchronization may take time to propagate.
*
* @param roomId - The unique identifier of the room to search
* @param username - The username to find
* @param options - Retry configuration options
* @param options.maxRetries - Maximum number of retry attempts (default: 3)
* @param options.delay - Delay between retries in milliseconds (default: 1000)
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 0)
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the user object if found, null otherwise
*/
export const findRoomMember = async (
roomId: IRoom['_id'],
username: string,
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
config?: IRequestConfig,
): Promise<IUser | null> => {
const { maxRetries = 3, delay = 1000, initialDelay = 0 } = options;
if (initialDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, initialDelay));
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const membersResponse = await getRoomMembers(roomId, config);
const member = membersResponse.members.find((member: IUser) => member.username === username);
if (member) {
return member;
}
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
} catch (error) {
console.warn(`Attempt ${attempt} to find room member failed:`, error);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
return null;
};
/**
* Retrieves the message history for a group/private room.
*
* Fetches the complete message history including system messages,
* user messages, and federation events. Essential for verifying
* message synchronization and system message generation in federated rooms.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to message history response
*/
export const getGroupHistory = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/groups.history']['GET']>>((resolve) => {
void requestInstance
.get(api('groups.history'))
.set(credentialsInstance)
.query({
roomId,
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Loads message history for a room using the loadHistory method call.
*
* Fetches message history via the DDP method call endpoint, which returns
* messages with markdown parsing metadata (md attribute). This is useful
* for testing message rendering and markdown parsing, including emoji handling.
*
* @param rid - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @param end - Optional end date to load messages before this timestamp
* @param limit - Optional limit for number of messages to return (default: 20)
* @param ls - Optional last seen timestamp for unread calculation
* @param showThreadMessages - Optional flag to include thread messages (default: true)
* @returns Promise resolving to message history with structure: { messages, firstUnread?, unreadNotLoaded? }
*/
export const loadHistory = async (
rid: IRoom['_id'],
config?: IRequestConfig,
end?: Date,
limit?: number,
ls?: string | Date,
showThreadMessages?: boolean,
) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
const params: any[] = [rid];
if (end !== undefined) {
params.push(end);
}
if (limit !== undefined) {
params.push(limit);
}
if (ls !== undefined) {
params.push(ls);
}
if (showThreadMessages !== undefined) {
params.push(showThreadMessages);
}
const response = await requestInstance
.post(methodCall('loadHistory'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'loadHistory',
params,
id: 'id',
msg: 'method',
}),
});
if (!response.body.success) {
throw new Error(`loadHistory failed: ${JSON.stringify(response.body)}`);
}
const data = JSON.parse(response.body.message);
if (data.error) {
throw new Error(`loadHistory method error: ${JSON.stringify(data.error)}`);
}
return data.result as {
messages: IMessage[];
firstUnread?: IMessage;
unreadNotLoaded?: number;
};
};
/**
* Accepts a room invite for the authenticated user.
*
* Processes a room invitation by accepting it, which grants the user
* access to the room. This is essential for federated room workflows
* where users receive invitations rather than auto-joining.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the acceptance response
*/
export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<{ success: boolean; error?: string }>((resolve) => {
void requestInstance
.post(api('rooms.invite'))
.set(credentialsInstance)
.send({
roomId,
action: 'accept',
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Retrieves the subscriptions for the authenticated user.
*
* Fetches the complete list of subscriptions for the authenticated user, which is essential
* for verifying federation subscription synchronization and member synchronization.
*
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the subscriptions response
*/
export const getSubscriptions = (config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/subscriptions.get']['GET']>>((resolve) => {
void requestInstance
.get(api('subscriptions.get'))
.set(credentialsInstance)
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Rejects a room invite for the authenticated user.
*
* Processes a room invitation by rejecting it, which prevents the user
* from joining the room and removes them from the invited members list.
* This is essential for federated room workflows where users can decline invitations.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the rejection response
*/
export const rejectRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<{ success: boolean; error?: string }>((resolve) => {
void requestInstance
.post(api('rooms.invite'))
.set(credentialsInstance)
.send({
roomId,
action: 'reject',
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};

View File

@@ -0,0 +1,35 @@
import { credentials, methodCall, request } from './api-data';
/**
* Executes an app slash command via a POST request to the slashCommand method.
*
* @param cmd - The slashcommand name to execute
* @param rid - The room ID where the command will be executed
* @param params - Optional parameters to pass to the command (default: '')
* @param triggerId - Optional trigger ID for the command invocation (default: 'triggerId')
*
* @returns Promise resolving to the API response
*/
export const executeAppSlashCommand = (cmd: string, rid: string, params = '', triggerId = 'triggerId') =>
request
.post(methodCall('slashCommand'))
.set(credentials)
.send({
message: JSON.stringify({
id: 'id',
msg: 'method',
method: 'slashCommand',
params: [
{
cmd,
params,
triggerId,
msg: {
rid,
_id: 'message_id',
msg: `/${cmd} ${params}`.trim(),
},
},
],
}),
});

View File

@@ -0,0 +1,27 @@
import type { ITeam, TeamType } from '@rocket.chat/core-typings';
import { api, request } from './api-data';
export const createTeam = async (
credentials: Record<string, any>,
teamName: string,
type: TeamType,
members?: string[],
): Promise<ITeam> => {
const response = await request
.post(api('teams.create'))
.set(credentials)
.send({
name: teamName,
type,
...(members && { members }),
});
return response.body.team;
};
export const deleteTeam = async (credentials: Record<string, any>, teamName: string): Promise<void> => {
await request.post(api('teams.delete')).set(credentials).send({
teamName,
});
};

View File

@@ -0,0 +1,380 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { after, before, it } from 'mocha';
import type { Response } from 'supertest';
import { api, request, credentials } from './api-data';
import { imgURL, soundURL } from './interactions';
import { updateSetting } from './permissions.helper';
import { addUserToRoom, createRoom, deleteRoom } from './rooms.helper';
import { password } from './user';
import { createUser, deleteUser, login } from './users.helper';
export async function testFileUploads(
filesEndpoint: 'channels.files' | 'groups.files' | 'im.files',
roomType: 'c' | 'd' | 'p',
invalidRoomError = 'error-room-not-found',
) {
let testRoom: IRoom;
const propertyMap = {
c: 'channel',
p: 'group',
d: 'room',
};
let user: any;
before(async () => {
await Promise.all([updateSetting('Message_KeepHistory', true)]);
user = await createUser();
testRoom = (
await createRoom({
type: roomType,
...(roomType === 'd' ? { username: user.username } : { name: `channel-files-${Date.now()}` }),
} as any)
).body[propertyMap[roomType]];
});
after(() =>
Promise.all([deleteRoom({ type: 'c' as const, roomId: testRoom._id }), updateSetting('Message_KeepHistory', false), deleteUser(user)]),
);
it('should fail if invalid channel', (done) => {
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: 'invalid',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', invalidRoomError);
})
.end(done);
});
it('should succeed when searching by roomId', (done) => {
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
it('should succeed when searching by roomId even requested with count and offset params', (done) => {
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
count: 5,
offset: 0,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
it('should succeed when searching by roomName', function (done) {
if (!testRoom.name) {
this.skip();
}
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomName: testRoom.name,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
it('should succeed when searching by roomName even requested with count and offset params', function (done) {
if (!testRoom.name) {
this.skip();
}
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomName: testRoom.name,
count: 5,
offset: 0,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
let fileId: string;
it('should not return thumbnails', async () => {
await request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
await request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
files.forEach((file: unknown) => {
expect(file).to.not.have.property('originalFileId');
});
});
});
it('should not return hidden files', async () => {
let msgId;
await request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
msgId = res.body.message._id;
});
await request
.post(api('chat.delete'))
.set(credentials)
.send({
roomId: testRoom._id,
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200);
await request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
files.forEach((file: unknown) => {
expect(file).to.have.property('_id').to.not.be.equal(fileId);
});
});
});
it('should properly filter files by name or typeGroup', async () => {
const fileOneName = 'image-zyxwv.png';
const fileTwoName = 'sound-abcde.png';
const fileIdsToConfirm: string[] = [];
// Post 2 files, one image and one audio
await Promise.all([
request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', imgURL, { filename: fileOneName })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(typeof res.body?.file?._id).to.equal('string');
fileIdsToConfirm.push(res.body.file._id);
}),
request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', soundURL, { filename: fileTwoName })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(typeof res.body?.file?._id).to.equal('string');
fileIdsToConfirm.push(res.body.file._id);
}),
]);
// Confirm the files
await Promise.all(
fileIdsToConfirm.map((fileId) =>
request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200),
),
);
// test filtering by name
const nameFilterTest = request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
name: fileOneName,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
expect(files[0].name).to.equal(fileOneName);
});
// test filtering by typeGroup
const typeGroupFilterTest = request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
typeGroup: 'audio',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
expect(files[0].name).to.equal(fileTwoName);
});
await Promise.all([nameFilterTest, typeGroupFilterTest]);
});
describe('with another user', () => {
let anotherUserCreds: Credentials;
let anotherUser: IUser;
let extraRoom: IRoom;
before(async () => {
anotherUser = await createUser();
anotherUserCreds = await login(anotherUser.username, password);
extraRoom = (
await createRoom({
type: roomType,
...(roomType === 'd' ? { username: user.username } : { name: `channel-files-${Date.now()}` }),
credentials: anotherUserCreds,
} as any)
).body[propertyMap[roomType]];
if (roomType === 'p') {
await addUserToRoom({
rid: testRoom._id,
usernames: [anotherUser.username!],
type: 'p',
});
}
});
after(() => Promise.all([deleteUser(anotherUser), deleteRoom({ type: roomType, roomId: extraRoom._id })]));
it('should not allow to confirm a file from another user', async function () {
if (roomType === 'd') {
this.skip();
}
let fileId: string;
await request
.post(api(`rooms.media/${testRoom._id}`))
.set(anotherUserCreds)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should not allow to confirm a file that was not uploaded to the same room', async () => {
let fileId: string;
await request
.post(api(`rooms.media/${extraRoom._id}`))
.set(anotherUserCreds)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(400);
});
});
}

View File

@@ -0,0 +1,12 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IUser } from '@rocket.chat/core-typings';
export const password = 'R0ck3t.ch@tP@ssw0rd1234.!';
export const adminUsername = 'rocketchat.internal.admin.test';
export const adminEmail = `${adminUsername}@rocket.chat`;
export const adminPassword = adminUsername;
export type IUserWithCredentials = {
user: IUser;
credentials: Credentials;
};

View File

@@ -0,0 +1,201 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import supertest from 'supertest';
import type { Response } from 'supertest';
import { api, credentials, methodCall, request } from './api-data';
import { password } from './user';
export type TestUser<TUser extends IUser> = TUser & { username: string; emails: string[] };
/**
* Configuration interface for custom request handling.
*
* Provides a way to override the default request instance and credentials
* for testing scenarios that require custom domains or authentication.
*/
export interface IRequestConfig {
credentials: Credentials;
request: ReturnType<typeof supertest>;
}
/**
* Creates a request configuration for a specific domain.
*
* Sets up a new request instance and authenticates with the specified
* domain, user, and password. This is essential for federation testing
* where multiple Rocket.Chat instances need to be accessed.
*
* @param domain - The base URL of the Rocket.Chat instance
* @param user - The username for authentication
* @param password - The password for authentication
* @returns Promise resolving to request configuration with credentials
*/
export async function getRequestConfig(domain: string, user: string, password: string): Promise<IRequestConfig> {
const request = supertest(domain);
const credentials = await login(user, password, { request, credentials: {} as Credentials });
return {
credentials,
request,
};
}
export const createUser = <TUser extends IUser>(
userData: {
username?: string;
email?: string;
roles?: string[];
active?: boolean;
joinDefaultChannels?: boolean;
verified?: boolean;
requirePasswordChange?: boolean;
name?: string;
password?: string;
freeSwitchExtension?: string;
bio?: string;
nickname?: string;
} = {},
config?: IRequestConfig,
) =>
new Promise<TestUser<TUser>>((resolve, reject) => {
const username = userData.username || `user.test.${Date.now()}.${Math.random()}`;
const email = userData.email || `${username}@rocket.chat`;
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
void requestInstance
.post(api('users.create'))
.set(credentialsInstance)
.send({ email, name: username, username, password, ...userData })
.end((err: unknown, res: Response) => {
if (err) {
return reject(err);
}
resolve(res.body.user);
});
});
export const login = (username: string | undefined, password: string, config?: IRequestConfig): Promise<Credentials> =>
new Promise((resolve) => {
const requestInstance = config?.request || request;
void requestInstance
.post(api('login'))
.send({
user: username,
password,
})
.end((_err: unknown, res: Response) => {
resolve({
'X-Auth-Token': res.body.data.authToken,
'X-User-Id': res.body.data.userId,
});
});
});
export const deleteUser = async (user: Pick<IUser, '_id'>, extraData = {}, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return requestInstance
.post(api('users.delete'))
.set(credentialsInstance)
.send({
userId: user._id,
...extraData,
});
};
export const getUserByUsername = <TUser extends IUser>(username: string, config?: IRequestConfig) =>
new Promise<TestUser<TUser>>((resolve) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
void requestInstance
.get(api('users.info'))
.query({ username })
.set(credentialsInstance)
.end((_err: unknown, res: Response) => {
resolve(res.body.user);
});
});
export const getMe = <TUser extends IUser>(overrideCredential = credentials, config?: IRequestConfig) =>
new Promise<TestUser<TUser>>((resolve) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || overrideCredential;
void requestInstance
.get(api('me'))
.set(credentialsInstance)
.expect('Content-Type', 'application/json')
.expect(200)
.end((_end: unknown, res: Response) => {
resolve(res.body);
});
});
export const setUserActiveStatus = (userId: IUser['_id'], activeStatus = true, config?: IRequestConfig) =>
new Promise((resolve) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
void requestInstance
.post(api('users.setActiveStatus'))
.set(credentialsInstance)
.send({
userId,
activeStatus,
})
.end(resolve);
});
export const setUserStatus = (overrideCredentials = credentials, status = UserStatus.ONLINE, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
return requestInstance.post(api('users.setStatus')).set(overrideCredentials).send({
message: '',
status,
});
};
export const setUserAway = (overrideCredentials = credentials, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
return requestInstance
.post(methodCall('UserPresence:away'))
.set(overrideCredentials)
.send({
message: JSON.stringify({
method: 'UserPresence:away',
params: [],
id: 'id',
msg: 'method',
}),
});
};
export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
return requestInstance
.post(methodCall('UserPresence:online'))
.set(overrideCredentials)
.send({
message: JSON.stringify({
method: 'UserPresence:online',
params: [],
id: 'id',
msg: 'method',
}),
});
};
export const removeRoleFromUser = (username: string, roleId: string, overrideCredentials = credentials, config?: IRequestConfig) =>
getUserByUsername(username, config).then((user) => {
const requestInstance = config?.request || request;
return requestInstance
.post(api('users.update'))
.set(overrideCredentials)
.send({
userId: user._id,
data: { roles: user.roles.filter((role) => role !== roleId) },
})
.expect(200);
});

View File

@@ -0,0 +1,40 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { Path } from '@rocket.chat/rest-typings';
import { expect } from 'chai';
import { api, request, type PathWithoutPrefix } from './api-data';
export function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, ms);
return fn(controller.signal).finally(() => {
clearTimeout(timeoutId);
});
}
export const pagination = <TPath extends PathWithoutPrefix<Path>>(
apiEndpoint: TPath,
credentials: Credentials,
extraQueryParams: Record<string, any> = {},
) => {
return request
.get(api(apiEndpoint))
.set(credentials)
.query({
...extraQueryParams,
count: 10,
offset: 1,
sort: JSON.stringify({ _updatedAt: -1 }),
})
.expect('content-type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('count').that.is.a('number');
expect(res.body).to.have.property('offset').that.is.a('number');
expect(res.body).to.have.property('total').that.is.a('number');
});
};

View File

@@ -0,0 +1,11 @@
import { expect } from 'chai';
import type { request } from './api-data';
export function expectInvalidParams(res: Awaited<ReturnType<(typeof request)['post']>>, expectedErrors: string[]): void {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.a('string');
expect(res.body.errorType).to.be.equal('invalid-params');
expect((res.body.error as string).split('\n').map((line) => line.trim())).to.be.deep.equal(expectedErrors);
}