mattermost-mobile-upstream/app/actions/local/post.test.ts
Guillermo Vayá cb16a8eba0 feat(drafts): Phase 3.2 — route Draft deletion by origin
Generic relation/cache cleanup must never silently discard durable draft sync
intent. Strip drafts from every generic cascade and remove clean reply drafts
only at the confirmed-server-post-deletion origin.

- prepareDeletePost: drop post.drafts from the cascade.
- prepareDeleteChannel: drop channel.drafts from the cascade (channel
  delete/leave/purge now preserves Draft + DraftOutbox).
- deletePosts: drop the DELETE FROM Draft SQL (data-retention eviction preserves
  drafts + outbox).
- Add prepareDeleteCleanReplyDrafts(database, rootIds): removes only CLEAN reply
  drafts (no DraftOutbox row) for confirmed-deleted roots, never enqueues a
  DELETE; preserves reply drafts with pending local intent (local-wins); never
  touches channel drafts.
- Wire it into preparePostDeletion (the removePost/removePosts confirmed path).

Checks: npm run tsc clean; eslint --quiet clean; 241 Jest tests pass across the
post/channel/drafts query + post/draft action suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 14:39:18 +02:00

504 lines
18 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 {buildDraftOutboxId, getDraft, getDraftOutbox} from '@queries/servers/drafts';
import {getPostById} 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,
getUsersCountFromMentions,
updatePostTranslation,
} from './post';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type DraftModel from '@typings/database/models/servers/draft';
import type DraftOutboxModel from '@typings/database/models/servers/draft_outbox';
const {DRAFT, DRAFT_OUTBOX} = MM_TABLES.SERVER;
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
});
});
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('draft deletion-origin routing', () => {
const seedReplyDraft = async (rootId: string) => {
await operator.database.write(async () => {
await operator.database.get<DraftModel>(DRAFT).create((d) => {
d.channelId = channelId;
d.rootId = rootId;
d.message = 'a reply draft';
d.updateAt = 1;
});
}, 'seed-reply-draft');
};
const seedOutbox = async (rootId: string) => {
await operator.database.write(async () => {
await operator.database.get<DraftOutboxModel>(DRAFT_OUTBOX).create((o) => {
o._raw.id = buildDraftOutboxId(channelId, rootId);
o.channelId = channelId;
o.rootId = rootId;
o.teamId = 'team1';
o.operation = 'upsert';
o.generation = 1;
o.keepLocal = false;
o.attemptCount = 0;
o.nextAttemptAt = 0;
o.status = 'pending';
});
}, 'seed-outbox');
};
it('confirmed post deletion removes a CLEAN reply draft and enqueues no delete outbox row', async () => {
const rootPost = TestHelper.fakePost({id: 'cleanrootpostid', channel_id: channelId});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [rootPost.id],
posts: [rootPost],
prepareRecordsOnly: false,
});
await seedReplyDraft(rootPost.id);
const {error} = await removePost(serverUrl, rootPost);
expect(error).toBeUndefined();
// The post is gone and the clean reply draft under it was removed...
expect(await getPostById(operator.database, rootPost.id)).toBeUndefined();
expect(await getDraft(operator.database, channelId, rootPost.id)).toBeUndefined();
// ...but server cleanup owns the deletion, so no DraftOutbox row was created.
expect(await getDraftOutbox(operator.database, channelId, rootPost.id)).toBeUndefined();
});
it('confirmed post deletion preserves a DIRTY reply draft and its outbox (local-wins)', async () => {
const rootPost = TestHelper.fakePost({id: 'dirtyrootpostid', channel_id: channelId});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [rootPost.id],
posts: [rootPost],
prepareRecordsOnly: false,
});
await seedReplyDraft(rootPost.id);
await seedOutbox(rootPost.id);
const {error} = await removePost(serverUrl, rootPost);
expect(error).toBeUndefined();
expect(await getDraft(operator.database, channelId, rootPost.id)).toBeDefined();
expect(await getDraftOutbox(operator.database, channelId, rootPost.id)).toBeDefined();
});
it('cache eviction via deletePosts emits no Draft deletion and preserves drafts + outbox', async () => {
const rootId = 'cacheevictionrootid';
await seedReplyDraft(rootId);
await seedOutbox(rootId);
// The LokiJS test adapter's unsafeExecute only accepts { loki }, not { sqls }, so we
// intercept it to both bypass the invariant and capture the emitted SQL statements.
let capturedSqls: Array<[string, unknown[]]> = [];
const spy = jest.spyOn(operator.database.adapter, 'unsafeExecute').
mockImplementation((operations) => {
capturedSqls = (operations as {sqls: Array<[string, unknown[]]>}).sqls;
return Promise.resolve();
});
const {error} = await deletePosts(serverUrl, [rootId]);
expect(error).toBe(false);
// No emitted statement targets the Draft table (DraftOutbox is a different table name).
expect(capturedSqls.some(([sql]) => (/from\s+Draft\b/i).test(sql))).toBe(false);
spy.mockRestore();
// The draft and its outbox intent survive cache eviction.
expect(await getDraft(operator.database, channelId, rootId)).toBeDefined();
expect(await getDraftOutbox(operator.database, channelId, rootId)).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();
});
});