element-web/test/ContentMessages-test.ts

419 lines
17 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 { mocked } from "jest-mock";
import { ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix";
import { ImageInfo } from "matrix-js-sdk/src/types";
import { defer } from "matrix-js-sdk/src/utils";
import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment";
import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages";
import { doMaybeLocalRoomAction } from "../src/utils/local-room";
import { createTestClient, mkEvent } from "./test-utils";
import { BlurhashEncoder } from "../src/BlurhashEncoder";
jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) }));
jest.mock("../src/BlurhashEncoder", () => ({
BlurhashEncoder: {
instance: {
getBlurhash: jest.fn(),
},
},
}));
jest.mock("../src/utils/local-room", () => ({
doMaybeLocalRoomAction: jest.fn(),
}));
const createElement = document.createElement.bind(document);
describe("ContentMessages", () => {
const stickerUrl = "https://example.com/sticker";
const roomId = "!room:example.com";
const imageInfo = {} as unknown as ImageInfo;
const text = "test sticker";
let client: MatrixClient;
let contentMessages: ContentMessages;
let prom: Promise<ISendEventResponse>;
beforeEach(() => {
client = {
getSafeUserId: jest.fn().mockReturnValue("@alice:test"),
sendStickerMessage: jest.fn(),
sendMessage: jest.fn(),
isRoomEncrypted: jest.fn().mockReturnValue(false),
uploadContent: jest.fn().mockResolvedValue({ content_uri: "mxc://server/file" }),
} as unknown as MatrixClient;
contentMessages = new ContentMessages();
prom = Promise.resolve<ISendEventResponse>({ event_id: "$event_id" });
});
describe("sendStickerContentToRoom", () => {
beforeEach(() => {
mocked(client.sendStickerMessage).mockReturnValue(prom);
mocked(doMaybeLocalRoomAction).mockImplementation(
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>, client?: MatrixClient) => {
return fn(roomId);
},
);
});
it("should forward the call to doMaybeLocalRoomAction", async () => {
await contentMessages.sendStickerContentToRoom(stickerUrl, roomId, null, imageInfo, text, client);
expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text);
});
});
describe("sendContentToRoom", () => {
const roomId = "!roomId:server";
beforeEach(() => {
Object.defineProperty(global.Image.prototype, "src", {
// Define the property setter
set(src) {
window.setTimeout(() => this.onload());
},
});
Object.defineProperty(global.Image.prototype, "height", {
get() {
return 600;
},
});
Object.defineProperty(global.Image.prototype, "width", {
get() {
return 800;
},
});
mocked(doMaybeLocalRoomAction).mockImplementation(
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>) => fn(roomId),
);
mocked(BlurhashEncoder.instance.getBlurhash).mockResolvedValue("blurhashstring");
});
it("should use m.image for image files", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/jpeg" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.image",
}),
);
});
it("should use m.image for PNG files which cannot be parsed but successfully thumbnail", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/png" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.image",
}),
);
});
it("should fall back to m.file for invalid image files", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/jpeg" });
mocked(BlurhashEncoder.instance.getBlurhash).mockRejectedValue("NOT_AN_IMAGE");
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
}),
);
});
it("should use m.video for video files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "video") {
(<HTMLVideoElement>element).load = jest.fn();
(<HTMLVideoElement>element).play = () => element.onloadeddata!(new Event("loadeddata"));
(<HTMLVideoElement>element).pause = jest.fn();
Object.defineProperty(element, "videoHeight", {
get() {
return 600;
},
});
Object.defineProperty(element, "videoWidth", {
get() {
return 800;
},
});
Object.defineProperty(element, "duration", {
get() {
return 123;
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "video/mp4" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.video",
info: expect.objectContaining({
duration: 123000,
}),
}),
);
});
it("should use m.audio for audio files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "audio") {
Object.defineProperty(element, "duration", {
get() {
return 621;
},
});
Object.defineProperty(element, "src", {
set() {
element.onloadedmetadata!(new Event("loadedmetadata"));
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "audio/mp3" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.audio",
info: expect.objectContaining({
duration: 621000,
}),
}),
);
});
it("should fall back to m.file for invalid audio files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "audio") {
Object.defineProperty(element, "src", {
set() {
element.onerror!("fail");
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "audio/mp3" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
}),
);
});
it("should default to name 'Attachment' if file doesn't have a name", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "", { type: "text/plain" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
body: "Attachment",
}),
);
});
it("should keep RoomUpload's total and loaded values up to date", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "", { type: "text/plain" });
const prom = contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
const [upload] = contentMessages.getCurrentUploads();
expect(upload.loaded).toBe(0);
expect(upload.total).toBe(file.size);
const { progressHandler } = mocked(client.uploadContent).mock.calls[0][1]!;
progressHandler!({ loaded: 123, total: 1234 });
expect(upload.loaded).toBe(123);
expect(upload.total).toBe(1234);
await prom;
});
it("properly handles replies", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/jpeg" });
const replyToEvent = mkEvent({
type: "m.room.message",
user: "@bob:test",
room: roomId,
content: {},
event: true,
});
await contentMessages.sendContentToRoom(file, roomId, undefined, client, replyToEvent);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
"url": "mxc://server/file",
"msgtype": "m.image",
"m.mentions": {
user_ids: ["@bob:test"],
},
}),
);
});
});
describe("getCurrentUploads", () => {
const file1 = new File([], "file1");
const file2 = new File([], "file2");
const roomId = "!roomId:server";
beforeEach(() => {
mocked(doMaybeLocalRoomAction).mockImplementation(
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>) => fn(roomId),
);
});
it("should return only uploads for the given relation", async () => {
const relation = {
rel_type: RelationType.Thread,
event_id: "!threadId:server",
};
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
const uploads = contentMessages.getCurrentUploads(relation);
expect(uploads).toHaveLength(1);
expect(uploads[0].relation).toEqual(relation);
expect(uploads[0].fileName).toEqual("file1");
await Promise.all([p1, p2]);
});
it("should return only uploads for no relation when not passed one", async () => {
const relation = {
rel_type: RelationType.Thread,
event_id: "!threadId:server",
};
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
const uploads = contentMessages.getCurrentUploads();
expect(uploads).toHaveLength(1);
expect(uploads[0].relation).toEqual(undefined);
expect(uploads[0].fileName).toEqual("file2");
await Promise.all([p1, p2]);
});
});
describe("cancelUpload", () => {
it("should cancel in-flight upload", async () => {
const deferred = defer<UploadResponse>();
mocked(client.uploadContent).mockReturnValue(deferred.promise);
const file1 = new File([], "file1");
const prom = contentMessages.sendContentToRoom(file1, roomId, undefined, client, undefined);
const { abortController } = mocked(client.uploadContent).mock.calls[0][1]!;
expect(abortController!.signal.aborted).toBeFalsy();
const [upload] = contentMessages.getCurrentUploads();
contentMessages.cancelUpload(upload);
expect(abortController!.signal.aborted).toBeTruthy();
deferred.resolve({} as UploadResponse);
await prom;
});
});
});
describe("uploadFile", () => {
beforeEach(() => {
jest.clearAllMocks();
});
const client = createTestClient();
it("should not encrypt the file if the room isn't encrypted", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const progressHandler = jest.fn();
const file = new Blob([]);
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
expect(res.url).toBe("mxc://server/file");
expect(res.file).toBeFalsy();
expect(encrypt.encryptAttachment).not.toHaveBeenCalled();
expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler }));
});
it("should encrypt the file if the room is encrypted", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(true);
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
mocked(encrypt.encryptAttachment).mockResolvedValue({
data: new ArrayBuffer(123),
info: {} as IEncryptedFile,
});
const progressHandler = jest.fn();
const file = new Blob(["123"]);
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
expect(res.url).toBeFalsy();
expect(res.file).toEqual(
expect.objectContaining({
url: "mxc://server/file",
}),
);
expect(encrypt.encryptAttachment).toHaveBeenCalled();
expect(client.uploadContent).toHaveBeenCalledWith(
expect.any(Blob),
expect.objectContaining({
progressHandler,
includeFilename: false,
type: "application/octet-stream",
}),
);
expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file);
});
it("should throw UploadCanceledError upon aborting the upload", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
const deferred = defer<UploadResponse>();
mocked(client.uploadContent).mockReturnValue(deferred.promise);
const file = new Blob([]);
const prom = uploadFile(client, "!roomId:server", file);
mocked(client.uploadContent).mock.calls[0][1]!.abortController!.abort();
deferred.resolve({ content_uri: "mxc://foo/bar" });
await expect(prom).rejects.toThrow(UploadCanceledError);
});
});