mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-10 13:10: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 * MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel * First draft of updated read permission handling * 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 * improved Data fetches * fixes * Fixed test * Fixed archived channel issue * Removed re-sync on team switch * Define message enhancement * Coderabbit fixes * React to permission_policy_updated only for the actions it touched The server now lists the rule actions a permission policy change touched. Every connected client gets this event, so each step runs only when an action it depends on changed: - download_file_attachment raises the redaction epoch, which also revalidates the render decisions - any other cached render-decision action only expires the decisions - channel_read_access reconciles the channel list An empty list (an auto-add toggle) does nothing, and a server that predates the list is treated as having changed every action. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvkCrND4n9oyehQUa9V5QH * Handled empty channel list * Fixed channel access races and tablet kick fallback Read stored channel IDs before the team channel list request so channels joined or left mid-request are not wrongly purged or restored, and set the fallback channel instead of the kicked one as current on a tablet when the server is not active. * Minor fix * Moved pending channel access checks to EphemeralStore * Moved channel access revoked sheet helpers to navigation utils * Gated channel access enforcement on the ChannelPermissionActions feature flag * Reported a channel lost to read access on reconnect as revoked access * Addressed review comments on channel read access Jittered the channel list reconcile after a permission policy update, logged non-request failures in channel access checks as errors, and set the channel read access version to 12.2.0. * Implemented write actions (#10224) * Implemented write actions * Lint fix * Test that write and management policy changes only revalidate decisions A permission_policy_updated event naming channel_write_access or channel_management_access expires the cached render decisions those gates read, without raising the redaction epoch or reconciling the channel list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvkCrND4n9oyehQUa9V5QH * Shared the channel access enforcement check * Addressed review comments on channel write access Hid pinning and the acknowledge button while a write access policy denies the channel, as the server refuses both, and moved the read-only notice to app/components since two screens share it. --------- Co-authored-by: Claude <noreply@anthropic.com> * Expect no Manage button on archived channel members The members screen now renders the Manage header button only when the user can manage and remove members, which is never the case for an archived channel. MM-T1719_1 still expected the button to render and failed on detox-ios and detox-android with "Got: was null". Assert that neither the Manage button nor the remove controls exist instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Fj5x2Znwhwx94X9g76Hny --------- Co-authored-by: Pablo Andrés Vélez Vidal <pablovv2012@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1782 lines
75 KiB
TypeScript
1782 lines
75 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
/* eslint-disable max-lines */
|
|
|
|
import {ActionType, General, Post, ServerErrors} from '@constants';
|
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
|
import DatabaseManager from '@database/manager';
|
|
import PostModel from '@database/models/server/post';
|
|
import NetworkManager from '@managers/network_manager';
|
|
import {getPostById, getRecentPostsInChannel, queryPostsById, queryPostsInChannel} from '@queries/servers/post';
|
|
import EphemeralStore from '@store/ephemeral_store';
|
|
import RenderPermissionsStore from '@store/render_permissions_store';
|
|
import TestHelper from '@test/test_helper';
|
|
import {getFullErrorMessage} from '@utils/errors';
|
|
|
|
import * as LocalChannelActions from '../local/channel';
|
|
import {RedactionInvalidationReason, invalidateRedactionGlobally} from '../local/redaction';
|
|
|
|
import {
|
|
createPost,
|
|
retryFailedPost,
|
|
togglePinPost,
|
|
deletePost,
|
|
markPostAsUnread,
|
|
editPost,
|
|
acknowledgePost,
|
|
unacknowledgePost,
|
|
revealBoRPost,
|
|
fetchPostsForChannel,
|
|
refetchPostsForRedaction,
|
|
refreshPostsForChannel,
|
|
fetchPostsForUnreadChannels,
|
|
fetchPosts,
|
|
fetchPostsBefore,
|
|
revalidatePostsBefore,
|
|
fetchPostsSince,
|
|
fetchPostAuthors,
|
|
fetchPostThread,
|
|
fetchPostsAround,
|
|
fetchMissingChannelsFromPosts,
|
|
fetchPostById,
|
|
fetchPostInfo,
|
|
fetchSavedPosts,
|
|
fetchPinnedPosts,
|
|
burnPostNow,
|
|
} from './post';
|
|
import * as PostAuxilaryFunctions from './post.auxiliary';
|
|
|
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
|
|
|
const serverUrl = 'baseHandler.test.com';
|
|
let operator: ServerDataOperator;
|
|
|
|
const channelId = 'channelid1';
|
|
const teamId = 'teamid1';
|
|
|
|
const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile;
|
|
const user2 = {id: 'userid2', username: 'user2', email: 'user2@mattermost.com', roles: ''} as UserProfile;
|
|
|
|
const post1 = TestHelper.fakePost({channel_id: channelId, id: 'postid1', user_id: user1.id});
|
|
const post2 = TestHelper.fakePost({channel_id: channelId, id: 'postid2', user_id: user2.id});
|
|
const reply1 = TestHelper.fakePost({channel_id: channelId, id: 'replyid1', root_id: post1.id, user_id: user2.id});
|
|
|
|
const channel1 = {
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 12,
|
|
creator_id: user1.id,
|
|
delete_at: 0,
|
|
} as Channel;
|
|
|
|
const channelMember1 = {
|
|
id: 'memberid1',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 10,
|
|
} as ChannelMembership;
|
|
|
|
const fileInfo1: FileInfo = {
|
|
id: 'fileid',
|
|
clientId: 'clientid',
|
|
localPath: 'path1',
|
|
} as FileInfo;
|
|
|
|
const mockPostModel = (overrides: Partial<PostModel> = {}): PostModel => ({
|
|
id: 'post-id',
|
|
channelId: 'channel-id',
|
|
createAt: Date.now(),
|
|
deleteAt: 0,
|
|
type: 'custom_post_type',
|
|
userId: 'user-id',
|
|
...overrides,
|
|
} as PostModel);
|
|
|
|
const throwFunc = () => {
|
|
throw Error('error');
|
|
};
|
|
|
|
const acknowledgedTime = Date.now();
|
|
|
|
const genericGetPostsMock = jest.fn((_channelId: string) => ({posts: {[post1.id]: {...post1, channel_id: _channelId}, [post2.id]: {...post2, channel_id: _channelId}}, order: [post1.id, post2.id]}));
|
|
|
|
const mockClient = {
|
|
createPost: jest.fn((post: Post) => ({...post, id: 'newid'})),
|
|
pinPost: jest.fn(),
|
|
unpinPost: jest.fn(),
|
|
deletePost: jest.fn(),
|
|
burnPostNow: jest.fn(),
|
|
getChannel: jest.fn((_channelId: string) => ({id: _channelId, name: 'channel1', creatorId: user1.id, total_msg_count: 100})),
|
|
getChannelMember: jest.fn((_channelId: string, userId: string) => ({id: userId + '-' + _channelId, user_id: userId, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
|
|
getMyChannelMember: jest.fn((_channelId: string) => ({id: user1.id + '-' + _channelId, user_id: user1.id, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
|
|
markPostAsUnread: jest.fn(),
|
|
patchPost: jest.fn((message: string, postId: string) => ({...post1, id: postId, message})),
|
|
acknowledgePost: jest.fn(() => ({acknowledged_at: acknowledgedTime})),
|
|
unacknowledgePost: jest.fn(),
|
|
revealBoRPost: jest.fn((_postId: string) => ({
|
|
...post1,
|
|
id: _postId,
|
|
message: 'Revealed message content',
|
|
metadata: {
|
|
files: [{id: 'file1', name: 'revealed-file.pdf'}],
|
|
},
|
|
props: {
|
|
revealed: true,
|
|
},
|
|
})),
|
|
getPosts: genericGetPostsMock,
|
|
getPostsBefore: genericGetPostsMock,
|
|
getPostsSince: jest.fn((_channelId: string, since: number) => ({posts: {[post1.id]: {...post1, channel_id: _channelId, create_at: since + 1}, [post2.id]: {...post2, channel_id: _channelId, create_at: since + 2}}, order: [post1.id, post2.id]})),
|
|
getProfilesByIds: jest.fn((ids: string[]) => (ids.map((id) => ({...user1, id})))),
|
|
getProfilesByUsernames: jest.fn((names: string[]) => (names.map((name) => ({...user1, username: name, id: 'id' + name})))),
|
|
getPostThread: jest.fn((_postId: string) => ({posts: {[_postId]: {...post1, id: _postId}, [reply1.id]: {...reply1, root_id: _postId}}, order: [_postId, reply1.id]})),
|
|
getPostsAfter: genericGetPostsMock,
|
|
getPost: jest.fn((_postId: string) => ({...post2, id: _postId})),
|
|
getPostInfo: jest.fn(() => ({
|
|
channel_id: channelId,
|
|
channel_type: 'O' as ChannelType,
|
|
channel_display_name: 'Town Square',
|
|
has_joined_channel: false,
|
|
team_id: teamId,
|
|
team_type: 'O',
|
|
team_display_name: 'Test Team',
|
|
has_joined_team: true,
|
|
})),
|
|
getSavedPosts: genericGetPostsMock,
|
|
getPinnedPosts: genericGetPostsMock,
|
|
};
|
|
|
|
let mockGetIsCRTEnabled: jest.Mock;
|
|
jest.mock('@queries/servers/thread', () => {
|
|
const original = jest.requireActual('@queries/servers/thread');
|
|
mockGetIsCRTEnabled = jest.fn(() => true);
|
|
return {
|
|
...original,
|
|
getIsCRTEnabled: mockGetIsCRTEnabled,
|
|
};
|
|
});
|
|
|
|
let mockAddRecentReaction: jest.Mock;
|
|
jest.mock('@actions/local/reactions', () => {
|
|
const original = jest.requireActual('@actions/local/reactions');
|
|
mockAddRecentReaction = jest.fn(() => [{user_id: 'userid1', emoji_name: 'smile'}]);
|
|
return {
|
|
...original,
|
|
addRecentReaction: mockAddRecentReaction,
|
|
};
|
|
});
|
|
|
|
let mockFetchChannelStats: jest.Mock;
|
|
jest.mock('@actions/remote/channel', () => {
|
|
const original = jest.requireActual('@actions/remote/channel');
|
|
mockFetchChannelStats = jest.fn(() => Promise.resolve({}));
|
|
return {
|
|
...original,
|
|
fetchChannelStats: mockFetchChannelStats,
|
|
};
|
|
});
|
|
|
|
beforeAll(() => {
|
|
// @ts-ignore
|
|
NetworkManager.getClient = () => mockClient;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await DatabaseManager.init([serverUrl]);
|
|
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
|
|
mockFetchChannelStats.mockClear();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await DatabaseManager.destroyServerDatabase(serverUrl);
|
|
});
|
|
|
|
describe('create, update & delete posts', () => {
|
|
it('createPost - handle database not found', async () => {
|
|
const result = await createPost('foo', post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - handle client error', async () => {
|
|
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
|
|
|
|
const result = await createPost(serverUrl, post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - handle existing failed post', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [{...post1, props: {failed: false}}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await createPost(serverUrl, {...post1, pending_post_id: post1.id});
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBe(false);
|
|
});
|
|
|
|
it('createPost - fail create', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(throwFunc));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - fail on deleted root post server error', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.DELETED_ROOT_POST_ERROR};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - fail on town square read only server error', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - fail on plugin dismissed post server error', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.PLUGIN_DISMISSED_POST_ERROR};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - should expire the channel decisions and keep the post failed when a channel policy denies it', async () => {
|
|
const expireEntry = jest.spyOn(RenderPermissionsStore, 'expireEntry');
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.CHANNEL_WRITE_ACCESS_DENIED, status_code: 403};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, {...post1, pending_post_id: 'pendingid1'});
|
|
expect(result.error).toBeUndefined();
|
|
expect(expireEntry).toHaveBeenCalledWith(serverUrl, channelId);
|
|
|
|
const stored = await getPostById(operator.database, 'pendingid1');
|
|
expect(stored?.props?.failed).toBe(true);
|
|
});
|
|
|
|
it('createPost - should revalidate the channel decisions when the server refuses a burn-on-read post', async () => {
|
|
const expireEntry = jest.spyOn(RenderPermissionsStore, 'expireEntry');
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: 'api.post.create_post.burn_on_read.abac_denied.app_error'};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
await createPost(serverUrl, post1);
|
|
|
|
expect(expireEntry).toHaveBeenCalledTimes(1);
|
|
expect(expireEntry).toHaveBeenCalledWith(serverUrl, channelId);
|
|
expireEntry.mockRestore();
|
|
});
|
|
|
|
it('createPost - should keep the channel decisions when the post fails for another reason', async () => {
|
|
const expireEntry = jest.spyOn(RenderPermissionsStore, 'expireEntry');
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
await createPost(serverUrl, post1);
|
|
|
|
expect(expireEntry).not.toHaveBeenCalled();
|
|
expireEntry.mockRestore();
|
|
});
|
|
|
|
it('createPost - root', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - should store the created post verified under the current epoch', async () => {
|
|
// Left unstamped, the author's own attachments would show the unverified placeholder and cost
|
|
// a re-check, and the channel's newest post would force a page fetch on every visit.
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleConfigs({
|
|
configs: [
|
|
{id: 'FeatureFlagPermissionPolicies', value: 'true'},
|
|
{id: 'EnableAttributeBasedAccessControl', value: 'true'},
|
|
],
|
|
configsToDelete: [],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
await createPost(serverUrl, post1);
|
|
|
|
const rows = await queryPostsById(operator.database, ['newid']).fetch();
|
|
expect(rows[0].redactionVerifiedEpoch).toBe(1);
|
|
});
|
|
|
|
it('createPost - without reactions', async () => {
|
|
mockAddRecentReaction.mockImplementationOnce(() => []);
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, post1);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBeTruthy();
|
|
});
|
|
|
|
it('createPost - reply', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await createPost(serverUrl, reply1, [fileInfo1]);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.data).toBeTruthy();
|
|
});
|
|
|
|
it('retryFailedPost - handle database not found', async () => {
|
|
const result = await retryFailedPost('foo', mockPostModel());
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('retryFailedPost - handle client error', async () => {
|
|
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
|
|
|
|
const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1}));
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('retryFailedPost - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1}));
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('retryFailedPost - handle error', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1}));
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('retryFailedPost - fail on deleted root post server error', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.DELETED_ROOT_POST_ERROR};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1}));
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('retryFailedPost - fail on town square read only server error', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1}));
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('retryFailedPost - fail on plugin dismissed post server error', async () => {
|
|
mockClient.createPost.mockImplementationOnce(jest.fn(() => {
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw {message: 'error', server_error_id: ServerErrors.PLUGIN_DISMISSED_POST_ERROR};
|
|
}));
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await retryFailedPost(serverUrl, mockPostModel({id: post1.id, prepareUpdate: jest.fn(), toApi: async () => post1}));
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('togglePinPost - handle database not found', async () => {
|
|
const result = await togglePinPost('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('togglePinPost - base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await togglePinPost(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
expect(result.post?.isPinned).toBe(true);
|
|
});
|
|
|
|
it('deletePost - handle error', async () => {
|
|
mockClient.deletePost.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await deletePost('foo', {} as PostModel);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('deletePost - base case', async () => {
|
|
const postModels = await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await deletePost(serverUrl, postModels[0] as PostModel);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
});
|
|
|
|
it('burnPostNow - handle error', async () => {
|
|
mockClient.burnPostNow.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await burnPostNow('foo', {} as PostModel);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('burnPostNow - base case', async () => {
|
|
mockClient.burnPostNow.mockReset();
|
|
const borPost = TestHelper.fakePost({channel_id: channelId, id: 'bor_postid1', user_id: user1.id, type: 'burn_on_read'});
|
|
|
|
const postModels = await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [borPost.id],
|
|
posts: [borPost],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
// verify post exists in database before burn
|
|
const {database} = operator;
|
|
const existingPost = await database.get('Post').find(borPost.id);
|
|
expect(existingPost).toBeDefined();
|
|
|
|
const result = await burnPostNow(serverUrl, postModels[0] as PostModel);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
|
|
// Verify that the post is deleted from the database
|
|
try {
|
|
await database.get('Post').find(borPost.id);
|
|
expect(true).toBe(false); // Should not reach here
|
|
} catch (error) {
|
|
expect(error).toBeDefined();
|
|
expect(getFullErrorMessage(error)).toBe('Record Post#bor_postid1 not found');
|
|
}
|
|
});
|
|
|
|
it('burnPostNow - returns error for non-BoR posts', async () => {
|
|
const result = await burnPostNow('foo', {} as PostModel);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
expect(result.error).toBe('Post is not a Burn-on-Read post');
|
|
});
|
|
|
|
it('deletePost - system post', async () => {
|
|
const postModels = await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [{...post1, id: `user-activity-${post1.id}`, type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType, props: {system_post_ids: [post1.id]}}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await deletePost(serverUrl, postModels[0] as PostModel);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
});
|
|
|
|
it('markPostAsUnread - handle error', async () => {
|
|
mockClient.deletePost.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await markPostAsUnread('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('markPostAsUnread - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
mockGetIsCRTEnabled.mockReturnValueOnce(false);
|
|
(mockClient.getChannel as jest.Mock).mockReturnValueOnce(channel1);
|
|
(mockClient.getChannelMember as jest.Mock).mockReturnValueOnce({
|
|
...channelMember1,
|
|
mention_count: 1,
|
|
urgent_mention_count: 2,
|
|
});
|
|
const markChannelAsUnreadSpy = jest.spyOn(LocalChannelActions, 'markChannelAsUnread').
|
|
mockResolvedValueOnce({member: TestHelper.fakeMyChannelModel()});
|
|
|
|
try {
|
|
const result = await markPostAsUnread(serverUrl, post1.id);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
expect(markChannelAsUnreadSpy).toHaveBeenCalledWith(serverUrl, {
|
|
channelId,
|
|
messageCount: 2,
|
|
mentionsCount: 1,
|
|
urgentMentionCount: 2,
|
|
lastViewed: post1.create_at,
|
|
});
|
|
} finally {
|
|
markChannelAsUnreadSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('markPostAsUnread - no current user', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await markPostAsUnread(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
});
|
|
|
|
it('editPost - handle error', async () => {
|
|
mockClient.deletePost.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await editPost('foo', '', '', [], []);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('editPost - base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await editPost(serverUrl, post1.id, 'new message', [], []);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
});
|
|
|
|
it('editPost - delete files', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const testFiles: FileInfo[] = [
|
|
TestHelper.fakeFileInfo({
|
|
id: 'file-1',
|
|
post_id: post1.id,
|
|
name: 'test-file-1.jpg',
|
|
extension: 'jpg',
|
|
size: 1024,
|
|
mime_type: 'image/jpeg',
|
|
user_id: user1.id,
|
|
}),
|
|
TestHelper.fakeFileInfo({
|
|
id: 'file-2',
|
|
post_id: post1.id,
|
|
name: 'test-file-2.png',
|
|
extension: 'png',
|
|
size: 2048,
|
|
mime_type: 'image/png',
|
|
user_id: user1.id,
|
|
}),
|
|
TestHelper.fakeFileInfo({
|
|
id: 'file-3',
|
|
post_id: post1.id,
|
|
name: 'test-file-3.pdf',
|
|
extension: 'pdf',
|
|
size: 4096,
|
|
mime_type: 'application/pdf',
|
|
user_id: user1.id,
|
|
}),
|
|
];
|
|
|
|
await operator.handleFiles({
|
|
files: testFiles,
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {database} = operator;
|
|
const filesBefore = await database.get('File').query().fetch();
|
|
expect(filesBefore).toHaveLength(3);
|
|
|
|
const file1Before = filesBefore.find((f) => f.id === 'file-1');
|
|
const file2Before = filesBefore.find((f) => f.id === 'file-2');
|
|
const file3Before = filesBefore.find((f) => f.id === 'file-3');
|
|
expect(file1Before).toBeDefined();
|
|
expect(file2Before).toBeDefined();
|
|
expect(file3Before).toBeDefined();
|
|
|
|
const result = await editPost(serverUrl, post1.id, 'new message', [], ['file-1', 'file-2']);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
|
|
const filesAfter = await database.get('File').query().fetch();
|
|
expect(filesAfter).toHaveLength(1);
|
|
|
|
const remainingFile = filesAfter[0];
|
|
expect(remainingFile.id).toBe('file-3');
|
|
|
|
const deletedFiles = await database.get('File').query().fetch();
|
|
const file1After = deletedFiles.find((f) => f.id === 'file-1');
|
|
const file2After = deletedFiles.find((f) => f.id === 'file-2');
|
|
expect(file1After).toBeUndefined();
|
|
expect(file2After).toBeUndefined();
|
|
});
|
|
|
|
it('editPost - should call fetchChannelStats when files are removed', async () => {
|
|
// Setup post with files
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const testFiles: FileInfo[] = [
|
|
TestHelper.fakeFileInfo({
|
|
id: 'file-1',
|
|
post_id: post1.id,
|
|
name: 'test-file-1.jpg',
|
|
extension: 'jpg',
|
|
size: 1024,
|
|
mime_type: 'image/jpeg',
|
|
user_id: user1.id,
|
|
}),
|
|
];
|
|
|
|
await operator.handleFiles({
|
|
files: testFiles,
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
mockFetchChannelStats.mockClear();
|
|
|
|
// Edit post to remove files
|
|
const result = await editPost(serverUrl, post1.id, 'new message', [], ['file-1']);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockFetchChannelStats).toHaveBeenCalledWith(serverUrl, post1.channel_id);
|
|
expect(mockFetchChannelStats).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('editPost - should call fetchChannelStats when files are added', async () => {
|
|
// Setup post without files
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
mockFetchChannelStats.mockClear();
|
|
|
|
// Edit post to add files
|
|
const result = await editPost(serverUrl, post1.id, 'new message', ['new-file-1'], []);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockFetchChannelStats).toHaveBeenCalledWith(serverUrl, post1.channel_id);
|
|
expect(mockFetchChannelStats).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('editPost - should NOT call fetchChannelStats when no files change', async () => {
|
|
// Setup post without files
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
mockFetchChannelStats.mockClear();
|
|
|
|
// Edit post without changing files
|
|
const result = await editPost(serverUrl, post1.id, 'new message', [], []);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockFetchChannelStats).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('editPost - should call fetchChannelStats when file IDs change', async () => {
|
|
// Setup post with files
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const testFiles: FileInfo[] = [
|
|
TestHelper.fakeFileInfo({
|
|
id: 'file-1',
|
|
post_id: post1.id,
|
|
name: 'test-file-1.jpg',
|
|
extension: 'jpg',
|
|
size: 1024,
|
|
mime_type: 'image/jpeg',
|
|
user_id: user1.id,
|
|
}),
|
|
];
|
|
|
|
await operator.handleFiles({
|
|
files: testFiles,
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
mockFetchChannelStats.mockClear();
|
|
|
|
// Edit post to replace files (remove one, add another)
|
|
const result = await editPost(serverUrl, post1.id, 'new message', ['new-file-2'], ['file-1']);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockFetchChannelStats).toHaveBeenCalledWith(serverUrl, post1.channel_id);
|
|
expect(mockFetchChannelStats).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('acknowledgePost - handle error', async () => {
|
|
mockClient.deletePost.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await acknowledgePost('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('acknowledgePost - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await acknowledgePost(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.model).toBeDefined();
|
|
expect(result.model?.metadata?.acknowledgements?.[0].acknowledged_at).toBe(acknowledgedTime);
|
|
});
|
|
|
|
it('unacknowledgePost - handle error', async () => {
|
|
mockClient.deletePost.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await unacknowledgePost('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('unacknowledgePost - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await unacknowledgePost(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.model).toBeDefined();
|
|
expect(result.model?.metadata?.acknowledgements?.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('get posts', () => {
|
|
it('fetchPostsForChannel - handle database not found', async () => {
|
|
const result = await fetchPostsForChannel('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostsForChannel - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [{
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
creator_id: user1.id,
|
|
} as Channel],
|
|
myChannels: [{
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 0,
|
|
} as ChannelMembership],
|
|
prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostsForChannel(serverUrl, channelId);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsForChannel - base case with since', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [{
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
creator_id: user1.id,
|
|
} as Channel],
|
|
myChannels: [{
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 0,
|
|
} as ChannelMembership],
|
|
prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await fetchPostsForChannel(serverUrl, channelId, true);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('refetchPostsForRedaction - should re-verify with a page after the since-fetch when the channel already has posts', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [{
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
creator_id: user1.id,
|
|
} as Channel],
|
|
myChannels: [{
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 0,
|
|
} as ChannelMembership],
|
|
prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
mockClient.getPosts.mockClear();
|
|
mockClient.getPostsSince.mockClear();
|
|
|
|
const result = await refetchPostsForRedaction(serverUrl, channelId);
|
|
|
|
// The page re-verifies what we hold; only the since-fetch carries deletions and edits.
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockClient.getPostsSince).toHaveBeenCalledTimes(1);
|
|
expect(mockClient.getPosts).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('fetchPosts - should stamp the epoch it was dispatched under, even when a later invalidation lands in flight', async () => {
|
|
await operator.handleConfigs({
|
|
configs: [
|
|
{id: 'FeatureFlagPermissionPolicies', value: 'true'},
|
|
{id: 'EnableAttributeBasedAccessControl', value: 'true'},
|
|
],
|
|
configsToDelete: [],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const stored = await fetchPosts(serverUrl, channelId);
|
|
expect(stored.error).toBeUndefined();
|
|
expect(stored.redactionVerifiedEpoch).toBe(1);
|
|
const rows = await queryPostsById(operator.database, [post1.id]).fetch();
|
|
expect(rows[0].redactionVerifiedEpoch).toBe(1);
|
|
|
|
// The response is still stored, so its new posts and edits are not lost, but under the epoch
|
|
// it was dispatched at: the invalidation that landed in flight leaves it behind the gate.
|
|
let requiredEpoch: number | undefined;
|
|
(mockClient.getPosts as jest.Mock).mockImplementationOnce(async () => {
|
|
requiredEpoch = await invalidateRedactionGlobally(serverUrl, RedactionInvalidationReason.GlobalPolicy);
|
|
return {order: [post1.id], posts: {[post1.id]: {...post1, message: 'edited in flight', update_at: post1.update_at + 1}}};
|
|
});
|
|
|
|
const superseded = await fetchPosts(serverUrl, channelId);
|
|
expect(superseded.posts).toHaveLength(1);
|
|
const after = await queryPostsById(operator.database, [post1.id]).fetch();
|
|
expect(after[0].message).toBe('edited in flight');
|
|
expect(after[0].redactionVerifiedEpoch).toBe(1);
|
|
expect(requiredEpoch).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsForChannel - should re-verify with a page after the since-fetch when cached posts are behind the required epoch', async () => {
|
|
// The persisted epoch replaces the in-memory stale flag precisely so this survives a restart:
|
|
// a since-fetch filters on UpdateAt, which an ABAC change never moves.
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleConfigs({
|
|
configs: [
|
|
{id: 'FeatureFlagPermissionPolicies', value: 'true'},
|
|
{id: 'EnableAttributeBasedAccessControl', value: 'true'},
|
|
],
|
|
configsToDelete: [],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
await operator.handleMyChannel({channels: [{
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
creator_id: user1.id,
|
|
} as Channel],
|
|
myChannels: [{
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 0,
|
|
} as ChannelMembership],
|
|
prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [{...post1, metadata: {redacted_file_count: 1}}],
|
|
prepareRecordsOnly: false,
|
|
redactionVerifiedEpoch: 1,
|
|
});
|
|
|
|
await invalidateRedactionGlobally(serverUrl, RedactionInvalidationReason.GlobalPolicy);
|
|
|
|
mockClient.getPosts.mockClear();
|
|
mockClient.getPostsSince.mockClear();
|
|
|
|
const result = await fetchPostsForChannel(serverUrl, channelId);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockClient.getPostsSince).toHaveBeenCalledTimes(1);
|
|
expect(mockClient.getPosts).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
describe('fetchPostsForChannel - re-verification settles once the newest page is re-stamped', () => {
|
|
// After an invalidation the first visit needs since + page. A second visit with no new
|
|
// invalidation must not page again: the page already re-verified everything it can.
|
|
const setupChannelWithPosts = async (count: number, metadata: PostMetadata = {redacted_file_count: 1}) => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleConfigs({
|
|
configs: [
|
|
{id: 'FeatureFlagPermissionPolicies', value: 'true'},
|
|
{id: 'EnableAttributeBasedAccessControl', value: 'true'},
|
|
],
|
|
configsToDelete: [],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
await operator.handleMyChannel({channels: [{
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
creator_id: user1.id,
|
|
} as Channel],
|
|
myChannels: [{
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 0,
|
|
} as ChannelMembership],
|
|
prepareRecordsOnly: false});
|
|
|
|
// Every post carries attachment evidence, newest first, all in one PostsInChannel interval,
|
|
// so only the page cap can explain the second visit not paging.
|
|
const posts = Array.from({length: count}, (_, i) => TestHelper.fakePost({
|
|
id: `settle-post-${i}`,
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
create_at: 100000 - i,
|
|
update_at: 100000 - i,
|
|
metadata,
|
|
}));
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: posts.map((p) => p.id),
|
|
posts,
|
|
prepareRecordsOnly: false,
|
|
redactionVerifiedEpoch: 1,
|
|
});
|
|
|
|
await invalidateRedactionGlobally(serverUrl, RedactionInvalidationReason.GlobalPolicy);
|
|
|
|
// The server returns the newest POST_CHUNK_SIZE posts for page 0, and nothing new since.
|
|
const page = posts.slice(0, General.POST_CHUNK_SIZE);
|
|
const pageResponse = {
|
|
posts: Object.fromEntries(page.map((p) => [p.id, p])),
|
|
order: page.map((p) => p.id),
|
|
};
|
|
(mockClient.getPosts as jest.Mock).mockImplementation(() => pageResponse);
|
|
(mockClient.getPostsSince as jest.Mock).mockImplementation(() => ({posts: {}, order: []}));
|
|
mockClient.getPosts.mockClear();
|
|
mockClient.getPostsSince.mockClear();
|
|
};
|
|
|
|
// getPosts is the shared genericGetPostsMock, so its original implementation is restored rather
|
|
// than the mock itself.
|
|
const originalGetPosts = genericGetPostsMock.getMockImplementation();
|
|
const originalGetPostsSince = (mockClient.getPostsSince as jest.Mock).getMockImplementation();
|
|
afterEach(() => {
|
|
(mockClient.getPosts as jest.Mock).mockImplementation(originalGetPosts);
|
|
(mockClient.getPostsSince as jest.Mock).mockImplementation(originalGetPostsSince);
|
|
});
|
|
|
|
it.each([
|
|
['exactly a page of cached posts (control)', 60],
|
|
['more cached posts than one page', 61],
|
|
])('should not page again on the second visit with %s', async (_label, count) => {
|
|
expect(General.POST_CHUNK_SIZE).toBe(60);
|
|
await setupChannelWithPosts(count);
|
|
|
|
const first = await fetchPostsForChannel(serverUrl, channelId);
|
|
expect(first.error).toBeUndefined();
|
|
expect(mockClient.getPosts).toHaveBeenCalledTimes(1);
|
|
|
|
(mockClient.getPosts as jest.Mock).mockClear();
|
|
const second = await fetchPostsForChannel(serverUrl, channelId);
|
|
expect(second.error).toBeUndefined();
|
|
expect(mockClient.getPosts).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not page for unverified posts whose rendering does not depend on the decision', async () => {
|
|
// Text-only posts are never re-stamped by anything, so counting them would page every visit.
|
|
await setupChannelWithPosts(1, {});
|
|
|
|
const result = await fetchPostsForChannel(serverUrl, channelId);
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockClient.getPostsSince).toHaveBeenCalledTimes(1);
|
|
expect(mockClient.getPosts).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('revalidatePostsBefore', () => {
|
|
const history = Array.from({length: 3}, (_, i) => TestHelper.fakePost({
|
|
id: `history-${i}`,
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
create_at: 5000 - (i * 100),
|
|
update_at: 5000 - (i * 100),
|
|
metadata: {redacted_file_count: 1},
|
|
}));
|
|
|
|
const setup = async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleConfigs({
|
|
configs: [
|
|
{id: 'FeatureFlagPermissionPolicies', value: 'true'},
|
|
{id: 'EnableAttributeBasedAccessControl', value: 'true'},
|
|
],
|
|
configsToDelete: [],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: history.map((p) => p.id),
|
|
posts: history,
|
|
prepareRecordsOnly: false,
|
|
redactionVerifiedEpoch: 1,
|
|
});
|
|
await invalidateRedactionGlobally(serverUrl, RedactionInvalidationReason.GlobalPolicy);
|
|
};
|
|
|
|
it('should re-stamp the block under the dispatch epoch without touching history or the loading state', async () => {
|
|
await setup();
|
|
const database = DatabaseManager.serverDatabases[serverUrl]!.database;
|
|
const intervalsBefore = (await queryPostsInChannel(database, channelId).fetch()).map((c) => [c.earliest, c.latest]);
|
|
|
|
// The block reaches past the stored interval: it must not be widened over it.
|
|
const older = TestHelper.fakePost({id: 'older', channel_id: channelId, user_id: user1.id, create_at: 4000, update_at: 4000});
|
|
const block = [history[1], history[2], older];
|
|
(mockClient.getPostsBefore as jest.Mock).mockImplementationOnce(() => ({
|
|
posts: Object.fromEntries(block.map((p) => [p.id, p])),
|
|
order: block.map((p) => p.id),
|
|
}));
|
|
const loading = jest.spyOn(EphemeralStore, 'addLoadingMessagesForChannel');
|
|
|
|
const result = await revalidatePostsBefore(serverUrl, channelId, history[0].id);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.oldestCreateAt).toBe(4000);
|
|
const rows = await queryPostsById(database, [history[0].id, history[1].id, history[2].id]).fetch();
|
|
const epochs = Object.fromEntries(rows.map((r) => [r.id, r.redactionVerifiedEpoch]));
|
|
expect(epochs).toEqual({[history[0].id]: 1, [history[1].id]: 2, [history[2].id]: 2});
|
|
expect((await queryPostsInChannel(database, channelId).fetch()).map((c) => [c.earliest, c.latest])).toEqual(intervalsBefore);
|
|
expect(loading).not.toHaveBeenCalled();
|
|
loading.mockRestore();
|
|
});
|
|
|
|
it('should return the error and leave the posts unverified when the page fails', async () => {
|
|
await setup();
|
|
(mockClient.getPostsBefore as jest.Mock).mockRejectedValueOnce(new Error('network down'));
|
|
|
|
const result = await revalidatePostsBefore(serverUrl, channelId, history[0].id);
|
|
|
|
expect(result.error).toBeTruthy();
|
|
const rows = await queryPostsById(DatabaseManager.serverDatabases[serverUrl]!.database, [history[1].id]).fetch();
|
|
expect(rows[0].redactionVerifiedEpoch).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('fetchPostsForChannel - should keep the since-fetch result when the re-verification page fails', async () => {
|
|
// The since-fetch already stored new content; a failed page only leaves older posts unverified.
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleConfigs({
|
|
configs: [
|
|
{id: 'FeatureFlagPermissionPolicies', value: 'true'},
|
|
{id: 'EnableAttributeBasedAccessControl', value: 'true'},
|
|
],
|
|
configsToDelete: [],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
await operator.handleMyChannel({channels: [{
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
creator_id: user1.id,
|
|
} as Channel],
|
|
myChannels: [{
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 0,
|
|
} as ChannelMembership],
|
|
prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [{...post1, metadata: {redacted_file_count: 1}}],
|
|
prepareRecordsOnly: false,
|
|
redactionVerifiedEpoch: 1,
|
|
});
|
|
|
|
await invalidateRedactionGlobally(serverUrl, RedactionInvalidationReason.GlobalPolicy);
|
|
mockClient.getPostsSince.mockClear();
|
|
mockClient.getPosts.mockClear();
|
|
(mockClient.getPosts as jest.Mock).mockRejectedValueOnce(new Error('network down'));
|
|
|
|
const result = await fetchPostsForChannel(serverUrl, channelId);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockClient.getPostsSince).toHaveBeenCalledTimes(1);
|
|
expect(mockClient.getPosts).toHaveBeenCalledTimes(1);
|
|
expect(result.posts).toHaveLength(2);
|
|
});
|
|
|
|
it('fetchPostsForChannel - no posts with since', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [{
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
creator_id: user1.id,
|
|
} as Channel],
|
|
myChannels: [{
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
user_id: user1.id,
|
|
msg_count: 0,
|
|
} as ChannelMembership],
|
|
prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
mockClient.getPostsSince.mockImplementationOnce(jest.fn(() => ({posts: {}, order: []})));
|
|
const result = await fetchPostsForChannel(serverUrl, channelId, true);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(0);
|
|
});
|
|
|
|
it('fetchPostsForChannel - request error', async () => {
|
|
mockClient.getPosts.mockImplementationOnce(jest.fn(throwFunc));
|
|
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostsForChannel(serverUrl, channelId);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('fetchPostsForChannel - should recover a channel whose newest interval lost all its posts', async () => {
|
|
// The only cached post is deleted server-side while the app is closed. On the next
|
|
// open the since-fetch returns the tombstone, handlePosts destroys the post, and the
|
|
// interval is left covering nothing - which would render the channel blank forever.
|
|
const only = TestHelper.fakePost({channel_id: channelId, id: 'only', user_id: user1.id, create_at: 5000, update_at: 5000});
|
|
mockClient.getPostsSince.mockImplementationOnce(jest.fn(() => ({posts: {only: {...only, delete_at: 6000, update_at: 6000}}, order: [only.id]})));
|
|
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleChannel({channels: [channel1], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_NEW,
|
|
order: [only.id],
|
|
posts: [only],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const database = DatabaseManager.serverDatabases[serverUrl]!.database;
|
|
mockClient.getPosts.mockClear();
|
|
|
|
await fetchPostsForChannel(serverUrl, channelId);
|
|
|
|
// the empty interval is gone and a page fetch repopulated the channel
|
|
expect(mockClient.getPosts).toHaveBeenCalled();
|
|
const intervals = await queryPostsInChannel(database, channelId).fetch();
|
|
expect(intervals).toHaveLength(1);
|
|
expect(intervals[0].latest).not.toBe(5000);
|
|
expect(await getRecentPostsInChannel(database, channelId)).not.toHaveLength(0);
|
|
});
|
|
|
|
it('refreshPostsForChannel - should do a plain fetch and keep cached posts when not blank', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const database = DatabaseManager.serverDatabases[serverUrl]!.database;
|
|
const before = await getPostById(database, post1.id);
|
|
expect(before).toBeDefined();
|
|
|
|
const result = await refreshPostsForChannel(serverUrl, channelId, false);
|
|
expect(result.error).toBeUndefined();
|
|
|
|
// the cached post was not wiped
|
|
expect(await getPostById(database, post1.id)).toBeDefined();
|
|
});
|
|
|
|
it('refreshPostsForChannel - should clear the cache and re-fetch when blank with hidden posts', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleChannel({channels: [channel1], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const database = DatabaseManager.serverDatabases[serverUrl]!.database;
|
|
mockClient.getPosts.mockClear();
|
|
mockClient.getPostsSince.mockClear();
|
|
|
|
const result = await refreshPostsForChannel(serverUrl, channelId, true);
|
|
expect(result.error).toBeUndefined();
|
|
|
|
// the stale interval was cleared, so a full page fetch runs instead of a
|
|
// since-fetch that would return nothing and leave the channel blank.
|
|
expect(await queryPostsInChannel(database, channelId).fetch()).toHaveLength(1);
|
|
expect(mockClient.getPosts).toHaveBeenCalled();
|
|
expect(mockClient.getPostsSince).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshPostsForChannel - should return the error and not fetch when clearing the cache fails', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleChannel({channels: [channel1], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false});
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const spyOnDelete = jest.spyOn(LocalChannelActions, 'deletePostsForChannel').
|
|
mockResolvedValueOnce({models: [], error: 'delete failed'});
|
|
mockClient.getPosts.mockClear();
|
|
mockClient.getPostsSince.mockClear();
|
|
|
|
const result = await refreshPostsForChannel(serverUrl, channelId, true);
|
|
|
|
expect(result.error).toBe('delete failed');
|
|
expect(mockClient.getPosts).not.toHaveBeenCalled();
|
|
expect(mockClient.getPostsSince).not.toHaveBeenCalled();
|
|
|
|
spyOnDelete.mockRestore();
|
|
});
|
|
|
|
it('refreshPostsForChannel - should not reset a genuinely empty channel', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false});
|
|
|
|
const spyOnDelete = jest.spyOn(LocalChannelActions, 'deletePostsForChannel');
|
|
|
|
const result = await refreshPostsForChannel(serverUrl, channelId, true);
|
|
expect(result.error).toBeUndefined();
|
|
expect(spyOnDelete).not.toHaveBeenCalled();
|
|
|
|
spyOnDelete.mockRestore();
|
|
});
|
|
|
|
it('fetchPostsForUnreadChannels - base case', async () => {
|
|
const spyOnProcessChannelPostsByTeam = jest.spyOn(PostAuxilaryFunctions, 'processChannelPostsByTeam');
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false});
|
|
|
|
await fetchPostsForUnreadChannels(serverUrl, [{id: teamId}] as Team[], [channel1, {...channel1, id: 'channelid2', total_msg_count: 10}], [{...channelMember1, msg_count: 5}, {...channelMember1, channel_id: 'channelid2', msg_count: 10}], 'testid');
|
|
|
|
expect(spyOnProcessChannelPostsByTeam).toHaveBeenCalledWith(serverUrl, ['channelid1'], false, undefined, undefined);
|
|
});
|
|
|
|
it('fetchPosts - handle database not found', async () => {
|
|
const result = await fetchPosts('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPosts - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPosts(serverUrl, channelId);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPosts - no CRT', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockGetIsCRTEnabled.mockImplementationOnce(() => false);
|
|
|
|
const result = await fetchPosts(serverUrl, channelId);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPosts - no authors needed', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => []));
|
|
|
|
const result = await fetchPosts(serverUrl, channelId);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsBefore - handle database not found', async () => {
|
|
const result = await fetchPostsBefore('foo', '', '', 50, true) as {error: unknown};
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostsBefore - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostsBefore(serverUrl, channelId, post1.id) as {
|
|
posts: Post[];
|
|
order: string[];
|
|
previousPostId: string | undefined;
|
|
};
|
|
expect(result).toBeDefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsBefore - no CRT', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockGetIsCRTEnabled.mockImplementationOnce(() => false);
|
|
|
|
const result = await fetchPostsBefore(serverUrl, channelId, post1.id) as {
|
|
posts: Post[];
|
|
order: string[];
|
|
previousPostId: string | undefined;
|
|
};
|
|
expect(result).toBeDefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsBefore - no authors needed', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => []));
|
|
|
|
const result = await fetchPostsBefore(serverUrl, channelId, post1.id) as {
|
|
posts: Post[];
|
|
order: string[];
|
|
previousPostId: string | undefined;
|
|
};
|
|
expect(result).toBeDefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsBefore - no posts', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockClient.getPostsBefore.mockImplementationOnce(jest.fn(() => ({posts: {}, order: []})));
|
|
|
|
const result = await fetchPostsBefore(serverUrl, channelId, post1.id) as {
|
|
posts: Post[];
|
|
order: string[];
|
|
previousPostId: string | undefined;
|
|
};
|
|
expect(result).toBeDefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(0);
|
|
});
|
|
|
|
it('fetchPostsSince - handle database not found', async () => {
|
|
const result = await fetchPostsSince('foo', '', 0);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostsSince - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostsSince(serverUrl, channelId, 123);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostAuthors - handle database not found', async () => {
|
|
const result = await fetchPostAuthors('foo', []);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostAuthors - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostAuthors(serverUrl, [{...post1, message: 'hi @user3'}, post2]);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.authors).toBeDefined();
|
|
expect(result.authors?.length).toBe(2); // 1 by id for user2, 1 by username for user3
|
|
});
|
|
|
|
it('fetchPostAuthors - no users to fetch', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostAuthors(serverUrl, [post1]);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.authors).toBeDefined();
|
|
expect(result.authors?.length).toBe(0);
|
|
});
|
|
|
|
it('fetchPostThread - handle database not found', async () => {
|
|
const result = await fetchPostThread('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostThread - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostThread(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
expect(result.posts?.[0].id).toBe(post1.id);
|
|
expect(result.posts?.[1].id).toBe(reply1.id);
|
|
});
|
|
|
|
it('fetchPostThread - no CRT', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockGetIsCRTEnabled.mockImplementationOnce(() => false);
|
|
|
|
const result = await fetchPostThread(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
expect(result.posts?.[0].id).toBe(post1.id);
|
|
expect(result.posts?.[1].id).toBe(reply1.id);
|
|
});
|
|
|
|
it('fetchPostThread - no authors needed', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => []));
|
|
|
|
const result = await fetchPostThread(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostThread - no posts', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockClient.getPostThread.mockImplementationOnce(jest.fn(() => ({posts: {}, order: []})));
|
|
|
|
const result = await fetchPostThread(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(0);
|
|
});
|
|
|
|
it('fetchPostsAround - handle database not found', async () => {
|
|
const result = await fetchPostsAround('foo', '', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostsAround - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostsAround(serverUrl, channelId, post2.id, 100, true);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsAround - no CRT', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockGetIsCRTEnabled.mockImplementationOnce(() => false);
|
|
|
|
const result = await fetchPostsAround(serverUrl, channelId, post2.id, 100, true);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPostsAround - no authors needed', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => []));
|
|
|
|
const result = await fetchPostsAround(serverUrl, channelId, post2.id, 100, true);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchMissingChannelsFromPosts - handle database not found', async () => {
|
|
const result = await fetchMissingChannelsFromPosts('foo', []);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchMissingChannelsFromPosts - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchMissingChannelsFromPosts(serverUrl, [post1]);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.channels).toBeDefined();
|
|
expect(result.channels?.[0].id).toBe(post1.channel_id);
|
|
});
|
|
|
|
it('fetchPostById - handle database not found', async () => {
|
|
const result = await fetchPostById('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPostById - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostById(serverUrl, post2.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
expect(result.post?.id).toBe(post2.id);
|
|
});
|
|
|
|
it('fetchPostById - no CRT', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockGetIsCRTEnabled.mockImplementationOnce(() => false);
|
|
|
|
const result = await fetchPostById(serverUrl, post2.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
expect(result.post?.id).toBe(post2.id);
|
|
});
|
|
|
|
it('fetchPostById - no authors needed', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
mockClient.getProfilesByIds.mockImplementationOnce(jest.fn(() => []));
|
|
|
|
const result = await fetchPostById(serverUrl, post2.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
expect(result.post?.id).toBe(post2.id);
|
|
});
|
|
|
|
it('fetchPostById - fetch only', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPostById(serverUrl, post2.id, true);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
expect(result.post?.id).toBe(post2.id);
|
|
});
|
|
|
|
it('fetchPostInfo - should return post info on success', async () => {
|
|
const result = await fetchPostInfo(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.postInfo).toBeDefined();
|
|
expect(result.postInfo?.channel_id).toBe(channelId);
|
|
expect(result.postInfo?.team_id).toBe(teamId);
|
|
expect(result.postInfo?.has_joined_channel).toBe(false);
|
|
});
|
|
|
|
it('fetchPostInfo - should return error when client throws', async () => {
|
|
mockClient.getPostInfo.mockImplementationOnce(throwFunc);
|
|
const result = await fetchPostInfo(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
expect(result.postInfo).toBeUndefined();
|
|
});
|
|
|
|
it('fetchSavedPosts - handle database not found', async () => {
|
|
const result = await fetchSavedPosts('foo');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchSavedPosts - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchSavedPosts(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
|
|
it('fetchPinnedPosts - handle database not found', async () => {
|
|
const result = await fetchPinnedPosts('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('fetchPinnedPosts - base case', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await fetchPinnedPosts(serverUrl, channel1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.posts).toBeTruthy();
|
|
expect(result.posts?.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('revealBoRPost', () => {
|
|
it('revealBoRPost - handle database not found', async () => {
|
|
const result = await revealBoRPost('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('revealBoRPost - handle client error', async () => {
|
|
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
|
|
|
|
const result = await revealBoRPost(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('revealBoRPost - handle error', async () => {
|
|
mockClient.revealBoRPost.mockImplementationOnce(jest.fn(throwFunc));
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [{...post1, props: {failed: false}}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await revealBoRPost(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('revealBoRPost - base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post1.id],
|
|
posts: [post1],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await revealBoRPost(serverUrl, post1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeDefined();
|
|
});
|
|
|
|
it('revealBoRPost - post not found', async () => {
|
|
const result = await revealBoRPost(serverUrl, 'nonexistent-post-id');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.post).toBeUndefined();
|
|
});
|
|
});
|