mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-04 01:48:59 +00:00
* auto cache rolling cleanup for posts * ai threads rolling cleanup * auto rolling cleanup of playbook runs * fix to import and use renamed module * makes post cleanup atomic by moving processing to sql layer * restore previous file viewer post id and use full error logging * performs vacuum as last step and removes unused code * return early on empty channel array * address gallery items without a post id * prevent drafts to be deleted as part of auto cache cleanups * unset channel oldest visible on channel switch * clear oldest visible on empty array * revert early return to prevent flickering * restore previous playbookrun id on unmount * fixes issue creating data retention deletion batches * move active threads root cleanup protection to SQL execution * notify observers of range changes in post lists * in flight protection for auto cache cleanup runs * add space around operator Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * show warnings as snackbars related to ephemeral mode settings snackbar can now be persistent for some warnings requiring the user to acknowledge reading the message by manually dismissing it. * adds warnings when mobile ephemeral mode settings change snackbar component has been augmented to show user dismissable messages that require user interaction and to support longer messages with title and description. A header icon continuously show when ephemeral mode or zero persistence is enabled for a particular server. * fix i18n * renders snackbar properly when changing from persistent to ephemeral versions * reset visibility for tooltip when ephemeral mode config changes * show ephemeral mode warnings only for active server * renames added tests to start with should... * guards against a race casing to wipe an untracked server * enqueue ephemeral mode config changes so wipe operations don't race * auto cache cleanup warning shows only for active server * shows snackbar instead of tooltip on ephemeral mode icon tap * drops virtual set to true from jest mocks of real modules this was causing some flaky tests that failed depending on ordering while running in CI. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
649 lines
27 KiB
TypeScript
649 lines
27 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {ActionType, Post} from '@constants';
|
|
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
|
import DatabaseManager from '@database/manager';
|
|
import {getMyChannel} from '@queries/servers/channel';
|
|
import {getPostById, queryPostsInChannel} from '@queries/servers/post';
|
|
import TestHelper from '@test/test_helper';
|
|
import {COMBINED_USER_ACTIVITY} from '@utils/post_list';
|
|
|
|
import {
|
|
sendAddToChannelEphemeralPost,
|
|
sendEphemeralPost,
|
|
removePost,
|
|
markPostAsDeleted,
|
|
storePostsForChannel,
|
|
getPosts,
|
|
addPostAcknowledgement,
|
|
removePostAcknowledgement,
|
|
deletePosts,
|
|
deletePostsInChannelsByCutoff,
|
|
getUsersCountFromMentions,
|
|
updatePostTranslation,
|
|
} from './post';
|
|
|
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
|
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
|
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
|
|
|
|
const {SERVER: {DRAFT, FILE, MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES;
|
|
|
|
const serverUrl = 'baseHandler.test.com';
|
|
let operator: ServerDataOperator;
|
|
|
|
let mockGenerateId: jest.Mock;
|
|
jest.mock('@utils/general', () => {
|
|
const original = jest.requireActual('@utils/general');
|
|
mockGenerateId = jest.fn(() => 'testpostid');
|
|
return {
|
|
...original,
|
|
generateId: mockGenerateId,
|
|
};
|
|
});
|
|
|
|
let mockGetIsCRTEnabled: jest.Mock;
|
|
jest.mock('@queries/servers/thread', () => {
|
|
const original = jest.requireActual('@queries/servers/thread');
|
|
mockGetIsCRTEnabled = jest.fn(() => true);
|
|
return {
|
|
...original,
|
|
getIsCRTEnabled: mockGetIsCRTEnabled,
|
|
};
|
|
});
|
|
|
|
const channelId = 'channelid1';
|
|
const user: UserProfile = TestHelper.fakeUser({
|
|
id: 'userid',
|
|
username: 'username',
|
|
roles: '',
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await DatabaseManager.init([serverUrl]);
|
|
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await DatabaseManager.destroyServerDatabase(serverUrl);
|
|
});
|
|
|
|
describe('sendAddToChannelEphemeralPost', () => {
|
|
it('handle not found database', async () => {
|
|
const {posts, error} = await sendAddToChannelEphemeralPost('foo', TestHelper.fakeUserModel(), ['username2'], ['added username2'], channelId, '');
|
|
expect(posts).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('base case', async () => {
|
|
const users = await operator.handleUsers({users: [user], prepareRecordsOnly: false});
|
|
|
|
const {posts, error} = await sendAddToChannelEphemeralPost(serverUrl, users[0], ['username2'], ['added username2'], channelId);
|
|
expect(error).toBeUndefined();
|
|
expect(posts).toBeDefined();
|
|
expect(posts?.length).toBe(1);
|
|
expect(posts![0].message).toBe('added username2');
|
|
});
|
|
});
|
|
|
|
describe('sendEphemeralPost', () => {
|
|
it('handle not found database', async () => {
|
|
const {post, error} = await sendEphemeralPost('foo', 'message', channelId, '', user.id);
|
|
expect(post).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('handle no channel', async () => {
|
|
const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', '', '', user.id);
|
|
expect(post).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('handle no user', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'useridcurrent'}], prepareRecordsOnly: false});
|
|
|
|
const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', channelId);
|
|
expect(error).toBeUndefined();
|
|
expect(post).toBeDefined();
|
|
expect(post?.user_id).toBe('useridcurrent');
|
|
});
|
|
|
|
it('base case', async () => {
|
|
const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', channelId, '', user.id);
|
|
expect(error).toBeUndefined();
|
|
expect(post).toBeDefined();
|
|
expect(post?.message).toBe('newmessage');
|
|
});
|
|
});
|
|
|
|
describe('removePost', () => {
|
|
const post = TestHelper.fakePost({id: 'postid', channel_id: channelId});
|
|
|
|
it('handle not found database', async () => {
|
|
const {post: rPost, error} = await removePost('foo', post);
|
|
expect(rPost).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [post],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {post: rPost, error} = await removePost(serverUrl, post);
|
|
expect(error).toBeUndefined();
|
|
expect(rPost).toBeDefined();
|
|
});
|
|
|
|
it('base case - missing post', async () => {
|
|
const {post: rPost, error} = await removePost(serverUrl, post);
|
|
expect(error).toBeUndefined();
|
|
expect(rPost).toBeDefined();
|
|
});
|
|
|
|
it('base case - system message', async () => {
|
|
const systemPost = TestHelper.fakePost({channel_id: channelId, id: `${COMBINED_USER_ACTIVITY}id1_id2`, type: Post.POST_TYPES.COMBINED_USER_ACTIVITY, props: {system_post_ids: ['id1']}});
|
|
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id, 'id1'],
|
|
posts: [systemPost, TestHelper.fakePost({id: 'id1', channel_id: channelId})],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {post: rPost, error} = await removePost(serverUrl, systemPost);
|
|
expect(error).toBeUndefined();
|
|
expect(rPost).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('markPostAsDeleted', () => {
|
|
const post = TestHelper.fakePost({channel_id: channelId});
|
|
|
|
it('handle not found database', async () => {
|
|
const {model, error} = await markPostAsDeleted('foo', post);
|
|
expect(model).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('handle no post', async () => {
|
|
const {model, error} = await markPostAsDeleted(serverUrl, post, false);
|
|
expect(model).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [post],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {model, error} = await markPostAsDeleted(serverUrl, post, false);
|
|
expect(error).toBeUndefined();
|
|
expect(model).toBeDefined();
|
|
expect(model?.deleteAt).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('storePostsForChannel', () => {
|
|
const post = TestHelper.fakePost({channel_id: channelId, user_id: user.id});
|
|
const teamId = 'tId1';
|
|
const channel: Channel = TestHelper.fakeChannel({
|
|
id: channelId,
|
|
team_id: teamId,
|
|
total_msg_count: 0,
|
|
});
|
|
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
|
|
id: 'id',
|
|
channel_id: channelId,
|
|
msg_count: 0,
|
|
});
|
|
|
|
it('handle not found database', async () => {
|
|
const {models, error} = await storePostsForChannel('foo', channelId, [post], [post.id], '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user], false);
|
|
expect(models).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('base case - CRT on', async () => {
|
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
|
|
|
const {models, error} = await storePostsForChannel(serverUrl, channelId, [post], [post.id], '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user], false);
|
|
expect(error).toBeUndefined();
|
|
expect(models).toBeDefined();
|
|
expect(models?.length).toBe(5); // Post, PostsInChannel, User, MyChannel, Thread
|
|
});
|
|
|
|
it('base case - CRT off', async () => {
|
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
|
mockGetIsCRTEnabled.mockImplementationOnce(() => false);
|
|
|
|
const {models, error} = await storePostsForChannel(serverUrl, channelId, [post], [post.id], '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user]);
|
|
expect(error).toBeUndefined();
|
|
expect(models).toBeDefined();
|
|
expect(models?.length).toBe(4); // Post, PostsInChannel, User, MyChannel
|
|
});
|
|
|
|
it('should not advance lastFetchedAt for a thread payload', async () => {
|
|
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
|
|
|
|
const seed = [100, 105, 110].map((t) => TestHelper.fakePost({
|
|
id: `seed-${t}`, channel_id: channelId, user_id: user.id, create_at: t, update_at: t,
|
|
}));
|
|
await storePostsForChannel(serverUrl, channelId, seed, seed.map((p) => p.id), '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user]);
|
|
|
|
const database = DatabaseManager.serverDatabases[serverUrl]!.database;
|
|
expect((await getMyChannel(database, channelId))?.lastFetchedAt).toBe(110);
|
|
const intervalsBefore = await queryPostsInChannel(database, channelId).fetch();
|
|
expect(intervalsBefore.length).toBe(1);
|
|
|
|
// A thread reply far newer than the channel tail, as delivered by a push notification.
|
|
const root = TestHelper.fakePost({id: 'root', channel_id: channelId, user_id: user.id, create_at: 90, update_at: 90});
|
|
const reply = TestHelper.fakePost({id: 'reply', channel_id: channelId, user_id: user.id, create_at: 200, update_at: 200, root_id: 'root'});
|
|
await storePostsForChannel(serverUrl, channelId, [root, reply], [reply.id, root.id], '', ActionType.POSTS.RECEIVED_IN_THREAD, [user]);
|
|
|
|
// The watermark must stay put: posts created between 110 and 200 were never fetched,
|
|
// and getPostsSince would skip them forever if it moved.
|
|
expect((await getMyChannel(database, channelId))?.lastFetchedAt).toBe(110);
|
|
|
|
const intervalsAfter = await queryPostsInChannel(database, channelId).fetch();
|
|
expect(intervalsAfter.length).toBe(1);
|
|
expect(intervalsAfter[0].latest).toBe(110);
|
|
});
|
|
});
|
|
|
|
describe('getPosts', () => {
|
|
const post = TestHelper.fakePost({channel_id: channelId});
|
|
|
|
it('handle not found database', async () => {
|
|
const posts = await getPosts('foo', [post.id]);
|
|
expect(posts).toBeDefined();
|
|
expect(posts?.length).toBe(0);
|
|
});
|
|
|
|
it('base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [post],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const posts = await getPosts(serverUrl, [post.id]);
|
|
expect(posts).toBeDefined();
|
|
expect(posts.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('addPostAcknowledgement', () => {
|
|
const post = TestHelper.fakePost({channel_id: channelId});
|
|
|
|
it('handle not found database', async () => {
|
|
const {model, error} = await addPostAcknowledgement('foo', post.id, user.id, 123, false);
|
|
expect(model).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('handle no post', async () => {
|
|
const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123, false);
|
|
expect(error).toBeDefined();
|
|
expect(model).toBeUndefined();
|
|
});
|
|
|
|
it('handle already acked', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [{...post, metadata: {acknowledgements: [{user_id: user.id, post_id: post.id, acknowledged_at: 1}]}}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123, false);
|
|
expect(error).toBeDefined();
|
|
expect(error).toBe(false);
|
|
expect(model).toBeUndefined();
|
|
});
|
|
|
|
it('base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [post],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123);
|
|
expect(error).toBeUndefined();
|
|
expect(model).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('removePostAcknowledgement', () => {
|
|
const post = TestHelper.fakePost({channel_id: channelId});
|
|
|
|
it('handle not found database', async () => {
|
|
const {model, error} = await removePostAcknowledgement('foo', post.id, user.id, false);
|
|
expect(model).toBeUndefined();
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('handle no post', async () => {
|
|
const {model, error} = await removePostAcknowledgement(serverUrl, post.id, user.id, false);
|
|
expect(error).toBeDefined();
|
|
expect(model).toBeUndefined();
|
|
});
|
|
|
|
it('base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [{...post, metadata: {acknowledgements: [{user_id: user.id, post_id: post.id, acknowledged_at: 1}]}}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {model, error} = await removePostAcknowledgement(serverUrl, post.id, user.id);
|
|
expect(error).toBeUndefined();
|
|
expect(model).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('deletePosts', () => {
|
|
const post = TestHelper.fakePost({channel_id: channelId});
|
|
|
|
it('handle not found database', async () => {
|
|
const {error} = await deletePosts('foo', [post.id]);
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('base case', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [post],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {error} = await deletePosts(serverUrl, [post.id, 'id2']);
|
|
expect(error).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('getUsersCountFromMentions', () => {
|
|
it('handle not found database', async () => {
|
|
const num = await getUsersCountFromMentions('foo', []);
|
|
expect(num).toBe(0);
|
|
});
|
|
|
|
it('base case', async () => {
|
|
await operator.handleUsers({users: [user], prepareRecordsOnly: false});
|
|
|
|
const num = await getUsersCountFromMentions(serverUrl, [user.username]);
|
|
expect(num).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('updatePostTranslation', () => {
|
|
const post = TestHelper.fakePost({id: 'postid4', channel_id: channelId});
|
|
|
|
it('post not found', async () => {
|
|
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
|
const result = await updatePostTranslation(serverUrl, 'nonexistent', 'es', translation);
|
|
expect(result.error).toBe('Post not found');
|
|
});
|
|
|
|
it('updates post metadata with translation', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [post],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
|
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
|
|
expect(result.error).toBeUndefined();
|
|
|
|
const updatedPost = await getPostById(operator.database, post.id);
|
|
expect(updatedPost).toBeDefined();
|
|
expect(updatedPost?.metadata?.translations?.es).toEqual(translation);
|
|
});
|
|
|
|
it('handle database write error', async () => {
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [post.id],
|
|
posts: [post],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const originalWrite = operator.database.write;
|
|
operator.database.write = jest.fn().mockRejectedValue(new Error('Write failed'));
|
|
|
|
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
|
|
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
|
|
|
|
operator.database.write = originalWrite;
|
|
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('deletePostsInChannelsByCutoff', () => {
|
|
const CUTOFF = 50_000_000;
|
|
const OLD = 10_000_000;
|
|
const RECENT = 90_000_000;
|
|
|
|
const postsInChannel = [
|
|
TestHelper.fakePost({channel_id: 'cId', create_at: OLD}),
|
|
TestHelper.fakePost({channel_id: 'cId', create_at: RECENT}),
|
|
];
|
|
|
|
async function writeRootPost(id: string, chanId: string, createAt: number): Promise<void> {
|
|
await operator.database.write(async () => {
|
|
await operator.database.get(POST).create((r: any) => {
|
|
r._raw.id = id;
|
|
r.channelId = chanId;
|
|
r.createAt = createAt;
|
|
r.deleteAt = 0;
|
|
r.editAt = 0;
|
|
r.isPinned = false;
|
|
r.message = '';
|
|
r.messageSource = '';
|
|
r.originalId = '';
|
|
r.pendingPostId = '';
|
|
r.previousPostId = '';
|
|
r.props = '{}';
|
|
r.rootId = '';
|
|
r.type = '';
|
|
r.updateAt = 0;
|
|
r.userId = '';
|
|
});
|
|
});
|
|
}
|
|
|
|
async function writePostsInThread(rootId: string, earliest: number, latest: number): Promise<PostsInThreadModel> {
|
|
let record!: PostsInThreadModel;
|
|
await operator.database.write(async () => {
|
|
record = await operator.database.get<PostsInThreadModel>(POSTS_IN_THREAD).create((r) => {
|
|
r.rootId = rootId;
|
|
r.earliest = earliest;
|
|
r.latest = latest;
|
|
});
|
|
});
|
|
return record;
|
|
}
|
|
|
|
async function writeMyChannel(chanId: string, lastFetchedAt: number): Promise<MyChannelModel> {
|
|
let record!: MyChannelModel;
|
|
await operator.database.write(async () => {
|
|
record = await operator.database.get<MyChannelModel>(MY_CHANNEL).create((r) => {
|
|
r._raw.id = chanId;
|
|
r.lastPostAt = 0;
|
|
r.lastFetchedAt = lastFetchedAt;
|
|
r.lastViewedAt = 0;
|
|
r.manuallyUnread = false;
|
|
r.messageCount = 0;
|
|
r.mentionsCount = 0;
|
|
r.isUnread = false;
|
|
r.roles = '';
|
|
r.viewedAt = 0;
|
|
r.lastPlaybookRunsFetchAt = 0;
|
|
r.autotranslationDisabled = false;
|
|
});
|
|
});
|
|
return record;
|
|
}
|
|
|
|
it('handle not found database', async () => {
|
|
const {error} = await deletePostsInChannelsByCutoff('foo', [channelId], CUTOFF);
|
|
expect(error).toBeTruthy();
|
|
});
|
|
|
|
it('should return count of posts matched by cutoff', async () => {
|
|
jest.spyOn(operator.database.adapter, 'unsafeExecute').mockResolvedValue();
|
|
jest.spyOn(operator.database.adapter.underlyingAdapter, 'count').mockImplementation((_query, callback) => callback({value: 2}));
|
|
const oldPosts = [
|
|
TestHelper.fakePost({channel_id: channelId, create_at: OLD}),
|
|
TestHelper.fakePost({channel_id: channelId, create_at: OLD}),
|
|
];
|
|
const recentPost = TestHelper.fakePost({channel_id: channelId, create_at: RECENT});
|
|
|
|
await operator.handlePosts({
|
|
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
|
order: [oldPosts[0].id, oldPosts[1].id, recentPost.id],
|
|
posts: [...oldPosts, recentPost],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const {error, deletedCount} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF);
|
|
|
|
expect(error).toBeUndefined();
|
|
expect(deletedCount).toBe(2);
|
|
});
|
|
|
|
// A cached model whose earliest advances has to go through the model layer (fires observers)
|
|
it('advances the cached PostsInChannel earliest through the model layer', async () => {
|
|
jest.spyOn(operator.database.adapter, 'unsafeExecute').mockResolvedValue();
|
|
jest.spyOn(operator.database.adapter.underlyingAdapter, 'count').mockImplementation((_query, callback) => callback({value: 0}));
|
|
const [pic] = await operator.handleReceivedPostsInChannel(postsInChannel);
|
|
|
|
await deletePostsInChannelsByCutoff(serverUrl, ['cId'], CUTOFF);
|
|
|
|
expect(pic.earliest).toBe(CUTOFF);
|
|
});
|
|
|
|
it('advances the cached PostsInThread earliest through the model layer', async () => {
|
|
jest.spyOn(operator.database.adapter, 'unsafeExecute').mockResolvedValue();
|
|
jest.spyOn(operator.database.adapter.underlyingAdapter, 'count').mockImplementation((_query, callback) => callback({value: 0}));
|
|
await writeRootPost('rootId', 'cId', OLD);
|
|
const pit = await writePostsInThread('rootId', OLD, RECENT);
|
|
|
|
await deletePostsInChannelsByCutoff(serverUrl, ['cId'], CUTOFF);
|
|
|
|
expect(pit.earliest).toBe(CUTOFF);
|
|
});
|
|
|
|
it('resets the cached MyChannel lastFetchedAt when no PostsInChannel range survives for the channel', async () => {
|
|
jest.spyOn(operator.database.adapter, 'unsafeExecute').mockResolvedValue();
|
|
jest.spyOn(operator.database.adapter.underlyingAdapter, 'count').mockImplementation((_query, callback) => callback({value: 0}));
|
|
const myChannel = await writeMyChannel('cId', RECENT);
|
|
|
|
await deletePostsInChannelsByCutoff(serverUrl, ['cId'], CUTOFF);
|
|
|
|
expect(myChannel.lastFetchedAt).toBe(0);
|
|
});
|
|
|
|
it('leaves the cached MyChannel lastFetchedAt untouched when a PostsInChannel range still exists for the channel', async () => {
|
|
jest.spyOn(operator.database.adapter, 'unsafeExecute').mockResolvedValue();
|
|
await operator.handleReceivedPostsInChannel(postsInChannel);
|
|
const myChannel = await writeMyChannel('cId', RECENT);
|
|
|
|
await deletePostsInChannelsByCutoff(serverUrl, ['cId'], CUTOFF);
|
|
|
|
expect(myChannel.lastFetchedAt).toBe(RECENT);
|
|
});
|
|
|
|
it('includes the PostsInChannel destroy/update and MyChannel reset, in that order, in the same unsafeExecute call as the post delete', async () => {
|
|
const database = operator.database;
|
|
jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
|
jest.spyOn(database.adapter.underlyingAdapter, 'count').mockImplementation((_query, callback) => callback({value: 0}));
|
|
|
|
const {error} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF);
|
|
|
|
expect(error).toBeUndefined();
|
|
const hasActiveReply = `EXISTS (SELECT 1 FROM ${POSTS_IN_THREAD} WHERE ${POSTS_IN_THREAD}.root_id = ${POST}.id AND ${POSTS_IN_THREAD}.latest >= ${CUTOFF})`;
|
|
const hasDraft = `EXISTS (SELECT 1 FROM ${DRAFT} WHERE ${DRAFT}.root_id = ${POST}.id)`;
|
|
const postCondition = `channel_id IN (?) AND create_at < ${CUTOFF} AND NOT ${hasActiveReply} AND NOT ${hasDraft}`;
|
|
const postSubquery = `SELECT id FROM ${POST} WHERE ${postCondition}`;
|
|
const rootInChannelsExists = `EXISTS (SELECT 1 FROM ${POST} WHERE ${POST}.id = ${POSTS_IN_THREAD}.root_id AND ${POST}.channel_id IN (?))`;
|
|
expect(database.adapter.unsafeExecute).toHaveBeenCalledWith({
|
|
sqls: [
|
|
[`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, [channelId]],
|
|
[`DELETE FROM ${FILE} WHERE post_id IN (${postSubquery})`, [channelId]],
|
|
[`DELETE FROM ${POSTS_IN_THREAD} WHERE latest < ${CUTOFF} AND ${rootInChannelsExists}`, [channelId]],
|
|
[`UPDATE ${POSTS_IN_THREAD} SET earliest = ${CUTOFF} WHERE earliest < ${CUTOFF} AND ${rootInChannelsExists}`, [channelId]],
|
|
[`DELETE FROM ${THREAD} WHERE id IN (${postSubquery})`, [channelId]],
|
|
[`DELETE FROM ${THREAD_PARTICIPANT} WHERE thread_id IN (${postSubquery})`, [channelId]],
|
|
[`DELETE FROM ${THREADS_IN_TEAM} WHERE thread_id IN (${postSubquery})`, [channelId]],
|
|
[`DELETE FROM ${POST} WHERE ${postCondition}`, [channelId]],
|
|
[`DELETE FROM ${POSTS_IN_CHANNEL} WHERE channel_id IN (?) AND latest < ${CUTOFF}`, [channelId]],
|
|
[`UPDATE ${POSTS_IN_CHANNEL} SET earliest = ${CUTOFF} WHERE channel_id IN (?) AND earliest < ${CUTOFF}`, [channelId]],
|
|
[`UPDATE ${MY_CHANNEL} SET last_fetched_at = 0 WHERE id IN (?) AND last_fetched_at > 0 AND NOT EXISTS (SELECT 1 FROM ${POSTS_IN_CHANNEL} WHERE channel_id = ${MY_CHANNEL}.id)`, [channelId]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it('returns an error and no deleted count when the underlying transaction fails', async () => {
|
|
const database = operator.database;
|
|
jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.reject(new Error('fail')));
|
|
|
|
const {error, deletedCount} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF);
|
|
|
|
expect(error).toBeTruthy();
|
|
expect(deletedCount).toBe(0);
|
|
});
|
|
|
|
it('returns an error without deleting anything when counting matched posts fails', async () => {
|
|
const database = operator.database;
|
|
jest.spyOn(database.adapter.underlyingAdapter, 'count').mockImplementation((_query, callback) => {
|
|
callback({error: new Error('count failed')});
|
|
});
|
|
const unsafeExecuteSpy = jest.spyOn(database.adapter, 'unsafeExecute');
|
|
|
|
const {error, deletedCount} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF);
|
|
|
|
expect(error).toBeTruthy();
|
|
expect(deletedCount).toBe(0);
|
|
expect(unsafeExecuteSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('scopes the post delete and its dependent subqueries to exclude the given post IDs', async () => {
|
|
const database = operator.database;
|
|
jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
|
jest.spyOn(database.adapter.underlyingAdapter, 'count').mockImplementation((_query, callback) => callback({value: 0}));
|
|
|
|
const {error} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF, new Set(['excluded-1', 'excluded-2']));
|
|
|
|
expect(error).toBeUndefined();
|
|
const hasActiveReply = `EXISTS (SELECT 1 FROM ${POSTS_IN_THREAD} WHERE ${POSTS_IN_THREAD}.root_id = ${POST}.id AND ${POSTS_IN_THREAD}.latest >= ${CUTOFF})`;
|
|
const hasDraft = `EXISTS (SELECT 1 FROM ${DRAFT} WHERE ${DRAFT}.root_id = ${POST}.id)`;
|
|
const postCondition = `channel_id IN (?) AND create_at < ${CUTOFF} AND NOT ${hasActiveReply} AND NOT ${hasDraft} AND id NOT IN (?,?)`;
|
|
const postSubquery = `SELECT id FROM ${POST} WHERE ${postCondition}`;
|
|
const postConditionArgs = [channelId, 'excluded-1', 'excluded-2'];
|
|
expect(database.adapter.unsafeExecute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sqls: expect.arrayContaining([
|
|
[`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, postConditionArgs],
|
|
[`DELETE FROM ${POST} WHERE ${postCondition}`, postConditionArgs],
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|