Refactor outbound delivery state, take 2

This reverts commit ad217c808d.
This commit is contained in:
Evan Hahn 2021-07-19 17:44:49 -05:00 committed by GitHub
parent aade43bfa3
commit c4a09b7507
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 2303 additions and 502 deletions

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { SendStatus } from '../../messages/MessageSendState';
describe('Conversations', () => {
async function resetConversationController(): Promise<void> {
@ -19,9 +20,9 @@ describe('Conversations', () => {
// Creating a fake conversation
const conversation = new window.Whisper.Conversation({
id: '8c45efca-67a4-4026-b990-9537d5d1a08f',
id: window.getGuid(),
e164: '+15551234567',
uuid: '2f2734aa-f69d-4c1c-98eb-50eb0fc512d7',
uuid: window.getGuid(),
type: 'private',
inbox_position: 0,
isPinned: false,
@ -33,7 +34,6 @@ describe('Conversations', () => {
version: 0,
});
const destinationE164 = '+15557654321';
window.textsecure.storage.user.setNumberAndDeviceId(
ourNumber,
2,
@ -42,27 +42,29 @@ describe('Conversations', () => {
window.textsecure.storage.user.setUuidAndDeviceId(ourUuid, 2);
await window.ConversationController.loadPromise();
await window.Signal.Data.saveConversation(conversation.attributes);
// Creating a fake message
const now = Date.now();
let message = new window.Whisper.Message({
attachments: [],
body: 'bananas',
conversationId: conversation.id,
delivered: 1,
delivered_to: [destinationE164],
destination: destinationE164,
expirationStartTimestamp: now,
hasAttachments: false,
hasFileAttachments: false,
hasVisualMediaAttachments: false,
id: 'd8f2b435-e2ef-46e0-8481-07e68af251c6',
id: window.getGuid(),
received_at: now,
recipients: [destinationE164],
sent: true,
sent_at: now,
sent_to: [destinationE164],
timestamp: now,
type: 'outgoing',
sendStateByConversationId: {
[conversation.id]: {
status: SendStatus.Sent,
updatedAt: now,
},
},
});
// Saving to db and updating the convo's last message
@ -70,7 +72,7 @@ describe('Conversations', () => {
forceSave: true,
});
message = window.MessageController.register(message.id, message);
await window.Signal.Data.saveConversation(conversation.attributes);
await window.Signal.Data.updateConversation(conversation.attributes);
await conversation.updateLastMessage();
// Should be set to bananas because that's the last message sent.

View file

@ -5,10 +5,21 @@ import { assert } from 'chai';
import * as sinon from 'sinon';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { CallbackResultType } from '../../textsecure/SendMessage';
import { SendStatus } from '../../messages/MessageSendState';
import MessageSender, {
CallbackResultType,
} from '../../textsecure/SendMessage';
import type { StorageAccessType } from '../../types/Storage.d';
import { SignalService as Proto } from '../../protobuf';
describe('Message', () => {
const STORAGE_KEYS_TO_RESTORE: Array<keyof StorageAccessType> = [
'number_id',
'uuid_id',
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const oldStorageValues = new Map<keyof StorageAccessType, any>();
const i18n = setupI18n('en', enMessages);
const attributes = {
@ -34,16 +45,25 @@ describe('Message', () => {
before(async () => {
window.ConversationController.reset();
await window.ConversationController.load();
STORAGE_KEYS_TO_RESTORE.forEach(key => {
oldStorageValues.set(key, window.textsecure.storage.get(key));
});
window.textsecure.storage.put('number_id', `${me}.2`);
window.textsecure.storage.put('uuid_id', `${ourUuid}.2`);
});
after(async () => {
window.textsecure.storage.remove('number_id');
window.textsecure.storage.remove('uuid_id');
await window.Signal.Data.removeAll();
await window.storage.fetch();
oldStorageValues.forEach((oldValue, key) => {
if (oldValue) {
window.textsecure.storage.put(key, oldValue);
} else {
window.textsecure.storage.remove(key);
}
});
});
beforeEach(function beforeEach() {
@ -56,34 +76,98 @@ describe('Message', () => {
// NOTE: These tests are incomplete.
describe('send', () => {
it("saves the result's dataMessage", async () => {
const message = createMessage({ type: 'outgoing', source });
let oldMessageSender: undefined | MessageSender;
const fakeDataMessage = new ArrayBuffer(0);
const result = {
dataMessage: fakeDataMessage,
};
const promise = Promise.resolve(result);
await message.send(promise);
beforeEach(function beforeEach() {
oldMessageSender = window.textsecure.messaging;
assert.strictEqual(message.get('dataMessage'), fakeDataMessage);
window.textsecure.messaging =
oldMessageSender ?? new MessageSender('username', 'password');
this.sandbox
.stub(window.textsecure.messaging, 'sendSyncMessage')
.resolves({});
});
it('updates the `sent` attribute', async () => {
const message = createMessage({ type: 'outgoing', source, sent: false });
afterEach(() => {
if (oldMessageSender) {
window.textsecure.messaging = oldMessageSender;
} else {
// `window.textsecure.messaging` can be undefined in tests. Instead of updating
// the real type, I just ignore it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window.textsecure as any).messaging;
}
});
const promise: Promise<CallbackResultType> = Promise.resolve({
successfulIdentifiers: [window.getGuid(), window.getGuid()],
it('updates `sendStateByConversationId`', async function test() {
this.sandbox.useFakeTimers(1234);
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
const conversation1 = await window.ConversationController.getOrCreateAndWait(
'a072df1d-7cee-43e2-9e6b-109710a2131c',
'private'
);
const conversation2 = await window.ConversationController.getOrCreateAndWait(
'62bd8ef1-68da-4cfd-ac1f-3ea85db7473e',
'private'
);
const message = createMessage({
type: 'outgoing',
conversationId: (
await window.ConversationController.getOrCreateAndWait(
'71cc190f-97ba-4c61-9d41-0b9444d721f9',
'group'
)
).id,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Pending,
updatedAt: 123,
},
[conversation1.id]: {
status: SendStatus.Pending,
updatedAt: 123,
},
[conversation2.id]: {
status: SendStatus.Pending,
updatedAt: 456,
},
},
});
const fakeDataMessage = new ArrayBuffer(0);
const conversation1Uuid = conversation1.get('uuid');
const ignoredUuid = window.getGuid();
if (!conversation1Uuid) {
throw new Error('Test setup failed: conversation1 should have a UUID');
}
const promise = Promise.resolve<CallbackResultType>({
successfulIdentifiers: [conversation1Uuid, ignoredUuid],
errors: [
Object.assign(new Error('failed'), {
identifier: window.getGuid(),
identifier: conversation2.get('uuid'),
}),
],
dataMessage: fakeDataMessage,
});
await message.send(promise);
assert.isTrue(message.get('sent'));
const result = message.get('sendStateByConversationId') || {};
assert.hasAllKeys(result, [
ourConversationId,
conversation1.id,
conversation2.id,
]);
assert.strictEqual(result[ourConversationId]?.status, SendStatus.Sent);
assert.strictEqual(result[ourConversationId]?.updatedAt, 1234);
assert.strictEqual(result[conversation1.id]?.status, SendStatus.Sent);
assert.strictEqual(result[conversation1.id]?.updatedAt, 1234);
assert.strictEqual(result[conversation2.id]?.status, SendStatus.Failed);
assert.strictEqual(result[conversation2.id]?.updatedAt, 1234);
});
it('saves errors from promise rejections with errors', async () => {

View file

@ -3,10 +3,16 @@
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import { SendStatus } from '../../../messages/MessageSendState';
import {
MessageAttributesType,
ShallowChallengeError,
} from '../../../model-types.d';
import { ConversationType } from '../../../state/ducks/conversations';
import {
canReply,
getMessagePropStatus,
isEndSession,
isGroupUpdate,
isIncoming,
@ -14,6 +20,12 @@ import {
} from '../../../state/selectors/message';
describe('state/selectors/messages', () => {
let ourConversationId: string;
beforeEach(() => {
ourConversationId = uuid();
});
describe('canReply', () => {
const defaultConversation: ConversationType = {
id: uuid(),
@ -35,7 +47,7 @@ describe('state/selectors/messages', () => {
isGroupV1AndDisabled: true,
});
assert.isFalse(canReply(message, getConversationById));
assert.isFalse(canReply(message, ourConversationId, getConversationById));
});
// NOTE: This is missing a test for mandatory profile sharing.
@ -48,33 +60,70 @@ describe('state/selectors/messages', () => {
};
const getConversationById = () => defaultConversation;
assert.isFalse(canReply(message, getConversationById));
assert.isFalse(canReply(message, ourConversationId, getConversationById));
});
it('returns false for outgoing messages that have not been sent', () => {
const message = {
conversationId: 'fake-conversation-id',
type: 'outgoing' as const,
sent_to: [],
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
};
const getConversationById = () => defaultConversation;
assert.isFalse(canReply(message, getConversationById));
assert.isFalse(canReply(message, ourConversationId, getConversationById));
});
it('returns true for outgoing messages that have been delivered to at least one person', () => {
it('returns true for outgoing messages that are only sent to yourself', () => {
const message = {
conversationId: 'fake-conversation-id',
type: 'outgoing' as const,
receipients: [uuid(), uuid()],
sent_to: [uuid()],
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
};
const getConversationById = () => defaultConversation;
assert.isTrue(canReply(message, ourConversationId, getConversationById));
});
it('returns true for outgoing messages that have been sent to at least one person', () => {
const message = {
conversationId: 'fake-conversation-id',
type: 'outgoing' as const,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
},
};
const getConversationById = () => ({
...defaultConversation,
type: 'group' as const,
});
assert.isTrue(canReply(message, getConversationById));
assert.isTrue(canReply(message, ourConversationId, getConversationById));
});
it('returns true for incoming messages', () => {
@ -84,7 +133,247 @@ describe('state/selectors/messages', () => {
};
const getConversationById = () => defaultConversation;
assert.isTrue(canReply(message, getConversationById));
assert.isTrue(canReply(message, ourConversationId, getConversationById));
});
});
describe('getMessagePropStatus', () => {
const createMessage = (overrides: Partial<MessageAttributesType>) => ({
type: 'outgoing' as const,
...overrides,
});
it('returns undefined for incoming messages', () => {
const message = createMessage({ type: 'incoming' });
assert.isUndefined(
getMessagePropStatus(message, ourConversationId, true)
);
});
it('returns "paused" for messages with challenges', () => {
const challengeError: ShallowChallengeError = Object.assign(
new Error('a challenge'),
{
name: 'SendMessageChallengeError',
retryAfter: 123,
data: {},
}
);
const message = createMessage({ errors: [challengeError] });
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, true),
'paused'
);
});
it('returns "partial-sent" if the message has errors but was sent to at least one person', () => {
const message = createMessage({
errors: [new Error('whoopsie')],
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Delivered,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, true),
'partial-sent'
);
});
it('returns "error" if the message has errors and has not been sent', () => {
const message = createMessage({
errors: [new Error('whoopsie')],
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, true),
'error'
);
});
it('returns "read" if the message is just for you and has been sent', () => {
const message = createMessage({
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
},
});
[true, false].forEach(readReceiptSetting => {
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, readReceiptSetting),
'read'
);
});
});
it('returns "read" if the message was read by at least one person and you have read receipts enabled', () => {
const readMessage = createMessage({
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Delivered,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Read,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(readMessage, ourConversationId, true),
'read'
);
const viewedMessage = createMessage({
sendStateByConversationId: {
[uuid()]: {
status: SendStatus.Viewed,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(viewedMessage, ourConversationId, true),
'read'
);
});
it('returns "delivered" if the message was read by at least one person and you have read receipts disabled', () => {
const message = createMessage({
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Read,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, false),
'delivered'
);
});
it('returns "delivered" if the message was delivered to at least one person, but no "higher"', () => {
const message = createMessage({
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Delivered,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, true),
'delivered'
);
});
it('returns "sent" if the message was sent to at least one person, but no "higher"', () => {
const message = createMessage({
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, true),
'sent'
);
});
it('returns "sending" if the message has not been sent yet, even if it has been synced to yourself', () => {
const message = createMessage({
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
});
assert.strictEqual(
getMessagePropStatus(message, ourConversationId, true),
'sending'
);
});
});