diff --git a/README.md b/README.md index e8c2c213..c0ebb19c 100644 --- a/README.md +++ b/README.md @@ -304,17 +304,17 @@ const updatedMessage = await room.messages.update(message, `updatedMessage` is a Message object with all updates applied. As with sending and deleting, the promise may resolve after the updated message is received via the messages subscription. -A `Message` that was updated will have `updatedAt` and `updatedBy` fields set, and `isUpdated()` will return `true`. +A `Message` that was updated will have values for `updatedAt` and `updatedBy`, and `isUpdated()` will return `true`. Note that if you delete an updated message, it is no longer considered _updated_. Only the last operation takes effect. #### Handling updates in realtime -Updated messages received from realtime have the `latestAction` parameter set to `ChatMessageActions.MessageUpdate`, and the event received has the `type` set to `MessageEvents.Updated`. Updated messages are full copies of the message, meaning that all that is needed to keep a state or UI up to date is to replace the old message with the received one. +Updated messages received from realtime have the `action` parameter set to `ChatMessageActions.MessageUpdate`, and the event received has the `type` set to `MessageEvents.Updated`. Updated messages are full copies of the message, meaning that all that is needed to keep a state or UI up to date is to replace the old message with the received one. -In rare occasions updates might arrive over realtime out of order. To keep a correct state, the `Message` interface provides methods to compare two instances of the same base message to determine which one is newer: `actionBefore()`, `actionAfter()`, and `actionEqual()`. +In rare occasions updates might arrive over realtime out of order. To keep a correct state, compare the `version` lexicographically (string compare). Alternatively, the `Message` interface provides convenience methods to compare two instances of the same base message to determine which version is newer: `versionBefore()`, `versionAfter()`, and `versionEqual()`. -The same out-of-order situation can happen between updates received over realtime and HTTP responses. In the situation where two concurrent edits happen, both might be received via realtime before the HTTP response of the first one arrives. Always use `actionAfter()`, `actionBefore()`, or `actionEqual()` to determine which instance of a `Message` is newer. +The same out-of-order situation can happen between updates received over realtime and HTTP responses. In the situation where two concurrent updates happen, both might be received via realtime before the HTTP response of the first one arrives. Always compare the message `version` to determine which instance of a `Message` is newer. Example for handling updates: ```typescript @@ -325,7 +325,7 @@ room.messages.subscribe(event => { case MessageEvents.Updated: { const serial = event.message.serial; const index = messages.findIndex((m) => m.serial === serial); - if (index !== -1 && messages[index].actionBefore(event.message)) { + if (index !== -1 && messages[index].version < event.message.version) { messages[index] = event.message; } break; diff --git a/demo/src/containers/Chat/Chat.tsx b/demo/src/containers/Chat/Chat.tsx index 689dd6ea..646d44dd 100644 --- a/demo/src/containers/Chat/Chat.tsx +++ b/demo/src/containers/Chat/Chat.tsx @@ -37,8 +37,8 @@ export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => voi return prevMessages; } - // skip update if the received action is not newer - if (!prevMessages[index].actionBefore(message)) { + // skip update if the received version is not newer + if (!prevMessages[index].versionBefore(message)) { return prevMessages; } diff --git a/src/core/chat-api.ts b/src/core/chat-api.ts index ec340b49..12be5937 100644 --- a/src/core/chat-api.ts +++ b/src/core/chat-api.ts @@ -1,7 +1,7 @@ import * as Ably from 'ably'; import { Logger } from './logger.js'; -import { DefaultMessage, Message, MessageActionMetadata, MessageHeaders, MessageMetadata } from './message.js'; +import { DefaultMessage, Message, MessageHeaders, MessageMetadata, MessageOperationMetadata } from './message.js'; import { OccupancyEvent } from './occupancy.js'; import { PaginatedResult } from './query.js'; @@ -31,18 +31,25 @@ interface SendMessageParams { headers?: MessageHeaders; } -export interface DeleteMessageResponse { +/** + * Represents the response for deleting or updating a message. + */ +interface MessageOperationResponse { /** - * The serial of the deletion action. + * The new message version. */ - serial: string; + version: string; /** - * The timestamp of the deletion action. + * The timestamp of the operation. */ - deletedAt: number; + timestamp: number; } +export type UpdateMessageResponse = MessageOperationResponse; + +export type DeleteMessageResponse = MessageOperationResponse; + interface UpdateMessageParams { /** * Message data to update. All fields are updated and if omitted they are @@ -58,7 +65,7 @@ interface UpdateMessageParams { description?: string; /** Metadata of the update action */ - metadata?: MessageActionMetadata; + metadata?: MessageOperationMetadata; } interface DeleteMessageParams { @@ -66,19 +73,7 @@ interface DeleteMessageParams { description?: string; /** Metadata of the delete action */ - metadata?: MessageActionMetadata; -} - -interface UpdateMessageResponse { - /** - * The serial of the update action. - */ - serial: string; - - /** - * The timestamp of when the update occurred. - */ - updatedAt: number; + metadata?: MessageOperationMetadata; } /** @@ -112,12 +107,11 @@ export class ChatApi { message.text, metadata ?? {}, headers ?? {}, - new Date(message.createdAt), - message.latestAction, - message.latestActionSerial, - message.deletedAt ? new Date(message.deletedAt) : undefined, - message.updatedAt ? new Date(message.updatedAt) : undefined, - message.latestActionDetails, + message.action, + message.version, + (message.createdAt as Date | undefined) ? new Date(message.createdAt) : new Date(message.timestamp), + new Date(message.timestamp), + message.operation, ); }; @@ -139,7 +133,7 @@ export class ChatApi { } async deleteMessage(roomId: string, serial: string, params?: DeleteMessageParams): Promise { - const body: { description?: string; metadata?: MessageActionMetadata } = { + const body: { description?: string; metadata?: MessageOperationMetadata } = { description: params?.description, metadata: params?.metadata, }; diff --git a/src/core/index.ts b/src/core/index.ts index 48167fa9..053d7818 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,7 +2,6 @@ * @module chat-js */ -export type { ActionMetadata } from './action-metadata.js'; export { ChatClient } from './chat.js'; export type { ClientOptions } from './config.js'; export type { @@ -27,26 +26,21 @@ export { } from './helpers.js'; export type { LogContext, Logger, LogHandler } from './logger.js'; export { LogLevel } from './logger.js'; +export type { Message, MessageHeaders, MessageMetadata, MessageOperationMetadata, Operation } from './message.js'; export type { - Message, - MessageActionDetails, - MessageActionMetadata, - MessageHeaders, - MessageMetadata, -} from './message.js'; -export type { - ActionDetails, DeleteMessageParams, MessageEventPayload, MessageListener, Messages, MessageSubscriptionResponse, + OperationDetails, QueryOptions, SendMessageParams, UpdateMessageParams, } from './messages.js'; export type { Metadata } from './metadata.js'; export type { Occupancy, OccupancyEvent, OccupancyListener, OccupancySubscriptionResponse } from './occupancy.js'; +export type { OperationMetadata } from './operation-metadata.js'; export type { Presence, PresenceData, diff --git a/src/core/message-parser.ts b/src/core/message-parser.ts index 0b695399..ea6b50bc 100644 --- a/src/core/message-parser.ts +++ b/src/core/message-parser.ts @@ -1,7 +1,7 @@ import * as Ably from 'ably'; import { ChatMessageActions } from './events.js'; -import { DefaultMessage, Message, MessageActionDetails, MessageHeaders, MessageMetadata } from './message.js'; +import { DefaultMessage, Message, MessageHeaders, MessageMetadata, Operation } from './message.js'; interface MessagePayload { data?: { @@ -16,28 +16,15 @@ interface MessagePayload { serial: string; createdAt: number; - version?: string; + version: string; action: Ably.MessageAction; operation?: Ably.Operation; } -interface ChatMessageFields { - serial: string; - clientId: string; - roomId: string; - text: string; - metadata: MessageMetadata; - headers: MessageHeaders; - createdAt: Date; - latestAction: ChatMessageActions; - latestActionSerial: string; - updatedAt?: Date; - deletedAt?: Date; - operation?: MessageActionDetails; -} - +// Parse a realtime message to a chat message export function parseMessage(roomId: string | undefined, inboundMessage: Ably.InboundMessage): Message { const message = inboundMessage as MessagePayload; + if (!roomId) { throw new Ably.ErrorInfo(`received incoming message without roomId`, 50000, 500); } @@ -62,48 +49,40 @@ export function parseMessage(roomId: string | undefined, inboundMessage: Ably.In throw new Ably.ErrorInfo(`received incoming message without serial`, 50000, 500); } - const newMessage: ChatMessageFields = { - serial: message.serial, - clientId: message.clientId, - roomId, - text: message.data.text, - metadata: message.data.metadata ?? {}, - headers: message.extras.headers ?? {}, - createdAt: new Date(message.createdAt), - latestAction: message.action as ChatMessageActions, - latestActionSerial: message.version ?? message.serial, - updatedAt: message.timestamp ? new Date(message.timestamp) : undefined, - deletedAt: message.timestamp ? new Date(message.timestamp) : undefined, - operation: message.operation as MessageActionDetails, - }; + if (!message.version) { + throw new Ably.ErrorInfo(`received incoming message without version`, 50000, 500); + } + + if (!message.createdAt) { + throw new Ably.ErrorInfo(`received incoming message without createdAt`, 50000, 500); + } + + if (!message.timestamp) { + throw new Ably.ErrorInfo(`received incoming message without timestamp`, 50000, 500); + } switch (message.action) { - case ChatMessageActions.MessageCreate: { - break; - } + case ChatMessageActions.MessageCreate: case ChatMessageActions.MessageUpdate: case ChatMessageActions.MessageDelete: { - if (!message.version) { - throw new Ably.ErrorInfo(`received incoming ${message.action} without version`, 50000, 500); - } break; } default: { throw new Ably.ErrorInfo(`received incoming message with unhandled action; ${message.action}`, 50000, 500); } } + return new DefaultMessage( - newMessage.serial, - newMessage.clientId, - newMessage.roomId, - newMessage.text, - newMessage.metadata, - newMessage.headers, - newMessage.createdAt, - newMessage.latestAction, - newMessage.latestActionSerial, - newMessage.latestAction === ChatMessageActions.MessageDelete ? newMessage.deletedAt : undefined, - newMessage.latestAction === ChatMessageActions.MessageUpdate ? newMessage.updatedAt : undefined, - newMessage.operation, + message.serial, + message.clientId, + roomId, + message.data.text, + message.data.metadata ?? {}, + message.extras.headers ?? {}, + message.action as ChatMessageActions, + message.version, + new Date(message.createdAt), + new Date(message.timestamp), + message.operation as Operation, ); } diff --git a/src/core/message.ts b/src/core/message.ts index 4ef31abc..745830d7 100644 --- a/src/core/message.ts +++ b/src/core/message.ts @@ -1,9 +1,9 @@ import { ErrorInfo } from 'ably'; -import { ActionMetadata } from './action-metadata.js'; import { ChatMessageActions } from './events.js'; import { Headers } from './headers.js'; import { Metadata } from './metadata.js'; +import { OperationMetadata } from './operation-metadata.js'; /** * {@link Headers} type for chat messages. @@ -16,14 +16,14 @@ export type MessageHeaders = Headers; export type MessageMetadata = Metadata; /** - * {@link ActionMetadata} type for a chat messages {@link MessageActionDetails}. + * {@link OperationMetadata} type for a chat messages {@link Operation}. */ -export type MessageActionMetadata = ActionMetadata; +export type MessageOperationMetadata = OperationMetadata; /** * Represents the detail of a message deletion or update. */ -export interface MessageActionDetails { +export interface Operation { /** * The optional clientId of the user who performed the update or deletion. */ @@ -35,7 +35,7 @@ export interface MessageActionDetails { /** * The optional metadata associated with the update or deletion. */ - metadata?: MessageActionMetadata; + metadata?: MessageOperationMetadata; } /** @@ -97,73 +97,79 @@ export interface Message { readonly headers: MessageHeaders; /** - * The latest action of the message. This can be used to determine if the message was created, updated, or deleted. + * The action type of the message. This can be used to determine if the message was created, updated, or deleted. */ - readonly latestAction: ChatMessageActions; + readonly action: ChatMessageActions; /** - * A unique identifier for the latest action that updated the message. This is only set for update and deletes. + * A unique identifier for the latest version of this message. */ - readonly latestActionSerial: string; + readonly version: string; /** - * The details of the latest action that updated the message. This is only set for update and delete actions. + * The timestamp at which this version was updated, deleted, or created. */ - readonly latestActionDetails?: MessageActionDetails; + readonly timestamp: Date; + + /** + * The details of the operation that updated the message. This is only set for update and delete actions. It contains + * information about the operation: the clientId of the user who performed the operation, a description, and metadata. + */ + readonly operation?: Operation; /** * Indicates if the message has been updated. */ - readonly isUpdated: boolean; + get isUpdated(): boolean; /** * Indicates if the message has been deleted. */ - readonly isDeleted: boolean; + get isDeleted(): boolean; /** * The clientId of the user who deleted the message. */ - readonly deletedBy?: string; + get deletedBy(): string | undefined; /** * The clientId of the user who updated the message. */ - readonly updatedBy?: string; + get updatedBy(): string | undefined; /** * The timestamp at which the message was deleted. */ - readonly deletedAt?: Date; + get deletedAt(): Date | undefined; /** * The timestamp at which the message was updated. */ - readonly updatedAt?: Date; + get updatedAt(): Date | undefined; /** - * Determines if the action of this message is before the action of the given message. + * Determines if the version of this message is older than the version of the given message. * @param message The message to compare against. - * @returns true if the action of this message is before the given message. - * @throws {@link ErrorInfo} if both message serials do not match, or if {@link latestActionSerial} of either is invalid. + * @returns true if the version of this message is before the given message. + * @throws {@link ErrorInfo} if both message serials do not match. */ - actionBefore(message: Message): boolean; + versionBefore(message: Message): boolean; /** - * Determines if the action of this message is after the action of the given message. + * Determines if the version of this message is newer than the version of the given message. * @param message The message to compare against. - * @returns true if the action of this message is after the given message. - * @throws {@link ErrorInfo} if both message serials do not match, or if {@link latestActionSerial} of either is invalid. + * @returns true if the version of this message is after the given message. + * @throws {@link ErrorInfo} if both message serials do not match. */ - actionAfter(message: Message): boolean; + versionAfter(message: Message): boolean; /** - * Determines if the action of this message is equal to the action of the given message. + * Determines if the version of this message is the same as to the version of the given message. * @param message The message to compare against. - * @returns true if the action of this message is equal to the given message. - * @throws {@link ErrorInfo} if both message serials do not match, or if {@link latestActionSerial} of either is invalid. + * @returns true if the version of this message is equal to the given message. + * @throws {@link ErrorInfo} if both message serials do not match. */ - actionEqual(message: Message): boolean; + versionEqual(message: Message): boolean; /** * Determines if this message was created before the given message. This comparison is based on @@ -187,6 +193,9 @@ export interface Message { /** * Determines if this message is equal to the given message. + * + * Note that this method compares messages based on {@link Message.serial} alone. It returns true if the + * two messages represent different versions of the same message. * @param message The message to compare against. * @returns true if this message is equal to the given message. * @throws {@link ErrorInfo} if serials of either message is invalid. @@ -207,62 +216,65 @@ export class DefaultMessage implements Message { public readonly text: string, public readonly metadata: MessageMetadata, public readonly headers: MessageHeaders, + public readonly action: ChatMessageActions, + public readonly version: string, public readonly createdAt: Date, - public readonly latestAction: ChatMessageActions, - - // the `latestActionSerial` will be set to the current message `serial` for new messages, - // else it will be set to the `updateSerial` corresponding to whatever action - // (update/delete) that was just performed. - public readonly latestActionSerial: string, - public readonly deletedAt?: Date, - public readonly updatedAt?: Date, - public readonly latestActionDetails?: MessageActionDetails, + public readonly timestamp: Date, + public readonly operation?: Operation, ) { // The object is frozen after constructing to enforce readonly at runtime too Object.freeze(this); } get isUpdated(): boolean { - return this.updatedAt !== undefined; + return this.action === ChatMessageActions.MessageUpdate; } get isDeleted(): boolean { - return this.deletedAt !== undefined; + return this.action === ChatMessageActions.MessageDelete; } get updatedBy(): string | undefined { - return this.latestAction === ChatMessageActions.MessageUpdate ? this.latestActionDetails?.clientId : undefined; + return this.isUpdated ? this.operation?.clientId : undefined; } get deletedBy(): string | undefined { - return this.latestAction === ChatMessageActions.MessageDelete ? this.latestActionDetails?.clientId : undefined; + return this.isDeleted ? this.operation?.clientId : undefined; + } + + get updatedAt(): Date | undefined { + return this.isUpdated ? this.timestamp : undefined; + } + + get deletedAt(): Date | undefined { + return this.isDeleted ? this.timestamp : undefined; } - actionBefore(message: Message): boolean { + versionBefore(message: Message): boolean { // Check to ensure the messages are the same before comparing operation order if (!this.equal(message)) { - throw new ErrorInfo('actionBefore(): Cannot compare actions, message serials must be equal', 50000, 500); + throw new ErrorInfo('versionBefore(): Cannot compare versions, message serials must be equal', 50000, 500); } - return this.latestActionSerial < message.latestActionSerial; + return this.version < message.version; } - actionAfter(message: Message): boolean { + versionAfter(message: Message): boolean { // Check to ensure the messages are the same before comparing operation order if (!this.equal(message)) { - throw new ErrorInfo('actionAfter(): Cannot compare actions, message serials must be equal', 50000, 500); + throw new ErrorInfo('versionAfter(): Cannot compare versions, message serials must be equal', 50000, 500); } - return this.latestActionSerial > message.latestActionSerial; + return this.version > message.version; } - actionEqual(message: Message): boolean { + versionEqual(message: Message): boolean { // Check to ensure the messages are the same before comparing operation order if (!this.equal(message)) { - throw new ErrorInfo('actionEqual(): Cannot compare actions, message serials must be equal', 50000, 500); + throw new ErrorInfo('versionEqual(): Cannot compare versions, message serials must be equal', 50000, 500); } - return this.latestActionSerial === message.latestActionSerial; + return this.version === message.version; } before(message: Message): boolean { diff --git a/src/core/messages.ts b/src/core/messages.ts index 7632560e..e0861588 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -14,7 +14,7 @@ import { import { ErrorCodes } from './errors.js'; import { ChatMessageActions, MessageEvents, RealtimeMessageNames } from './events.js'; import { Logger } from './logger.js'; -import { DefaultMessage, Message, MessageActionMetadata, MessageHeaders, MessageMetadata } from './message.js'; +import { DefaultMessage, Message, MessageHeaders, MessageMetadata, MessageOperationMetadata } from './message.js'; import { parseMessage } from './message-parser.js'; import { PaginatedResult } from './query.js'; import { addListenerToChannelWithoutAttach } from './realtime-extensions.js'; @@ -80,7 +80,7 @@ export interface QueryOptions { /** * The parameters supplied to a message action like delete or update. */ -export interface ActionDetails { +export interface OperationDetails { /** * Optional description for the message action. */ @@ -90,14 +90,14 @@ export interface ActionDetails { * Optional metadata that will be added to the action. Defaults to empty. * */ - metadata?: MessageActionMetadata; + metadata?: MessageOperationMetadata; } /** * Parameters for deleting a message. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface DeleteMessageParams extends ActionDetails {} +export interface DeleteMessageParams extends OperationDetails {} /** * Params for sending a text message. Only `text` is mandatory. @@ -268,7 +268,7 @@ export interface Messages extends EmitsDiscontinuities { * @param details Optional details to record about the update action. * @returns A promise of the updated message. */ - update(message: Message, update: UpdateMessageParams, details?: ActionDetails): Promise; + update(message: Message, update: UpdateMessageParams, details?: OperationDetails): Promise; /** * Get the underlying Ably realtime channel used for the messages in this chat room. @@ -496,13 +496,14 @@ export class DefaultMessages text, metadata ?? {}, headers ?? {}, - new Date(response.createdAt), ChatMessageActions.MessageCreate, response.serial, + new Date(response.createdAt), + new Date(response.createdAt), // timestamp is the same as createdAt for new messages ); } - async update(message: Message, update: UpdateMessageParams, details?: ActionDetails): Promise { + async update(message: Message, update: UpdateMessageParams, details?: OperationDetails): Promise { this._logger.trace('Messages.update();', { message, update, details }); const response = await this._chatApi.updateMessage(this._roomId, message.serial, { @@ -510,24 +511,26 @@ export class DefaultMessages message: update, }); - return new DefaultMessage( + const updatedMessage = new DefaultMessage( message.serial, message.clientId, this._roomId, update.text, update.metadata ?? {}, update.headers ?? {}, - message.createdAt, ChatMessageActions.MessageUpdate, - response.serial, - undefined, - response.updatedAt ? new Date(response.updatedAt) : undefined, + response.version, + new Date(message.createdAt), + new Date(response.timestamp), { clientId: this._clientId, description: details?.description, metadata: details?.metadata, }, ); + + this._logger.debug('Messages.update(); message update successfully', { updatedMessage }); + return updatedMessage; } /** @@ -535,25 +538,27 @@ export class DefaultMessages */ async delete(message: Message, params?: DeleteMessageParams): Promise { this._logger.trace('Messages.delete();', { params }); + const response = await this._chatApi.deleteMessage(this._roomId, message.serial, params); + const deletedMessage: Message = new DefaultMessage( message.serial, message.clientId, - message.roomId, + this._roomId, message.text, message.metadata, message.headers, - message.createdAt, ChatMessageActions.MessageDelete, - response.serial, - response.deletedAt ? new Date(response.deletedAt) : undefined, - message.updatedAt, + response.version, + new Date(message.createdAt), + new Date(response.timestamp), { clientId: this._clientId, description: params?.description, metadata: params?.metadata, }, ); + this._logger.debug('Messages.delete(); message deleted successfully', { deletedMessage }); return deletedMessage; } diff --git a/src/core/action-metadata.ts b/src/core/operation-metadata.ts similarity index 69% rename from src/core/action-metadata.ts rename to src/core/operation-metadata.ts index a5306a33..25eb9a2d 100644 --- a/src/core/action-metadata.ts +++ b/src/core/operation-metadata.ts @@ -1,5 +1,5 @@ /** - * The type for metadata contained in the latestActionDetails field of a chat message. + * The type for metadata contained in the operations field of a chat message. * This is a key-value pair where the key is a string, and the value is a string, it represents the metadata supplied * to a message update or deletion request. * @@ -7,4 +7,4 @@ * validation. When reading the metadata, treat it like user input. * */ -export type ActionMetadata = Record; +export type OperationMetadata = Record; diff --git a/src/react/hooks/use-messages.ts b/src/react/hooks/use-messages.ts index 7412f67b..4925bb59 100644 --- a/src/react/hooks/use-messages.ts +++ b/src/react/hooks/use-messages.ts @@ -1,10 +1,10 @@ import { - ActionDetails, DeleteMessageParams, Message, MessageListener, Messages, MessageSubscriptionResponse, + OperationDetails, QueryOptions, SendMessageParams, UpdateMessageParams, @@ -117,7 +117,7 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => [context], ); const update = useCallback( - (message: Message, update: UpdateMessageParams, details?: ActionDetails) => + (message: Message, update: UpdateMessageParams, details?: OperationDetails) => context.room.then((room) => room.messages.update(message, update, details)), [context], ); diff --git a/test/core/chat.integration.test.ts b/test/core/chat.integration.test.ts index 8ad66955..146d5f1c 100644 --- a/test/core/chat.integration.test.ts +++ b/test/core/chat.integration.test.ts @@ -60,6 +60,7 @@ describe('Chat', () => { expect(occupancy).toEqual(expect.objectContaining({ connections: 1, presenceMembers: 0 })); // Request history, and expect it to succeed + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for cassandra const history = await room.messages.get({ limit: 1 }); expect(history.items).toEqual( expect.arrayContaining([expect.objectContaining({ text: 'my message', clientId: chat.clientId })]), @@ -79,6 +80,7 @@ describe('Chat', () => { expect(occupancy).toEqual(expect.objectContaining({ connections: 1, presenceMembers: 0 })); // Request history, and expect it to succeed + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history = await room.messages.get({ limit: 1 }); expect(history.items).toEqual( expect.arrayContaining([expect.objectContaining({ text: 'my message', clientId: chat.clientId })]), diff --git a/test/core/helpers.test.ts b/test/core/helpers.test.ts index 647132ff..72fba9f4 100644 --- a/test/core/helpers.test.ts +++ b/test/core/helpers.test.ts @@ -13,7 +13,7 @@ const TEST_ENVELOPED_MESSAGE = { timestamp: 1719948956834, encoding: 'json', action: 1, - serial: '01719948956834-000@108TeGZDQBderu97202638', + version: '01719948956834-000@108TeGZDQBderu97202638', extras: { headers: {}, }, @@ -45,9 +45,10 @@ describe('helpers', () => { 'I have the high ground now', {}, {}, - new Date(1719948956834), ChatMessageActions.MessageCreate, '01719948956834-000@108TeGZDQBderu97202638', + new Date(1719948956834), + new Date(1719948956834), ), ); }); diff --git a/test/core/message-parser.test.ts b/test/core/message-parser.test.ts index 7780e4f9..54c0eb61 100644 --- a/test/core/message-parser.test.ts +++ b/test/core/message-parser.test.ts @@ -76,52 +76,38 @@ describe('parseMessage', () => { createdAt: 1728402074206, extras: {}, action: ChatMessageActions.MessageCreate, + version: '01728402074206-000@cbfkKvEYgBhDaZ38195418:0', }, expectedError: 'received incoming message without serial', }, { - description: 'message.action is unhandled', + description: 'message.version is undefined', roomId: 'room1', message: { - data: { text: 'hello' }, - clientId: 'client1', - timestamp: 1728402074206, - createdAt: 1728402074206, - extras: {}, serial: '01728402074206-000@cbfkKvEYgBhDaZ38195418:0', - action: 'unhandled.action', - }, - expectedError: 'received incoming message with unhandled action; unhandled.action', - }, - { - description: 'message.version is undefined for update', - roomId: 'room1', - message: { data: { text: 'hello' }, clientId: 'client1', + timestamp: 1728402074206, createdAt: 1728402074206, extras: {}, - serial: '01728402074206-000@cbfkKvEYgBhDaZ38195418:0', - action: ChatMessageActions.MessageUpdate, - timestamp: 1234567890, - version: undefined, + action: ChatMessageActions.MessageCreate, }, - expectedError: 'received incoming message.update without version', + expectedError: 'received incoming message without version', }, { - description: 'message.version is undefined for deletion', + description: 'message.action is unhandled', roomId: 'room1', message: { data: { text: 'hello' }, clientId: 'client1', + timestamp: 1728402074206, createdAt: 1728402074206, extras: {}, serial: '01728402074206-000@cbfkKvEYgBhDaZ38195418:0', - timestamp: 1234567890, - version: undefined, - action: ChatMessageActions.MessageDelete, + version: '01728402074206-000@cbfkKvEYgBhDaZ38195418:0', + action: 'unhandled.action', }, - expectedError: 'received incoming message.delete without version', + expectedError: 'received incoming message with unhandled action; unhandled.action', }, ])('should throw an error ', ({ description, roomId, message, expectedError }) => { it(`should throw an error if ${description}`, () => { @@ -167,8 +153,8 @@ describe('parseMessage', () => { expect(result.updatedAt).toBeUndefined(); expect(result.updatedBy).toBeUndefined(); - expect(result.latestAction).toEqual(ChatMessageActions.MessageCreate); - expect(result.latestActionDetails).toBeUndefined(); + expect(result.action).toEqual(ChatMessageActions.MessageCreate); + expect(result.operation).toBeUndefined(); }); it('should return a DefaultMessage instance for a valid updated message', () => { @@ -199,9 +185,9 @@ describe('parseMessage', () => { expect(result.headers).toEqual({ headerKey: 'headerValue' }); expect(result.updatedAt).toEqual(new Date(1728402074206)); expect(result.updatedBy).toBe('client2'); - expect(result.latestAction).toEqual(ChatMessageActions.MessageUpdate); - expect(result.latestActionSerial).toEqual('01728402074206-000@cbfkKvEYgBhDaZ38195418:0'); - expect(result.latestActionDetails).toEqual({ + expect(result.action).toEqual(ChatMessageActions.MessageUpdate); + expect(result.version).toEqual('01728402074206-000@cbfkKvEYgBhDaZ38195418:0'); + expect(result.operation).toEqual({ clientId: 'client2', description: 'update message', metadata: { 'custom-update': 'some flag' }, @@ -244,8 +230,8 @@ describe('parseMessage', () => { expect(result.headers).toEqual({ headerKey: 'headerValue' }); expect(result.deletedAt).toEqual(new Date(1728402074206)); expect(result.deletedBy).toBe('client2'); - expect(result.latestActionSerial).toEqual('01728402074206-000@cbfkKvEYgBhDaZ38195418:0'); - expect(result.latestActionDetails).toEqual({ + expect(result.version).toEqual('01728402074206-000@cbfkKvEYgBhDaZ38195418:0'); + expect(result.operation).toEqual({ clientId: 'client2', description: 'delete message', metadata: { 'custom-warning': 'this is a warning' }, diff --git a/test/core/message.test.ts b/test/core/message.test.ts index c33951a0..818be513 100644 --- a/test/core/message.test.ts +++ b/test/core/message.test.ts @@ -5,24 +5,6 @@ import { ChatMessageActions } from '../../src/core/events.ts'; import { DefaultMessage } from '../../src/core/message.ts'; describe('ChatMessage', () => { - it('should correctly parse createdAt from serial', () => { - const serial = '01672531200000-123@abcdefghij'; - - const message = new DefaultMessage( - serial, - 'clientId', - 'roomId', - 'hello there', - {}, - {}, - new Date(1672531200000), - ChatMessageActions.MessageCreate, - serial, - ); - - expect(message.createdAt).toEqual(new Date(1672531200000)); - }); - it('is the same as another message', () => { const firstSerial = '01672531200000-123@abcdefghij'; const secondSerial = '01672531200000-123@abcdefghij'; @@ -34,9 +16,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, firstSerial, + new Date(1672531200000), + new Date(1672531200000), ); const secondMessage = new DefaultMessage( secondSerial, @@ -45,9 +28,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, secondSerial, + new Date(1672531200000), + new Date(1672531200000), ); expect(firstMessage.equal(secondMessage)).toBe(true); @@ -64,9 +48,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, firstSerial, + new Date(1672531200000), + new Date(1672531200000), ); const secondMessage = new DefaultMessage( secondSerial, @@ -75,9 +60,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, secondSerial, + new Date(1672531200000), + new Date(1672531200000), ); expect(firstMessage.equal(secondMessage)).toBe(false); @@ -94,9 +80,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, firstSerial, + new Date(1672531200000), + new Date(1672531200000), ); const secondMessage = new DefaultMessage( secondSerial, @@ -105,9 +92,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, secondSerial, + new Date(1672531200000), + new Date(1672531200000), ); expect(firstMessage.before(secondMessage)).toBe(true); @@ -123,9 +111,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, firstSerial, + new Date(1672531200000), + new Date(1672531200000), ); const secondMessage = new DefaultMessage( secondSerial, @@ -134,15 +123,16 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageCreate, secondSerial, + new Date(1672531200000), + new Date(1672531200000), ); expect(firstMessage.after(secondMessage)).toBe(true); }); - describe('message actions', () => { + describe('message versions', () => { it('is deleted', () => { const firstSerial = '01672531200000-124@abcdefghij:0'; const firstMessage = new DefaultMessage( @@ -152,11 +142,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageDelete, - '01672531200000-123@abcdefghij:0', + '01672531300000-123@abcdefghij:0', + new Date(1672531200000), new Date(1672531300000), - undefined, { clientId: 'clientId2', }, @@ -174,10 +163,9 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageUpdate, '01672531200000-123@abcdefghij:0', - undefined, + new Date(1672531200000), new Date(1672531300000), { clientId: 'clientId2' }, ); @@ -185,12 +173,12 @@ describe('ChatMessage', () => { expect(firstMessage.updatedBy).toBe('clientId2'); }); - it(`throws an error when trying to compare actions belonging to different origin messages`, () => { + it(`should throw an error when trying to compare versions belonging to different origin messages`, () => { const firstSerial = '01672531200000-124@abcdefghij'; const secondSerial = '01672531200000-123@abcdefghij'; - const firstActionSerial = '01672531200000-123@abcdefghij:0'; - const secondActionSerial = '01672531200000-123@abcdefghij:0'; + const firstVersion = '01672531200000-123@abcdefghij:0'; + const secondVersion = '01672531200000-123@abcdefghij:0'; const firstMessage = new DefaultMessage( firstSerial, @@ -199,9 +187,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageUpdate, - firstActionSerial, + firstVersion, + new Date(1672531200000), + new Date(1672531200000), ); const secondMessage = new DefaultMessage( secondSerial, @@ -210,63 +199,74 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageUpdate, - secondActionSerial, + secondVersion, + new Date(1672531200000), + new Date(1672531200000), ); - expect(() => firstMessage.actionEqual(secondMessage)).toThrowErrorInfo({ + expect(() => firstMessage.versionEqual(secondMessage)).toThrowErrorInfo({ + code: 50000, + message: 'versionEqual(): Cannot compare versions, message serials must be equal', + }); + + expect(() => firstMessage.versionBefore(secondMessage)).toThrowErrorInfo({ code: 50000, - message: 'actionEqual(): Cannot compare actions, message serials must be equal', + message: 'versionBefore(): Cannot compare versions, message serials must be equal', + }); + + expect(() => firstMessage.versionAfter(secondMessage)).toThrowErrorInfo({ + code: 50000, + message: 'versionAfter(): Cannot compare versions, message serials must be equal', }); }); describe.each([ [ - 'returns true when this message action is the same as another', + 'returns true when this message version is the same as another', { - firstActionSerial: '01672531200000-123@abcdefghij:0', - secondActionSerial: '01672531200000-123@abcdefghij:0', - action: 'actionEqual', + firstVersion: '01672531200000-123@abcdefghij:0', + secondVersion: '01672531200000-123@abcdefghij:0', + action: 'versionEqual', expected: (firstMessage: Message, secondMessage: Message) => { - expect(firstMessage.actionEqual(secondMessage)).toBe(true); + expect(firstMessage.versionEqual(secondMessage)).toBe(true); }, }, ], [ - 'returns false when this message action is not same as another message action', + 'returns false when this message version is not same as another message version', { - firstActionSerial: '01672531200000-123@abcdefghij:0', - secondActionSerial: '01672531200000-124@abcdefghij:0', - action: 'actionEqual', + firstVersion: '01672531200000-123@abcdefghij:0', + secondVersion: '01672531200000-124@abcdefghij:0', + action: 'versionEqual', expected: (firstMessage: Message, secondMessage: Message) => { - expect(firstMessage.actionEqual(secondMessage)).toBe(false); + expect(firstMessage.versionEqual(secondMessage)).toBe(false); }, }, ], [ - 'returns true when this message action is before another message action', + 'returns true when this message version is before another message version', { - firstActionSerial: '01672531200000-123@abcdefghij:0', - secondActionSerial: '01672531200000-124@abcdefghij:0', - action: 'actionBefore', + firstVersion: '01672531200000-123@abcdefghij:0', + secondVersion: '01672531200000-124@abcdefghij:0', + action: 'versionBefore', expected: (firstMessage: Message, secondMessage: Message) => { - expect(firstMessage.actionBefore(secondMessage)).toBe(true); + expect(firstMessage.versionBefore(secondMessage)).toBe(true); }, }, ], [ - 'returns true when this message action is after another message action', + 'returns true when this message version is after another message version', { - firstActionSerial: '01672531200000-124@abcdefghij:0', - secondActionSerial: '01672531200000-123@abcdefghij:0', - action: 'actionAfter', + firstVersion: '01672531200000-124@abcdefghij:0', + secondVersion: '01672531200000-123@abcdefghij:0', + action: 'versionAfter', expected: (firstMessage: Message, secondMessage: Message) => { - expect(firstMessage.actionAfter(secondMessage)).toBe(true); + expect(firstMessage.versionAfter(secondMessage)).toBe(true); }, }, ], - ])('compare message action serials', (name, { firstActionSerial, secondActionSerial, expected }) => { + ])('compare message versions', (name, { firstVersion, secondVersion, expected }) => { it(name, () => { const messageSerial = '01672531200000-123@abcdefghij'; const firstMessage = new DefaultMessage( @@ -276,9 +276,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageUpdate, - firstActionSerial, + firstVersion, + new Date(1672531200000), + new Date(1672531200001), ); const secondMessage = new DefaultMessage( messageSerial, @@ -287,9 +288,10 @@ describe('ChatMessage', () => { 'hello there', {}, {}, - new Date(1672531200000), ChatMessageActions.MessageUpdate, - secondActionSerial, + secondVersion, + new Date(1672531200000), + new Date(1672531200001), ); expected(firstMessage, secondMessage); }); diff --git a/test/core/messages.integration.test.ts b/test/core/messages.integration.test.ts index 4a786ba6..538f7330 100644 --- a/test/core/messages.integration.test.ts +++ b/test/core/messages.integration.test.ts @@ -1,10 +1,9 @@ -import { ChatMessageActions } from '@ably/chat'; +import { ChatMessageActions, Message } from '@ably/chat'; import * as Ably from 'ably'; import { beforeEach, describe, expect, it } from 'vitest'; import { ChatClient } from '../../src/core/chat.ts'; import { MessageEvents } from '../../src/core/events.ts'; -import { Message } from '../../src/core/message.ts'; import { RealtimeChannelWithOptions } from '../../src/core/realtime-extensions.ts'; import { RoomOptionsDefaults } from '../../src/core/room-options.ts'; import { RoomStatus } from '../../src/core/room-status.ts'; @@ -33,7 +32,7 @@ const waitForMessages = (messages: Message[], expectedCount: number) => { }); }; -describe('messages integration', () => { +describe('messages integration', { timeout: 10000 }, () => { beforeEach((context) => { context.chat = newChatClient(); }); @@ -116,6 +115,16 @@ describe('messages integration', () => { metadata: { key: 'value' }, }); + // deleted message should look like a deleted message and convenience getters should work + expect(deletedMessage1.version).not.toEqual(deletedMessage1.serial); + expect(deletedMessage1.version).not.toEqual(message1.version); + expect(deletedMessage1.action).toEqual(ChatMessageActions.MessageDelete); + expect(deletedMessage1.deletedAt).toBeDefined(); + expect(deletedMessage1.deletedAt).toEqual(deletedMessage1.timestamp); + expect(deletedMessage1.deletedBy).toBeDefined(); + expect(deletedMessage1.operation?.clientId).toBeDefined(); + expect(deletedMessage1.deletedBy).toEqual(deletedMessage1.operation?.clientId); + // Wait up to 5 seconds for the promises to resolve await waitForMessages(messages, 1); await waitForMessages(deletions, 1); @@ -128,18 +137,20 @@ describe('messages integration', () => { serial: message1.serial, }), ]); + // Check that the deletion was received expect(deletions).toEqual([ expect.objectContaining({ text: 'Hello there!', clientId: chat.clientId, serial: deletedMessage1.serial, - deletedAt: deletedMessage1.deletedAt, - deletedBy: chat.clientId, - latestAction: ChatMessageActions.MessageDelete, - latestActionSerial: deletedMessage1.latestActionSerial, + timestamp: deletedMessage1.deletedAt, + action: ChatMessageActions.MessageDelete, + version: deletedMessage1.version, }), ]); + + expect(deletions[0]?.operation?.clientId).toEqual(chat.clientId); }); it('should be able to update and receive update messages', async (context) => { @@ -200,8 +211,8 @@ describe('messages integration', () => { serial: message1.serial, updatedAt: updated1.updatedAt, updatedBy: chat.clientId, - latestAction: ChatMessageActions.MessageUpdate, - latestActionSerial: updated1.latestActionSerial, + action: ChatMessageActions.MessageUpdate, + version: updated1.version, createdAt: message1.createdAt, }), ]); @@ -218,6 +229,7 @@ describe('messages integration', () => { const message3 = await room.messages.send({ text: 'You underestimate my power!' }); // Do a history request to get all 3 messages + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history = await room.messages.get({ limit: 3, direction: 'forwards' }); expect(history.items).toEqual([ @@ -242,8 +254,7 @@ describe('messages integration', () => { expect(history.hasNext()).toBe(false); }); - // At the moment, the history API does not materialize deleted messages in the history. - it.skip('should be able to retrieve chat deletion in history', async (context) => { + it('should be able to retrieve chat deletion in history', async (context) => { const { chat } = context; const room = await getRandomRoom(chat); @@ -255,6 +266,7 @@ describe('messages integration', () => { const deletedMessage1 = await room.messages.delete(message1, { description: 'Deleted message' }); // Do a history request to get the deleted message + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history = await room.messages.get({ limit: 3, direction: 'forwards' }); expect(history.items).toEqual([ @@ -262,17 +274,24 @@ describe('messages integration', () => { text: 'Hello there!', clientId: chat.clientId, serial: deletedMessage1.serial, - deletedAt: deletedMessage1.deletedAt, - deletedBy: chat.clientId, + createdAt: message1.timestamp, + timestamp: deletedMessage1.deletedAt, + action: ChatMessageActions.MessageDelete, }), ]); + // test shorthand getters + expect(history.items[0]?.isUpdated).toBe(false); + expect(history.items[0]?.isDeleted).toBe(true); + expect(history.items[0]?.deletedAt).toEqual(deletedMessage1.deletedAt); + // todo: uncomment when operation is returned correctly in history endpoint + // expect(history.items[0]?.deletedBy).toEqual(deletedMessage1.deletedBy); + // We shouldn't have a "next" link in the response expect(history.hasNext()).toBe(false); }); - // At the moment, the history API does not materialize updated messages in the history. - it.skip('should be able to retrieve chat updated message in history', async (context) => { + it('should be able to retrieve updated chat message in history', async (context) => { const { chat } = context; const room = await getRandomRoom(chat); @@ -288,6 +307,7 @@ describe('messages integration', () => { ); // Do a history request to get the update message + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history = await room.messages.get({ limit: 3, direction: 'forwards' }); expect(history.items).toEqual([ @@ -295,13 +315,19 @@ describe('messages integration', () => { text: 'Hello test!', clientId: chat.clientId, serial: updatedMessage1.serial, - updatedAt: updatedMessage1.updatedAt, - updatedBy: chat.clientId, createdAt: message1.createdAt, - createdBy: message1.clientId, + timestamp: updatedMessage1.updatedAt, + action: ChatMessageActions.MessageUpdate, }), ]); + // test shorthand getters + expect(history.items[0]?.isUpdated).toBe(true); + expect(history.items[0]?.isDeleted).toBe(false); + expect(history.items[0]?.updatedAt).toEqual(updatedMessage1.updatedAt); + // todo: uncomment when operation is returned correctly in history endpoint + // expect(history.items[0]?.updatedBy).toEqual(updatedMessage1.updatedBy); + // We shouldn't have a "next" link in the response expect(history.hasNext()).toBe(false); }); @@ -318,6 +344,7 @@ describe('messages integration', () => { const message4 = await room.messages.send({ text: "Don't try it!" }); // Do a history request to get the first 3 messages + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history1 = await room.messages.get({ limit: 3, direction: 'forwards' }); expect(history1.items).toEqual([ @@ -423,6 +450,7 @@ describe('messages integration', () => { const message4 = await room.messages.send({ text: "Don't try it!" }); // Do a history request to get the first 3 messages + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history1 = await room.messages.get({ limit: 3, direction: 'forwards' }); expect(history1.items).toEqual([ @@ -473,6 +501,7 @@ describe('messages integration', () => { const message4 = await room.messages.send({ text: "Don't try it!" }); // Do a history request to get the last 3 messages + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history1 = await room.messages.get({ limit: 3, direction: 'backwards' }); expect(history1.items).toEqual([ @@ -561,6 +590,7 @@ describe('messages integration', () => { expect.objectContaining(expectedMessages[1]), ]); + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const history = await room.messages.get({ limit: 2, direction: 'forwards' }); expect(history.items.length).toEqual(2); @@ -588,6 +618,7 @@ describe('messages integration', () => { await room.attach(); // Do a history request to get the messages before up + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const historyPreSubscription1 = await getPreviousMessages({ limit: 50 }); // Check the items in the history @@ -609,6 +640,7 @@ describe('messages integration', () => { await room.messages.send({ text: "Don't try it!" }); // Try and get history again + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const historyPreSubscription2 = await getPreviousMessages({ limit: 50 }); // It should not contain the new messages since we should be getting messages based on initial attach serial @@ -625,6 +657,7 @@ describe('messages integration', () => { }), ]); }); + it('should be able to get history for listener with latest message serial', async (context) => { const { chat } = context; @@ -649,6 +682,7 @@ describe('messages integration', () => { const { getPreviousMessages: getPreviousMessagesListener2 } = room.messages.subscribe(() => {}); // Check we see the latest messages + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const historyPreSubscription2 = await getPreviousMessagesListener2({ limit: 50 }); // Should have the latest messages @@ -682,6 +716,8 @@ describe('messages integration', () => { const { getPreviousMessages: getPreviousMessages2 } = room.messages.subscribe(() => {}); const { getPreviousMessages: getPreviousMessages3 } = room.messages.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future + // Do a history request to get the messages before up const historyPreSubscription1 = await getPreviousMessages({ limit: 50 }); const historyPreSubscription2 = await getPreviousMessages2({ limit: 50 }); diff --git a/test/core/messages.test.ts b/test/core/messages.test.ts index e28e7073..0b85ec3b 100644 --- a/test/core/messages.test.ts +++ b/test/core/messages.test.ts @@ -89,7 +89,7 @@ describe('Messages', () => { it('should be able to delete a message and get it back from response', async (context) => { const { chatApi } = context; const sendTimestamp = Date.now(); - const sendSerial = 'abcdefghij@' + String(sendTimestamp) + '-123'; + const sendSerial = '01672531200001-123@abcdefghij:0'; vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ serial: sendSerial, createdAt: sendTimestamp, @@ -97,8 +97,8 @@ describe('Messages', () => { const deleteTimestamp = Date.now(); vi.spyOn(chatApi, 'deleteMessage').mockResolvedValue({ - serial: '01672531200000-123@abcdefghij:0', - deletedAt: deleteTimestamp, + version: '01672531200001-123@abcdefghij:0', + timestamp: deleteTimestamp, }); const message1 = await context.room.messages.send({ text: 'hello there' }); @@ -109,12 +109,13 @@ describe('Messages', () => { serial: sendSerial, text: 'hello there', clientId: 'clientId', - deletedAt: new Date(deleteTimestamp), - deletedBy: 'clientId', + timestamp: new Date(deleteTimestamp), createdAt: new Date(sendTimestamp), roomId: context.room.roomId, }), ); + + expect(deleteMessage1.operation).toEqual(expect.objectContaining({ clientId: 'clientId' })); }); }); @@ -183,6 +184,7 @@ describe('Messages', () => { extras: {}, timestamp: publishTimestamp, createdAt: publishTimestamp, + operation: { clientId: 'yoda' }, }); context.emulateBackendPublish({ clientId: 'yoda', @@ -196,6 +198,7 @@ describe('Messages', () => { extras: {}, timestamp: publishTimestamp, createdAt: publishTimestamp, + operation: { clientId: 'yoda' }, }); context.emulateBackendPublish({ clientId: 'yoda', @@ -203,6 +206,7 @@ describe('Messages', () => { data: { text: 'may the fourth be with you', }, + version: '01672531200000-123@abcdefghij', serial: '01672531200000-123@abcdefghij', action: ChatMessageActions.MessageCreate, extras: {}, @@ -246,9 +250,11 @@ describe('Messages', () => { text: 'may the fourth be with you', }, serial: '01672531200000-123@abcdefghij', + version: '01672531200000-123@abcdefghij', action: ChatMessageActions.MessageCreate, extras: {}, timestamp: publishTimestamp, + createdAt: publishTimestamp, }); context.emulateBackendPublish({ clientId: 'yoda', @@ -259,7 +265,8 @@ describe('Messages', () => { serial: '01672531200000-123@abcdefghij', action: ChatMessageActions.MessageUpdate, extras: {}, - timestamp: publishTimestamp, + timestamp: updateTimestamp, + createdAt: publishTimestamp, version: '01672531200000-123@abcdefghij:0', operation: { clientId: 'yoda', @@ -274,7 +281,7 @@ describe('Messages', () => { serial: '01672531200000-123@abcdefghij', action: ChatMessageActions.MessageDelete, extras: {}, - timestamp: publishTimestamp, + timestamp: deletionTimestamp, createdAt: publishTimestamp, version: '01672531200000-123@abcdefghij:0', operation: { @@ -282,57 +289,66 @@ describe('Messages', () => { }, }); + expect(receivedMessages).toHaveLength(1); + expect(receivedDeletions).toHaveLength(1); + expect(receivedUpdates).toHaveLength(1); + expect(receivedMessages[0]?.clientId).toEqual('yoda'); + expect(receivedDeletions[0]?.clientId).toEqual('yoda'); + expect(receivedUpdates[0]?.clientId).toEqual('yoda'); + unsubscribe(); - // try to send and delete a new message + // send, update and delete again when unsubscribed publishTimestamp = Date.now(); updateTimestamp = Date.now() + 500; deletionTimestamp = Date.now() + 1000; context.emulateBackendPublish({ - clientId: 'yoda2', + clientId: 'yoda', name: 'chat.message', data: { text: 'may the fourth be with you', }, - serial: '01672531200000-123@abcdefghij', + serial: '01672535500000-123@abcdefghij', + version: '01672535500000-123@abcdefghij', action: ChatMessageActions.MessageCreate, extras: {}, + timestamp: publishTimestamp, createdAt: publishTimestamp, }); context.emulateBackendPublish({ - clientId: 'yoda2', + clientId: 'yoda', name: 'chat.message', data: { text: 'I have the high ground now', }, - serial: '01672531200000-123@abcdefghij', + serial: '01672535500000-123@abcdefghij', action: ChatMessageActions.MessageUpdate, extras: {}, - version: '01672531200000-123@abcdefghij:0', + timestamp: updateTimestamp, + createdAt: publishTimestamp, + version: '01672535600000-123@abcdefghij:0', operation: { clientId: 'yoda', }, - createdAt: publishTimestamp, - timestamp: updateTimestamp, }); context.emulateBackendPublish({ - clientId: 'yoda2', + clientId: 'yoda', name: 'chat.message', data: { - text: 'may the fourth be with you', + text: 'I have the high ground now', }, - serial: '01672531200000-123@abcdefghij', + serial: '01672535500000-123@abcdefghij', action: ChatMessageActions.MessageDelete, extras: {}, - createdAt: publishTimestamp, timestamp: deletionTimestamp, - version: '01672531200000-123@abcdefghij:0', + createdAt: publishTimestamp, + version: '01672535700000-123@abcdefghij:0', operation: { - clientId: 'yoda2', + clientId: 'yoda', }, }); - // We should have only received one message and one deletion + // We should not have received anything new expect(receivedMessages).toHaveLength(1); expect(receivedDeletions).toHaveLength(1); expect(receivedUpdates).toHaveLength(1); @@ -390,6 +406,7 @@ describe('Messages', () => { const { unsubscribe } = room.messages.subscribe(listener); const { unsubscribe: unsubscribe2 } = room.messages.subscribe(listener2); + let publishTimestamp = Date.now(); let updateTimestamp = Date.now() + 500; let deletionTimestamp = Date.now() + 1000; @@ -400,6 +417,7 @@ describe('Messages', () => { text: 'may the fourth be with you', }, serial: '01672531200000-123@abcdefghij', + version: '01672531200000-123@abcdefghij', action: ChatMessageActions.MessageCreate, extras: {}, timestamp: publishTimestamp, @@ -438,6 +456,22 @@ describe('Messages', () => { }, }); + // We should have received the message above and the update and delete + expect(receivedMessages).toHaveLength(1); + expect(receivedMessages[0]?.clientId).toEqual('yoda'); + expect(receivedMessages2).toHaveLength(1); + expect(receivedMessages2[0]?.clientId).toEqual('yoda'); + + expect(receivedDeletions).toHaveLength(1); + expect(receivedDeletions[0]?.clientId).toEqual('yoda'); + expect(receivedDeletions2).toHaveLength(1); + expect(receivedDeletions2[0]?.clientId).toEqual('yoda'); + + expect(receivedUpdates).toHaveLength(1); + expect(receivedUpdates[0]?.clientId).toEqual('yoda'); + expect(receivedUpdates2).toHaveLength(1); + expect(receivedUpdates2[0]?.clientId).toEqual('yoda'); + room.messages.unsubscribeAll(); publishTimestamp = Date.now(); @@ -450,6 +484,7 @@ describe('Messages', () => { text: 'may the fourth be with you', }, serial: '01672531200000-123@abcdefghij', + version: '01672531200000-123@abcdefghij', action: ChatMessageActions.MessageCreate, extras: {}, createdAt: publishTimestamp, @@ -488,10 +523,9 @@ describe('Messages', () => { }, }); - // We should have only received one message + // We should not have received anything new - do same assertions again expect(receivedMessages).toHaveLength(1); expect(receivedMessages[0]?.clientId).toEqual('yoda'); - expect(receivedMessages2).toHaveLength(1); expect(receivedMessages2[0]?.clientId).toEqual('yoda'); diff --git a/test/helper/expectations.ts b/test/helper/expectations.ts index 416aa944..b83d985e 100644 --- a/test/helper/expectations.ts +++ b/test/helper/expectations.ts @@ -1,15 +1,12 @@ import * as Ably from 'ably'; import { expect } from 'vitest'; -const extractCommonKeys = (received: unknown, expected: unknown): Set => { - const receivedKeys = new Set(Object.keys(received as Ably.ErrorInfo)); - const expectedKeys = new Set(Object.keys(expected as Ably.ErrorInfo)); - - return new Set([...expectedKeys].filter((key) => receivedKeys.has(key))); +const extractExpectedKeys = (expected: unknown): Set => { + return new Set(Object.keys(expected as Ably.ErrorInfo)); }; const actualErrorInfo = (received: unknown, expected: unknown): Record => { - const commonKeys = extractCommonKeys(received, expected); + const commonKeys = extractExpectedKeys(expected); const returnVal = Object.fromEntries( [...commonKeys].map((key) => [key, (received as Ably.ErrorInfo)[key as keyof Ably.ErrorInfo]]), @@ -25,14 +22,6 @@ const actualErrorInfo = (received: unknown, expected: unknown): Record => { - const commonKeys = extractCommonKeys(received, expected); - - return Object.fromEntries( - [...commonKeys].map((key) => [key, (expected as Ably.ErrorInfo)[key as keyof Ably.ErrorInfo]]), - ); -}; - export interface ErrorInfoCompareType { code?: number; statusCode?: number; @@ -51,7 +40,7 @@ const toBeErrorInfo = (received: unknown, expected: ErrorInfoCompareType): Check if (!(received instanceof Ably.ErrorInfo)) { return { pass: false, - message: () => `Expected ErrorInfo`, + message: () => `Expected ErrorInfo, found ${typeof received}`, expected: expected, actual: received, }; @@ -67,7 +56,7 @@ const toBeErrorInfo = (received: unknown, expected: ErrorInfoCompareType): Check message: () => { return `Expected matching ErrorInfo`; }, - expected: expectedErrorInfo(received, expected), + expected: expected, actual: actualErrorInfo(received, expected), }; }; diff --git a/test/react/hooks/use-messages.integration.test.tsx b/test/react/hooks/use-messages.integration.test.tsx index e4fe5d37..381540dd 100644 --- a/test/react/hooks/use-messages.integration.test.tsx +++ b/test/react/hooks/use-messages.integration.test.tsx @@ -199,9 +199,9 @@ describe('useMessages', () => { expect(update?.updatedBy).toBe(chatClientOne.clientId); expect(update?.text).toBe('hello universe'); expect(update?.metadata).toEqual({ icon: 'universe' }); - expect(update?.latestAction).toBe(ChatMessageActions.MessageUpdate); - expect(update?.latestActionDetails?.description).toBe('make it better'); - expect(update?.latestActionDetails?.metadata).toEqual({ something: 'else' }); + expect(update?.action).toBe(ChatMessageActions.MessageUpdate); + expect(update?.operation?.description).toBe('make it better'); + expect(update?.operation?.metadata).toEqual({ something: 'else' }); }, 10000); it('should receive messages on a subscribed listener', async () => { @@ -316,6 +316,7 @@ describe('useMessages', () => { if (!getPreviousMessagesRoomOne) { expect.fail('getPreviousMessages was not defined'); } + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const results = await getPreviousMessagesRoomOne({ limit: 30 }); expect(results.items.length).toBe(3); @@ -387,6 +388,7 @@ describe('useMessages', () => { if (!getPreviousMessages) { expect.fail('getPreviousMessages was not defined'); } + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const results = await getPreviousMessages({ limit: 3 }); // Check we get the expected messages @@ -431,6 +433,7 @@ describe('useMessages', () => { ); // Check we get the expected messages + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const results2 = await getPreviousMessages({ limit: 3 }); expect(results2.items.length).toBe(3); const messageTexts2 = results2.items.map((item) => item.text); @@ -450,6 +453,7 @@ describe('useMessages', () => { ); // Do a get previous messages call, we should still get the same messages + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const results3 = await getPreviousMessages({ limit: 3 }); expect(results3.items.length).toBe(3); const messageTexts3 = results3.items.map((item) => item.text); @@ -521,6 +525,7 @@ describe('useMessages', () => { if (!getPreviousMessages) { expect.fail('getPreviousMessages was not defined'); } + await new Promise((resolve) => setTimeout(resolve, 3000)); // wait for persistence - this will not be necessary in the future const results = await getPreviousMessages({ limit: 3 }); // Check we get the expected messages @@ -540,8 +545,8 @@ describe('useMessages', () => { await room.messages.send({ text: 'Tis but a scratch' }); await room.messages.send({ text: 'Time is an illusion. Lunchtime doubly so.' }); - // Wait 2 seconds to make sure all messages are received - await new Promise((resolve) => setTimeout(resolve, 2000)); + // Wait 3 seconds to make sure all messages are received + await new Promise((resolve) => setTimeout(resolve, 3000)); const results2 = await getPreviousMessages({ limit: 3 }); diff --git a/test/react/hooks/use-messages.test.tsx b/test/react/hooks/use-messages.test.tsx index 78b4e2dc..fd73e615 100644 --- a/test/react/hooks/use-messages.test.tsx +++ b/test/react/hooks/use-messages.test.tsx @@ -116,13 +116,17 @@ describe('useMessages', () => { clientId: '123', roomId: '123', createdAt: new Date(), - latestAction: ChatMessageActions.MessageCreate, - latestActionSerial: '122', + action: ChatMessageActions.MessageCreate, + version: '123', isUpdated: false, isDeleted: false, - actionBefore: vi.fn(), - actionAfter: vi.fn(), - actionEqual: vi.fn(), + deletedBy: undefined, + updatedBy: undefined, + deletedAt: undefined, + updatedAt: undefined, + versionBefore: vi.fn(), + versionAfter: vi.fn(), + versionEqual: vi.fn(), before: vi.fn(), after: vi.fn(), equal: vi.fn(), @@ -177,9 +181,10 @@ describe('useMessages', () => { 'I have the high ground now', {}, {}, - new Date(1719948956834), ChatMessageActions.MessageCreate, '01719948956834-000@108TeGZDQBderu97202638', + new Date(1719948956834), + new Date(1719948956834), ); // call both methods and ensure they call the underlying messages methods await act(async () => {