element-web/test/unit-tests/utils/EventUtils-test.ts

479 lines
15 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
M_LOCATION,
EventStatus,
EventType,
IEvent,
MatrixClient,
MatrixEvent,
MsgType,
PendingEventOrdering,
RelationType,
Room,
Thread,
} from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import {
canCancel,
canEditContent,
canEditOwnEvent,
fetchInitialEvent,
findEditableEvent,
highlightEvent,
isContentActionable,
isLocationEvent,
isVoiceMessage,
} from "../../../src/utils/EventUtils";
import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../../test-utils";
import dis from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils";
import { VoiceBroadcastInfoState } from "../../../src/voice-broadcast/types";
jest.mock("../../../src/dispatcher/dispatcher");
describe("EventUtils", () => {
const userId = "@user:server";
const roomId = "!room:server";
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(userId),
});
beforeEach(() => {
mockClient.getUserId.mockClear().mockReturnValue(userId);
});
afterAll(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
});
// setup events
const unsentEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
unsentEvent.status = EventStatus.ENCRYPTING;
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
redactedEvent.makeRedacted(
redactedEvent,
new Room(redactedEvent.getRoomId()!, mockClient, mockClient.getUserId()!),
);
const stateEvent = new MatrixEvent({
type: EventType.RoomTopic,
state_key: "",
});
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
const roomMemberEvent = new MatrixEvent({
type: EventType.RoomMember,
sender: userId,
});
const stickerEvent = new MatrixEvent({
type: EventType.Sticker,
sender: userId,
});
const pollStartEvent = makePollStartEvent("What?", userId);
const notDecryptedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: "m.bad.encrypted",
},
});
const noMsgType = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: undefined,
},
});
const noContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Image,
},
});
const emptyContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "",
},
});
const objectContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.File,
body: {},
},
});
const niceTextMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
const bobsTextMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: "@bob:server",
content: {
msgtype: MsgType.Text,
body: "Hello from Bob",
},
});
const voiceBroadcastStart = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
"@user:example.com",
"ABC123",
);
const voiceBroadcastStop = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Stopped,
"@user:example.com",
"ABC123",
);
describe("isContentActionable()", () => {
type TestCase = [string, MatrixEvent];
it.each<TestCase>([
["unsent event", unsentEvent],
["redacted event", redactedEvent],
["state event", stateEvent],
["undecrypted event", notDecryptedEvent],
["room member event", roomMemberEvent],
["event without msgtype", noMsgType],
["event without content body property", noContentBody],
["broadcast stop event", voiceBroadcastStop],
])("returns false for %s", (_description, event) => {
expect(isContentActionable(event)).toBe(false);
});
it.each<TestCase>([
["sticker event", stickerEvent],
["poll start event", pollStartEvent],
["event with empty content body", emptyContentBody],
["event with a content body", niceTextMessage],
["beacon_info event", beaconInfoEvent],
["broadcast start event", voiceBroadcastStart],
])("returns true for %s", (_description, event) => {
expect(isContentActionable(event)).toBe(true);
});
});
describe("editable content helpers", () => {
const replaceRelationEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
["m.relates_to"]: {
rel_type: RelationType.Replace,
event_id: "1",
},
},
});
const referenceRelationEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: "1",
},
},
});
const emoteEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Emote,
body: "🧪",
},
});
type TestCase = [string, MatrixEvent];
const uneditableCases: TestCase[] = [
["redacted event", redactedEvent],
["state event", stateEvent],
["event that is not room message", roomMemberEvent],
["event without msgtype", noMsgType],
["event without content body property", noContentBody],
["event with empty content body property", emptyContentBody],
["event with non-string body", objectContentBody],
["event not sent by current user", bobsTextMessage],
["event with a replace relation", replaceRelationEvent],
];
const editableCases: TestCase[] = [
["event with reference relation", referenceRelationEvent],
["emote event", emoteEvent],
["poll start event", pollStartEvent],
["event with a content body", niceTextMessage],
];
describe("canEditContent()", () => {
it.each<TestCase>(uneditableCases)("returns false for %s", (_description, event) => {
expect(canEditContent(mockClient, event)).toBe(false);
});
it.each<TestCase>(editableCases)("returns true for %s", (_description, event) => {
expect(canEditContent(mockClient, event)).toBe(true);
});
});
describe("canEditOwnContent()", () => {
it.each<TestCase>(uneditableCases)("returns false for %s", (_description, event) => {
expect(canEditOwnEvent(mockClient, event)).toBe(false);
});
it.each<TestCase>(editableCases)("returns true for %s", (_description, event) => {
expect(canEditOwnEvent(mockClient, event)).toBe(true);
});
});
});
describe("isVoiceMessage()", () => {
it("returns true for an event with msc2516.voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
["org.matrix.msc2516.voice"]: {},
},
});
expect(isVoiceMessage(event)).toBe(true);
});
it("returns true for an event with msc3245.voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
["org.matrix.msc3245.voice"]: {},
},
});
expect(isVoiceMessage(event)).toBe(true);
});
it("returns false for an event with voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
body: "hello",
},
});
expect(isVoiceMessage(event)).toBe(false);
});
});
describe("isLocationEvent()", () => {
it("returns true for an event with m.location stable type", () => {
const event = new MatrixEvent({
type: M_LOCATION.altName,
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for an event with m.location unstable prefixed type", () => {
const event = new MatrixEvent({
type: M_LOCATION.name,
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for a room message with stable m.location msgtype", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
msgtype: M_LOCATION.altName,
},
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for a room message with unstable m.location msgtype", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
msgtype: M_LOCATION.name,
},
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns false for a non location event", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
body: "Hello",
},
});
expect(isLocationEvent(event)).toBe(false);
});
});
describe("canCancel()", () => {
it.each([[EventStatus.QUEUED], [EventStatus.NOT_SENT], [EventStatus.ENCRYPTING]])(
"return true for status %s",
(status) => {
expect(canCancel(status)).toBe(true);
},
);
it.each([
[EventStatus.SENDING],
[EventStatus.CANCELLED],
[EventStatus.SENT],
["invalid-status" as unknown as EventStatus],
])("return false for status %s", (status) => {
expect(canCancel(status)).toBe(false);
});
});
describe("fetchInitialEvent", () => {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
const NORMAL_EVENT = "$normalEvent";
const THREAD_ROOT = "$threadRoot";
const THREAD_REPLY = "$threadReply";
const events: Record<string, Partial<IEvent>> = {
[NORMAL_EVENT]: {
event_id: NORMAL_EVENT,
type: EventType.RoomMessage,
content: {
body: "Classic event",
msgtype: MsgType.Text,
},
},
[THREAD_ROOT]: {
event_id: THREAD_ROOT,
type: EventType.RoomMessage,
content: {
body: "Thread root",
msgtype: "m.text",
},
unsigned: {
"m.relations": {
[RelationType.Thread]: {
latest_event: {
event_id: THREAD_REPLY,
type: EventType.RoomMessage,
content: {
"body": "Thread reply",
"msgtype": MsgType.Text,
"m.relates_to": {
event_id: "$threadRoot",
rel_type: RelationType.Thread,
},
},
},
count: 1,
current_user_participated: false,
},
},
},
},
[THREAD_REPLY]: {
event_id: THREAD_REPLY,
type: EventType.RoomMessage,
content: {
"body": "Thread reply",
"msgtype": MsgType.Text,
"m.relates_to": {
event_id: THREAD_ROOT,
rel_type: RelationType.Thread,
},
},
},
};
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.safeGet();
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
return events[eventId] ?? Promise.reject();
});
});
it("returns null for unknown events", async () => {
expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull();
expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent);
});
it("creates a thread when needed", async () => {
await fetchInitialEvent(client, room.roomId, THREAD_REPLY);
expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread);
});
});
describe("findEditableEvent", () => {
it("should not explode when given empty events array", () => {
expect(
findEditableEvent({
events: [],
isForward: true,
matrixClient: mockClient,
}),
).toBeUndefined();
});
});
describe("highlightEvent", () => {
const eventId = "$zLg9jResFQmMO_UKFeWpgLgOgyWrL8qIgLgZ5VywrCQ";
it("should dispatch an action to view the event", () => {
highlightEvent(roomId, eventId);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: eventId,
highlighted: true,
room_id: roomId,
metricsTrigger: undefined,
});
});
});
});