mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-07 19:30:14 +00:00
* MM-68219 - Add redacted files placeholder and related functionality * add feature flag control and e2e tests * adjust placeholder design, polish code, fix permalink preview issue * polish code and add missing test * use import path with right path * gate post refetch behind the feature flag, refetch the open thread and avoid observeCount * restore filesInfo for the permalink files prop added upstream * fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter * replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden * raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged * store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival * nvalidate file decisions when a user attribute field changes or all its values are cleared * batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only re-verify the newest page's attachment posts * re-check older channel posts a page at a time instead of one request per post * skip the unstamped epoch lookup for posts with no files, redacted count or permalink * keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check * MM-70905 - disable and grey out the attachment button when an ABAC pe… (#10195) * MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel * only back off render permission fetches when the server request fails, and log other errors without retrying * remove the disableServer helper and have removeServer do both steps * split defaultAllowed into allowWhenUnenforced and allowWhenNotEvaluated, and treat render permissions as unenforced on servers without the decisions API * MM-69627: Add playbook task requirements support on mobile (#10035) * MM-69627: Add playbook task requirements support on mobile Allow filling required task fields when checking off checklist items during a run, gated by the same Playbooks beta setting used on web. Co-authored-by: Cursor <cursoragent@cursor.com> * MM-69627: Address PR review feedback for task requirements Propagate settings write failures, harden fill-requirements save/title handling, and add missing unit coverage. Co-authored-by: Cursor <cursoragent@cursor.com> * MM-69627: Fix TypeScript errors in new unit tests Co-authored-by: Cursor <cursoragent@cursor.com> * MM-69627: Use defineMessages for fill requirements titles Keep i18n extraction able to find the header titles after moving them to the route. Co-authored-by: Cursor <cursoragent@cursor.com> * MM-69627: Address review feedback for task requirements Update checklist items locally when saving requirement values instead of refetching the run, add missing query/navigation tests, and apply small callback/reduce cleanups from review. Co-authored-by: Cursor <cursoragent@cursor.com> * MM-69627: Fix task requirements review follow-ups Propagate local checklist write failures, clear a stale task-requirements flag when playbooks is unsupported, and cap fill values at the desktop/ server max length. Co-authored-by: Cursor <cursoragent@cursor.com> * MM-69627: Move requirement field into its own component Keep one component per file on the fill-requirements screen. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> * MM-69122: Add file element support to interactive dialogs (#9898) * Implement Interactive Dialog - file upload * update demo plugin version for e2etests * fix and add e2etests * review fixes * Fix version bump checkout when a tag shares the branch name (#10229) * Fix Android crashes caused by animated custom emoji (#10030) * Route GIF decoding through Glide - Keep APNG4Android for APNG, animated WebP, and animated AVIF support. - Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow. * Clear Expo image resources with Glide targets - Detach drawables when Glide clears their custom target. - Prevent recycled GIF frame resources from remaining attached to Expo image views. * Preserve foreground image during GIF target recycling --------- Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Scott Bishel <scott.bishel@mattermost.com> Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com> Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com> * detox - fix clobbering of feature flags that cause failures in other suites. Also small fix of a incorrectly set test-id --------- Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Scott Bishel <scott.bishel@mattermost.com> Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com> Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com> Co-authored-by: Nate Schlossberg <nate.schlossberg@mattermost.com>
785 lines
36 KiB
TypeScript
785 lines
36 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {DeviceEventEmitter} from 'react-native';
|
|
|
|
import {markChannelAsViewed, markChannelAsUnread, storeMyChannelsForTeam, updateLastPostAt} from '@actions/local/channel';
|
|
import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} from '@actions/local/post';
|
|
import * as Redaction from '@actions/local/redaction';
|
|
import {updateThread} from '@actions/local/thread';
|
|
import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel';
|
|
import {fetchPostAuthors} from '@actions/remote/post';
|
|
import {fetchThread} from '@actions/remote/thread';
|
|
import {fetchMissingProfilesByIds} from '@actions/remote/user';
|
|
import {settleStreamedPost} from '@agents/actions/websocket';
|
|
import {AGENT_POST_TYPES} from '@agents/constants';
|
|
import streamingStore from '@agents/store/streaming_store';
|
|
import {Events, Screens} from '@constants';
|
|
import {PostTypes} from '@constants/post';
|
|
import DatabaseManager from '@database/manager';
|
|
import {PostsInChannelModel} from '@database/models/server';
|
|
import {getChannelById, getMyChannel} from '@queries/servers/channel';
|
|
import {getPostById, syncPermalinkPreviewsForEditedPost} from '@queries/servers/post';
|
|
import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system';
|
|
import {getIsCRTEnabled} from '@queries/servers/thread';
|
|
import EphemeralStore from '@store/ephemeral_store';
|
|
import {NavigationStore} from '@store/navigation_store';
|
|
import TestHelper from '@test/test_helper';
|
|
import {isTablet} from '@utils/helpers';
|
|
import {shouldIgnorePost} from '@utils/post';
|
|
|
|
import {handleNewPostEvent, handlePostEdited, handlePostDeleted, handlePostUnread, handlePostAcknowledgementAdded, handlePostAcknowledgementRemoved} from './posts';
|
|
|
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
|
|
|
jest.mock('@queries/servers/post');
|
|
jest.mock('@queries/servers/channel');
|
|
jest.mock('@queries/servers/system');
|
|
jest.mock('@queries/servers/thread');
|
|
jest.mock('@actions/local/channel');
|
|
jest.mock('@actions/local/post');
|
|
jest.mock('@actions/local/thread');
|
|
jest.mock('@actions/remote/channel');
|
|
jest.mock('@actions/remote/post');
|
|
jest.mock('@actions/remote/thread');
|
|
jest.mock('@actions/remote/user');
|
|
jest.mock('@agents/actions/websocket', () => ({settleStreamedPost: jest.fn()}));
|
|
jest.mock('@utils/helpers');
|
|
jest.mock('@utils/post', () => ({
|
|
...jest.requireActual('@utils/post'),
|
|
shouldIgnorePost: jest.fn(),
|
|
}));
|
|
jest.mock('@utils/permalink_sync');
|
|
|
|
const serverUrl = 'baseHandler.test.com';
|
|
|
|
describe('WebSocket Post Actions', () => {
|
|
let operator: ServerDataOperator;
|
|
|
|
const post = TestHelper.fakePost({id: 'post1', channel_id: 'channel1', user_id: 'user1', create_at: 12345, message: 'hello'});
|
|
const postModels = [TestHelper.fakePostModel({channelId: post.channel_id, userId: post.user_id, message: post.message, isPinned: true, createAt: 12345})];
|
|
const myChannelModel = TestHelper.fakeMyChannelModel({id: 'channel1', manuallyUnread: false, messageCount: 4, mentionsCount: 0, lastViewedAt: 1});
|
|
|
|
const mockedGetPostById = jest.mocked(getPostById);
|
|
const mockedUpdateLastPostAt = jest.mocked(updateLastPostAt);
|
|
const mockedMarkChannelAsViewed = jest.mocked(markChannelAsViewed);
|
|
const mockedGetCurrentUserId = jest.mocked(getCurrentUserId);
|
|
const mockedGetCurrentChannelId = jest.mocked(getCurrentChannelId);
|
|
const mockedFetchPostAuthors = jest.mocked(fetchPostAuthors);
|
|
const mockedIsTablet = jest.mocked(isTablet);
|
|
const mockedGetMyChannel = jest.mocked(getMyChannel);
|
|
const mockedGetIsCRTEnabled = jest.mocked(getIsCRTEnabled);
|
|
const mockedShouldIgnorePost = jest.mocked(shouldIgnorePost);
|
|
const mockedFetchMyChannel = jest.mocked(fetchMyChannel);
|
|
const mockedStoreMyChannelsForTeam = jest.mocked(storeMyChannelsForTeam);
|
|
const mockedFetchChannelStats = jest.mocked(fetchChannelStats);
|
|
const mockedGetChannelById = jest.mocked(getChannelById);
|
|
const mockedMarkPostAsDeleted = jest.mocked(markPostAsDeleted);
|
|
const mockedUpdateThread = jest.mocked(updateThread);
|
|
const mockedFetchThread = jest.mocked(fetchThread);
|
|
const mockedMarkChannelAsUnread = jest.mocked(markChannelAsUnread);
|
|
const mockedAddPostAcknowledgement = jest.mocked(addPostAcknowledgement);
|
|
const mockedFetchMissingProfilesByIds = jest.mocked(fetchMissingProfilesByIds);
|
|
const mockedRemovePostAcknowledgement = jest.mocked(removePostAcknowledgement);
|
|
const mockedSyncPermalinkPreviewsForEditedPost = jest.mocked(syncPermalinkPreviewsForEditedPost);
|
|
|
|
beforeEach(async () => {
|
|
await DatabaseManager.init([serverUrl]);
|
|
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await DatabaseManager.deleteServerDatabase(serverUrl);
|
|
});
|
|
|
|
describe('handleNewPostEvent', () => {
|
|
const msg = {
|
|
data: {
|
|
post: JSON.stringify(post),
|
|
mentions: ['user1'],
|
|
},
|
|
} as WebSocketMessage;
|
|
|
|
const mockedGetScreensInStack = jest.spyOn(NavigationStore, 'getScreensInStack');
|
|
|
|
mockedGetPostById.mockResolvedValue(undefined);
|
|
mockedUpdateLastPostAt.mockResolvedValue({member: myChannelModel});
|
|
mockedMarkChannelAsViewed.mockResolvedValue({member: myChannelModel});
|
|
mockedGetCurrentUserId.mockResolvedValue('user1');
|
|
mockedGetCurrentChannelId.mockResolvedValue('channel1');
|
|
mockedFetchPostAuthors.mockResolvedValue({authors: []});
|
|
mockedGetScreensInStack.mockReturnValue([Screens.CHANNEL]);
|
|
mockedIsTablet.mockReturnValue(false);
|
|
|
|
it('should handle new post event - channel membership present', async () => {
|
|
const userModel = TestHelper.fakeUserModel({id: 'user1', username: 'username1'});
|
|
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels);
|
|
jest.spyOn(operator, 'handleUsers').mockResolvedValue([userModel]);
|
|
|
|
mockedFetchPostAuthors.mockResolvedValueOnce({authors: [TestHelper.fakeUser({id: 'user1', username: 'username1'})]});
|
|
mockedGetMyChannel.mockResolvedValue(myChannelModel);
|
|
mockedGetIsCRTEnabled.mockResolvedValue(false);
|
|
mockedShouldIgnorePost.mockReturnValue(false);
|
|
|
|
await handleNewPostEvent(serverUrl, msg);
|
|
|
|
expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, {
|
|
channelId: 'channel1',
|
|
rootId: '',
|
|
userId: 'user1',
|
|
now: expect.any(Number),
|
|
});
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith([
|
|
userModel,
|
|
expect.objectContaining({
|
|
id: 'channel1',
|
|
lastViewedAt: 1,
|
|
manuallyUnread: false,
|
|
mentionsCount: 0,
|
|
messageCount: 4,
|
|
}),
|
|
postModels[0],
|
|
], 'handleNewPostEvent');
|
|
});
|
|
|
|
it.each([
|
|
{description: 'increment for an urgent mention', isUrgent: true, mentions: ['user1'], expectedMentions: 1, expectedUrgent: 3},
|
|
{description: 'preserve for an urgent post without a mention', isUrgent: true, mentions: [], expectedMentions: 0, expectedUrgent: 2},
|
|
{description: 'preserve for a non-urgent mention', isUrgent: false, mentions: ['user1'], expectedMentions: 1, expectedUrgent: 2},
|
|
])('should $description', async ({isUrgent, mentions, expectedMentions, expectedUrgent}) => {
|
|
const incomingPost = TestHelper.fakePost({
|
|
...post,
|
|
user_id: 'user2',
|
|
metadata: isUrgent ? {priority: {priority: 'urgent'}} : undefined,
|
|
});
|
|
jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels);
|
|
|
|
const myChannelWithUrgentMentions = TestHelper.fakeMyChannelModel({
|
|
...myChannelModel,
|
|
urgentMentionCount: 2,
|
|
});
|
|
mockedGetMyChannel.mockResolvedValue(myChannelWithUrgentMentions);
|
|
mockedUpdateLastPostAt.mockResolvedValue({member: myChannelWithUrgentMentions});
|
|
mockedGetIsCRTEnabled.mockResolvedValue(false);
|
|
mockedShouldIgnorePost.mockReturnValue(false);
|
|
mockedMarkChannelAsUnread.mockResolvedValue({member: myChannelModel});
|
|
|
|
await handleNewPostEvent(serverUrl, {data: {post: JSON.stringify(incomingPost), mentions}} as WebSocketMessage);
|
|
|
|
expect(mockedMarkChannelAsUnread).toHaveBeenCalledTimes(1);
|
|
expect(mockedMarkChannelAsUnread).toHaveBeenCalledWith(serverUrl, expect.objectContaining({
|
|
mentionsCount: expectedMentions,
|
|
urgentMentionCount: expectedUrgent,
|
|
}), true);
|
|
});
|
|
|
|
it('should handle new post event - without channel membership present', async () => {
|
|
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels);
|
|
|
|
mockedGetMyChannel.mockResolvedValueOnce(undefined);
|
|
mockedFetchMyChannel.mockResolvedValue({teamId: 'team1', memberships: [TestHelper.fakeChannelMember({user_id: 'user1', channel_id: 'channel1'})]});
|
|
mockedStoreMyChannelsForTeam.mockResolvedValue({models: [myChannelModel], error: undefined});
|
|
mockedGetMyChannel.mockResolvedValueOnce(myChannelModel);
|
|
mockedGetIsCRTEnabled.mockResolvedValue(false);
|
|
mockedShouldIgnorePost.mockReturnValue(true);
|
|
|
|
await handleNewPostEvent(serverUrl, msg);
|
|
|
|
expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, {
|
|
channelId: 'channel1',
|
|
rootId: '',
|
|
userId: 'user1',
|
|
now: expect.any(Number),
|
|
});
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith(postModels, 'handleNewPostEvent');
|
|
});
|
|
|
|
it('should handle new post event - CRT enabled, manually unread', async () => {
|
|
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels);
|
|
|
|
mockedGetMyChannel.mockResolvedValue(TestHelper.fakeMyChannelModel({...myChannelModel, manuallyUnread: true}));
|
|
mockedGetIsCRTEnabled.mockResolvedValue(true);
|
|
mockedShouldIgnorePost.mockReturnValue(false);
|
|
mockedMarkChannelAsUnread.mockResolvedValue({member: myChannelModel});
|
|
|
|
await handleNewPostEvent(serverUrl, msg);
|
|
|
|
expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, {
|
|
channelId: 'channel1',
|
|
rootId: '',
|
|
userId: 'user1',
|
|
now: expect.any(Number),
|
|
});
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith([expect.objectContaining({
|
|
id: 'channel1',
|
|
lastViewedAt: 1,
|
|
manuallyUnread: false,
|
|
mentionsCount: 0,
|
|
messageCount: 4,
|
|
}), postModels[0]], 'handleNewPostEvent');
|
|
});
|
|
|
|
it('should handle new post event - out of order ws, CRT on, root id', async () => {
|
|
const newPost = TestHelper.fakePost({...post, root_id: 'post2'});
|
|
jest.spyOn(EphemeralStore, 'getLastPostWebsocketEvent').mockReturnValueOnce({deleted: true, post: newPost});
|
|
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels);
|
|
|
|
mockedGetMyChannel.mockResolvedValue(myChannelModel);
|
|
mockedGetIsCRTEnabled.mockResolvedValue(true);
|
|
mockedShouldIgnorePost.mockReturnValue(false);
|
|
|
|
await handleNewPostEvent(serverUrl, {data: {post: JSON.stringify(newPost), mentions: []}} as WebSocketMessage);
|
|
|
|
expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, {
|
|
channelId: 'channel1',
|
|
rootId: 'post2',
|
|
userId: 'user1',
|
|
now: expect.any(Number),
|
|
});
|
|
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle new post event - from webhook and is tablet', async () => {
|
|
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
jest.spyOn(operator, 'handlePosts').mockResolvedValue(postModels);
|
|
|
|
mockedGetMyChannel.mockResolvedValue(myChannelModel);
|
|
mockedGetIsCRTEnabled.mockResolvedValue(false);
|
|
mockedShouldIgnorePost.mockReturnValue(false);
|
|
mockedIsTablet.mockReturnValueOnce(true);
|
|
mockedGetScreensInStack.mockReturnValue([]);
|
|
|
|
await handleNewPostEvent(serverUrl, {data: {post: JSON.stringify({...post, props: {from_webhook: 'true'}}), mentions: []}} as WebSocketMessage);
|
|
|
|
expect(mockedGetScreensInStack).toHaveBeenCalled();
|
|
expect(emitSpy).toHaveBeenCalledWith(Events.USER_STOP_TYPING, {
|
|
channelId: 'channel1',
|
|
rootId: '',
|
|
userId: 'user1',
|
|
now: expect.any(Number),
|
|
});
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith([expect.objectContaining({
|
|
id: 'channel1',
|
|
lastViewedAt: 1,
|
|
manuallyUnread: false,
|
|
mentionsCount: 0,
|
|
messageCount: 4,
|
|
}), postModels[0]], 'handleNewPostEvent');
|
|
});
|
|
|
|
it('should handle new post event - no operator', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
await handleNewPostEvent('junk', msg);
|
|
|
|
expect(mockedGetCurrentUserId).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle new post event - existing post', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
mockedGetPostById.mockResolvedValueOnce(postModels[0]);
|
|
|
|
await handleNewPostEvent(serverUrl, msg);
|
|
|
|
expect(mockedGetIsCRTEnabled).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle new post event - malformed post data', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
mockedGetPostById.mockResolvedValueOnce(postModels[0]);
|
|
|
|
await handleNewPostEvent(serverUrl, {data: undefined} as WebSocketMessage);
|
|
|
|
expect(mockedGetCurrentUserId).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('handlePostEdited', () => {
|
|
const editedPost = {id: 'post1', channel_id: 'channel1', user_id: 'user1', is_pinned: false, edit_at: 54321, message: 'edited message'};
|
|
const msg = {
|
|
data: {
|
|
post: JSON.stringify(editedPost),
|
|
},
|
|
} as WebSocketMessage;
|
|
|
|
const permalinkPostModel = TestHelper.fakePostModel({id: 'permalink_post', channelId: 'channel2'});
|
|
|
|
beforeEach(() => {
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]);
|
|
mockedFetchPostAuthors.mockResolvedValue({authors: []});
|
|
mockedGetIsCRTEnabled.mockResolvedValue(false);
|
|
jest.spyOn(EphemeralStore, 'addEditingPost').mockImplementation(jest.fn());
|
|
});
|
|
|
|
it('should handle post edited event - post exists locally', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]);
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost);
|
|
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
|
expect(mockedFetchChannelStats).toHaveBeenCalledWith(serverUrl, 'channel1');
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith([expect.any(PostsInChannelModel)], 'handlePostEdited');
|
|
});
|
|
|
|
it('should stamp the epoch current when the edit arrived, not one raised while it was handled', async () => {
|
|
// The payload carries the decision the server made at broadcast; an invalidation flushed
|
|
// during the author fetch must not be stamped onto it.
|
|
let counter = 1;
|
|
const captureSpy = jest.spyOn(Redaction, 'captureRedactionEpoch').mockImplementation(async () => counter);
|
|
mockedFetchPostAuthors.mockImplementation(async () => {
|
|
counter = 2;
|
|
return {authors: []};
|
|
});
|
|
const handlePostsSpy = jest.spyOn(operator, 'handlePosts');
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(handlePostsSpy).toHaveBeenCalledWith(expect.objectContaining({redactionVerifiedEpoch: 1}));
|
|
captureSpy.mockRestore();
|
|
handlePostsSpy.mockRestore();
|
|
});
|
|
|
|
it('should not vouch for a burn-on-read edit, which the broadcast redaction does not cover', async () => {
|
|
const captureSpy = jest.spyOn(Redaction, 'captureRedactionEpoch').mockResolvedValue(5);
|
|
const handlePostsSpy = jest.spyOn(operator, 'handlePosts');
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
|
|
await handlePostEdited(serverUrl, {data: {post: JSON.stringify({...editedPost, type: PostTypes.BURN_ON_READ})}} as WebSocketMessage);
|
|
|
|
expect(captureSpy).not.toHaveBeenCalled();
|
|
expect(handlePostsSpy).toHaveBeenCalledWith(expect.objectContaining({redactionVerifiedEpoch: undefined}));
|
|
captureSpy.mockRestore();
|
|
handlePostsSpy.mockRestore();
|
|
});
|
|
|
|
it('should handle post edited event - post exists with permalink updates', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]);
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost);
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
permalinkPostModel,
|
|
expect.any(PostsInChannelModel),
|
|
]),
|
|
'handlePostEdited',
|
|
);
|
|
});
|
|
|
|
it('should handle post edited event - post missing but has permalink updates (key fix)', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
const addEditingPostSpy = jest.spyOn(EphemeralStore, 'addEditingPost');
|
|
|
|
mockedGetPostById.mockResolvedValue(undefined);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]);
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost);
|
|
expect(addEditingPostSpy).toHaveBeenCalledWith(serverUrl, editedPost);
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith([permalinkPostModel], 'handlePostEdited - permalink sync only');
|
|
});
|
|
|
|
it('should handle post edited event - post missing and no permalink updates', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
const addEditingPostSpy = jest.spyOn(EphemeralStore, 'addEditingPost');
|
|
|
|
mockedGetPostById.mockResolvedValue(undefined);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]);
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost);
|
|
expect(addEditingPostSpy).toHaveBeenCalledWith(serverUrl, editedPost);
|
|
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle permalink sync errors gracefully', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
const logWarningSpy = jest.spyOn(require('@utils/log'), 'logWarning');
|
|
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockRejectedValue(new Error('Permalink sync failed'));
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(logWarningSpy).toHaveBeenCalledWith('Failed to sync permalink previews for edited post:', expect.any(Error));
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith([expect.any(PostsInChannelModel)], 'handlePostEdited');
|
|
});
|
|
|
|
it('should leave streaming state to the stream-end refetch for conversation-backed agent posts', async () => {
|
|
const agentPost = {...editedPost, type: AGENT_POST_TYPES.LLMBOT, props: {conversation_id: 'conv1'}};
|
|
const removePostSpy = jest.spyOn(streamingStore, 'removePost');
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
|
|
await handlePostEdited(serverUrl, {data: {post: JSON.stringify(agentPost)}} as WebSocketMessage);
|
|
|
|
expect(removePostSpy).not.toHaveBeenCalled();
|
|
expect(settleStreamedPost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should settle a conversation-backed agent post whose stream ended before the edit was stored', async () => {
|
|
const agentPost = {...editedPost, type: AGENT_POST_TYPES.LLMBOT, props: {conversation_id: 'conv1'}};
|
|
streamingStore.startStreaming(serverUrl, agentPost.id);
|
|
streamingStore.endStreaming(serverUrl, agentPost.id);
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
|
|
await handlePostEdited(serverUrl, {data: {post: JSON.stringify(agentPost)}} as WebSocketMessage);
|
|
|
|
expect(settleStreamedPost).toHaveBeenCalledWith(serverUrl, agentPost.id);
|
|
streamingStore.removeServer(serverUrl);
|
|
});
|
|
|
|
it('should not settle a conversation-backed agent post that is still streaming', async () => {
|
|
const agentPost = {...editedPost, type: AGENT_POST_TYPES.LLMBOT, props: {conversation_id: 'conv1'}};
|
|
streamingStore.startStreaming(serverUrl, agentPost.id);
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
|
|
await handlePostEdited(serverUrl, {data: {post: JSON.stringify(agentPost)}} as WebSocketMessage);
|
|
|
|
expect(settleStreamedPost).not.toHaveBeenCalled();
|
|
streamingStore.removeServer(serverUrl);
|
|
});
|
|
|
|
it('should clear streaming state for legacy agent posts without a conversation', async () => {
|
|
const agentPost = {...editedPost, type: AGENT_POST_TYPES.LLMBOT, props: {}};
|
|
const removePostSpy = jest.spyOn(streamingStore, 'removePost');
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
|
|
await handlePostEdited(serverUrl, {data: {post: JSON.stringify(agentPost)}} as WebSocketMessage);
|
|
|
|
expect(removePostSpy).toHaveBeenCalledWith(serverUrl, 'post1');
|
|
});
|
|
|
|
it('should handle post edited event - no operator', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
await handlePostEdited('junk', msg);
|
|
|
|
expect(mockedSyncPermalinkPreviewsForEditedPost).not.toHaveBeenCalled();
|
|
expect(mockedGetPostById).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle post edited event - malformed post data', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
await handlePostEdited(serverUrl, {data: undefined} as WebSocketMessage);
|
|
|
|
expect(mockedSyncPermalinkPreviewsForEditedPost).not.toHaveBeenCalled();
|
|
expect(mockedGetPostById).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve ephemeral identity fields when an edited ephemeral arrives with incomplete data', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
const ephemeralPost = TestHelper.fakePost({
|
|
type: PostTypes.EPHEMERAL,
|
|
create_at: 0,
|
|
root_id: '',
|
|
user_id: '',
|
|
message: 'after',
|
|
});
|
|
const ephemeralMsg = {
|
|
data: {post: JSON.stringify(ephemeralPost)},
|
|
} as WebSocketMessage;
|
|
const storedEphemeralPost = TestHelper.fakePostModel({
|
|
type: PostTypes.EPHEMERAL,
|
|
createAt: 12345,
|
|
rootId: 'root1',
|
|
userId: 'creator1',
|
|
});
|
|
|
|
mockedGetPostById.mockResolvedValueOnce(storedEphemeralPost);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]);
|
|
|
|
await handlePostEdited(serverUrl, ephemeralMsg);
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
createAt: 12345,
|
|
rootId: 'root1',
|
|
userId: 'creator1',
|
|
message: 'after',
|
|
}),
|
|
]),
|
|
'handlePostEdited',
|
|
);
|
|
});
|
|
|
|
it('should not double batch permalink models in handlePostEdited', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]);
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledTimes(1);
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
permalinkPostModel,
|
|
expect.any(PostsInChannelModel),
|
|
]),
|
|
'handlePostEdited',
|
|
);
|
|
|
|
expect(batchRecordsSpy).not.toHaveBeenCalledWith(
|
|
[permalinkPostModel],
|
|
'handlePostEdited - permalink sync only',
|
|
);
|
|
});
|
|
|
|
it('should batch only permalink models when post does not exist locally', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
mockedGetPostById.mockResolvedValue(undefined);
|
|
mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]);
|
|
|
|
await handlePostEdited(serverUrl, msg);
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledTimes(1);
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith(
|
|
[permalinkPostModel],
|
|
'handlePostEdited - permalink sync only',
|
|
);
|
|
|
|
expect(batchRecordsSpy).not.toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'handlePostEdited',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('handlePostDeleted', () => {
|
|
const deletedPost = {id: 'post1', channel_id: 'channel1', user_id: 'user1', root_id: 'root1', reply_count: 1};
|
|
const msg = {
|
|
data: {
|
|
post: JSON.stringify(deletedPost),
|
|
},
|
|
} as WebSocketMessage;
|
|
|
|
const threadModel = TestHelper.fakeThreadModel({viewedAt: 1, id: 'thread1'});
|
|
|
|
it('should handle post deleted event', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
|
|
mockedGetIsCRTEnabled.mockResolvedValue(true);
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
mockedMarkPostAsDeleted.mockResolvedValue({model: postModels[0]});
|
|
mockedUpdateThread.mockResolvedValue({model: threadModel});
|
|
mockedGetChannelById.mockResolvedValue(TestHelper.fakeChannelModel({id: 'channel1', teamId: 'team1'}));
|
|
|
|
await handlePostDeleted(serverUrl, msg);
|
|
|
|
expect(mockedMarkPostAsDeleted).toHaveBeenCalledWith(serverUrl, expect.any(Object), true);
|
|
expect(mockedUpdateThread).toHaveBeenCalledWith(serverUrl, 'root1', {reply_count: 0}, true);
|
|
expect(mockedFetchThread).toHaveBeenCalledWith(serverUrl, 'team1', 'root1');
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith([postModels[0], threadModel], 'handlePostDeleted');
|
|
});
|
|
|
|
it('should handle post deleted event - missing thread and channel', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
|
|
mockedGetIsCRTEnabled.mockResolvedValue(true);
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
mockedMarkPostAsDeleted.mockResolvedValue({model: postModels[0]});
|
|
mockedUpdateThread.mockResolvedValue({error: 'not found'});
|
|
mockedGetChannelById.mockResolvedValue(undefined);
|
|
|
|
await handlePostDeleted(serverUrl, msg);
|
|
|
|
expect(mockedMarkPostAsDeleted).toHaveBeenCalledWith(serverUrl, expect.any(Object), true);
|
|
expect(mockedUpdateThread).toHaveBeenCalledWith(serverUrl, 'root1', {reply_count: 0}, true);
|
|
expect(mockedFetchThread).not.toHaveBeenCalled();
|
|
|
|
expect(batchRecordsSpy).toHaveBeenCalledWith(postModels, 'handlePostDeleted');
|
|
});
|
|
|
|
it('should handle post deleted event - missing delete model and no root id', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn());
|
|
|
|
mockedGetIsCRTEnabled.mockResolvedValue(true);
|
|
mockedGetPostById.mockResolvedValue(postModels[0]);
|
|
mockedMarkPostAsDeleted.mockResolvedValue({error: 'not found'});
|
|
|
|
await handlePostDeleted(serverUrl, {data: {post: JSON.stringify({...deletedPost, root_id: ''})}} as WebSocketMessage);
|
|
|
|
expect(mockedMarkPostAsDeleted).toHaveBeenCalledWith(serverUrl, expect.any(Object), true);
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('handlePostUnread', () => {
|
|
const msg = {
|
|
broadcast: {
|
|
channel_id: 'channel1',
|
|
team_id: 'team1',
|
|
},
|
|
data: {
|
|
mention_count: 1,
|
|
mention_count_root: 1,
|
|
msg_count: 1,
|
|
msg_count_root: 1,
|
|
last_viewed_at: 12345,
|
|
},
|
|
} as WebSocketMessage;
|
|
|
|
it('should handle post unread event', async () => {
|
|
mockedGetMyChannel.mockResolvedValue(myChannelModel);
|
|
mockedGetIsCRTEnabled.mockResolvedValue(false);
|
|
mockedFetchMyChannel.mockResolvedValue({teamId: 'team1', memberships: [TestHelper.fakeChannelMember({user_id: 'user1', channel_id: 'channel1', urgent_mention_count: 2})]});
|
|
mockedMarkChannelAsUnread.mockResolvedValue({member: myChannelModel});
|
|
|
|
await handlePostUnread(serverUrl, msg);
|
|
|
|
expect(mockedMarkChannelAsUnread).toHaveBeenCalledWith(serverUrl, {
|
|
channelId: 'channel1',
|
|
messageCount: 1,
|
|
mentionsCount: 1,
|
|
urgentMentionCount: 2,
|
|
lastViewed: 12345,
|
|
});
|
|
});
|
|
|
|
it('should handle post unread event - CRT enabled, manually marked read', async () => {
|
|
mockedGetMyChannel.mockResolvedValue(TestHelper.fakeMyChannelModel({...myChannelModel, manuallyUnread: true}));
|
|
mockedGetIsCRTEnabled.mockResolvedValue(true);
|
|
mockedFetchMyChannel.mockResolvedValue({teamId: 'team1', memberships: [TestHelper.fakeChannelMember({user_id: 'user1', channel_id: 'channel1'})]});
|
|
mockedMarkChannelAsUnread.mockResolvedValue({member: myChannelModel});
|
|
|
|
await handlePostUnread(serverUrl, msg);
|
|
|
|
expect(mockedMarkChannelAsUnread).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle post unread event - no database', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
await handlePostUnread('junk', msg);
|
|
|
|
expect(mockedGetMyChannel).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{description: 'fetchMyChannel fails', fetchResult: {error: new Error('network error')}},
|
|
{description: 'membership is absent', fetchResult: {teamId: 'team1', memberships: []}},
|
|
])('should not mark channel as unread when $description', async ({fetchResult}) => {
|
|
mockedGetMyChannel.mockResolvedValue(myChannelModel);
|
|
mockedGetIsCRTEnabled.mockResolvedValue(false);
|
|
mockedFetchMyChannel.mockResolvedValue(fetchResult);
|
|
|
|
await handlePostUnread(serverUrl, msg);
|
|
|
|
expect(mockedMarkChannelAsUnread).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('handlePostAcknowledgementAdded', () => {
|
|
const msg = {
|
|
data: {
|
|
acknowledgement: JSON.stringify({user_id: 'user1', post_id: 'post1', acknowledged_at: 12345}),
|
|
},
|
|
} as WebSocketMessage;
|
|
|
|
mockedGetCurrentUserId.mockResolvedValue('user1');
|
|
|
|
it('should handle post acknowledgement added event', async () => {
|
|
jest.spyOn(EphemeralStore, 'isAcknowledgingPost').mockReturnValue(false);
|
|
|
|
await handlePostAcknowledgementAdded(serverUrl, msg);
|
|
|
|
expect(mockedAddPostAcknowledgement).toHaveBeenCalledWith(serverUrl, 'post1', 'user1', 12345);
|
|
expect(mockedFetchMissingProfilesByIds).toHaveBeenCalledWith(serverUrl, ['user1']);
|
|
});
|
|
|
|
it('should handle post acknowledgement added event - already acknowledging', async () => {
|
|
jest.spyOn(EphemeralStore, 'isAcknowledgingPost').mockReturnValue(true);
|
|
|
|
await handlePostAcknowledgementAdded(serverUrl, msg);
|
|
|
|
expect(mockedAddPostAcknowledgement).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle post acknowledgement added event - no database', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
await handlePostAcknowledgementAdded('junk', msg);
|
|
|
|
expect(mockedGetCurrentUserId).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('handlePostAcknowledgementRemoved', () => {
|
|
const msg = {
|
|
data: {
|
|
acknowledgement: JSON.stringify({user_id: 'user1', post_id: 'post1'}),
|
|
},
|
|
} as WebSocketMessage;
|
|
|
|
mockedGetCurrentUserId.mockResolvedValue('user1');
|
|
|
|
it('should handle post acknowledgement removed event', async () => {
|
|
jest.spyOn(EphemeralStore, 'isUnacknowledgingPost').mockReturnValue(false);
|
|
|
|
await handlePostAcknowledgementRemoved(serverUrl, msg);
|
|
|
|
expect(mockedRemovePostAcknowledgement).toHaveBeenCalledWith(serverUrl, 'post1', 'user1');
|
|
});
|
|
|
|
it('should handle post acknowledgement added event - already unacknowledging', async () => {
|
|
jest.spyOn(EphemeralStore, 'isUnacknowledgingPost').mockReturnValue(true);
|
|
|
|
await handlePostAcknowledgementRemoved(serverUrl, msg);
|
|
|
|
expect(mockedRemovePostAcknowledgement).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle post acknowledgement removed event - no database', async () => {
|
|
const batchRecordsSpy = jest.spyOn(operator, 'batchRecords');
|
|
|
|
await handlePostAcknowledgementRemoved('junk', msg);
|
|
|
|
expect(mockedGetCurrentUserId).not.toHaveBeenCalled();
|
|
expect(batchRecordsSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|