mattermost-mobile-upstream/app/utils/post/index.test.ts
Pablo Vélez 7df4a0ab97
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
Mm 68219 show placeholder for redacted files (#9719)
* MM-68219 - Add redacted files placeholder and related functionality

* add feature flag control and e2e tests

* adjust placeholder design, polish code, fix permalink preview issue

* polish code and add missing test

* use import path with right path

* gate post refetch behind the feature flag, refetch the open thread and avoid observeCount

* restore filesInfo for the permalink files prop added upstream

* fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter

* replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden

* raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged

* store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival

* nvalidate file decisions when a user attribute field changes or all its values are cleared

* batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only  re-verify the newest page's attachment posts

* re-check older channel posts a page at a time instead of one request per post

* skip the unstamped epoch lookup for posts with no files, redacted count or permalink

* keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check

* MM-70905 - disable and grey out the attachment button when an ABAC pe… (#10195)

* MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel

* only back off render permission fetches when the server request fails, and log other errors without retrying

* remove the disableServer helper and have removeServer do both steps

* split defaultAllowed into allowWhenUnenforced and allowWhenNotEvaluated, and treat render permissions as unenforced  on servers without the decisions API

* MM-69627: Add playbook task requirements support on mobile (#10035)

* MM-69627: Add playbook task requirements support on mobile

Allow filling required task fields when checking off checklist items during
a run, gated by the same Playbooks beta setting used on web.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address PR review feedback for task requirements

Propagate settings write failures, harden fill-requirements save/title handling, and add missing unit coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix TypeScript errors in new unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Use defineMessages for fill requirements titles

Keep i18n extraction able to find the header titles after moving them to the route.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address review feedback for task requirements

Update checklist items locally when saving requirement values instead of
refetching the run, add missing query/navigation tests, and apply small
callback/reduce cleanups from review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix task requirements review follow-ups

Propagate local checklist write failures, clear a stale task-requirements
flag when playbooks is unsupported, and cap fill values at the desktop/
server max length.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Move requirement field into its own component

Keep one component per file on the fill-requirements screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69122: Add file element support to interactive dialogs (#9898)

* Implement Interactive Dialog - file upload

* update demo plugin version for e2etests

* fix and add e2etests

* review fixes

* Fix version bump checkout when a tag shares the branch name (#10229)

* Fix Android crashes caused by animated custom emoji (#10030)

* Route GIF decoding through Glide

- Keep APNG4Android for APNG, animated WebP, and animated AVIF support.
- Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow.

* Clear Expo image resources with Glide targets

- Detach drawables when Glide clears their custom target.
- Prevent recycled GIF frame resources from remaining attached to Expo image views.

* Preserve foreground image during GIF target recycling

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>

* detox - fix clobbering of feature flags that cause failures in other suites.  Also small fix of a incorrectly set test-id

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>
Co-authored-by: Nate Schlossberg <nate.schlossberg@mattermost.com>
2026-10-15 15:59:13 -07:00

840 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createIntl} from 'react-intl';
import {Alert} from 'react-native';
import {getUsersCountFromMentions} from '@actions/local/post';
import {General, Post} from '@constants';
import {mockedPosts} from '@database/operator/utils/mock';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {getUserById} from '@queries/servers/user';
import TestHelper from '@test/test_helper';
import {toMilliseconds} from '@utils/datetime';
import {
areConsecutivePosts,
hasAiGeneratedMetadata,
isFromWebhook,
isEdited,
isPostEphemeral,
restoreEphemeralIdentityFieldsForEdit,
isPostFailed,
isPostPendingOrFailed,
isSystemMessage,
fromAutoResponder,
postUserDisplayName,
shouldIgnorePost,
processPostsFetched,
getLastFetchedAtFromPosts,
moreThan5minAgo,
hasSpecialMentions,
persistentNotificationsConfirmation,
scheduledPostFromPost,
hasInteractivePostContent,
isPermalinkEmbedRedacted,
getPermalinkRedactedFileCount,
isAttachmentDecisionCurrent,
} from './index';
jest.mock('@actions/local/post', () => ({
getUsersCountFromMentions: jest.fn(),
}));
jest.mock('@queries/servers/user', () => ({
getUserById: jest.fn(),
}));
jest.mock('@database/manager', () => ({
getServerDatabaseAndOperator: jest.fn().mockReturnValue({
database: {},
}),
}));
describe('post utils', () => {
describe('hasAiGeneratedMetadata', () => {
it('should return true when both ai_generated_by and ai_generated_by_username are non-empty strings', () => {
const post = TestHelper.fakePostModel({
props: {
ai_generated_by: 'bot-user-id',
ai_generated_by_username: 'ai-bot',
},
});
expect(hasAiGeneratedMetadata(post)).toBe(true);
});
it('should return false when metadata is missing or invalid', () => {
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by_username: 'ai-bot'},
}))).toBe(false);
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by: 'bot-user-id'},
}))).toBe(false);
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by: '', ai_generated_by_username: ''},
}))).toBe(false);
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by: 123, ai_generated_by_username: true},
}))).toBe(false);
});
});
describe('areConsecutivePosts', () => {
it('should return true for consecutive posts from the same user within the collapse timeout', () => {
const post = TestHelper.fakePostModel({
userId: 'user1',
createAt: 1000,
props: {},
});
const previousPost = TestHelper.fakePostModel({
userId: 'user1',
createAt: 500,
props: {},
});
const result = areConsecutivePosts(post, previousPost, 'en');
expect(result).toBe(true);
});
it('should return false for posts from different users', () => {
const post = TestHelper.fakePostModel({
userId: 'user1',
createAt: 1000,
props: {},
});
const previousPost = TestHelper.fakePostModel({
userId: 'user2',
createAt: 500,
props: {},
});
const result = areConsecutivePosts(post, previousPost, 'en');
expect(result).toBe(false);
});
it('should return false for normal post after AI post from same user', () => {
const post = TestHelper.fakePostModel({
userId: 'user1',
createAt: 1000,
props: {},
});
const previousPost = TestHelper.fakePostModel({
userId: 'user1',
createAt: 500,
props: {
ai_generated_by: 'user1',
ai_generated_by_username: 'ai-bot',
},
});
const result = areConsecutivePosts(post, previousPost, 'en');
expect(result).toBe(false);
});
});
describe('isFromWebhook', () => {
it('should return true for posts from a webhook', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'true',
},
});
const result = isFromWebhook(post);
expect(result).toBe(true);
});
it('should return false for posts not from a webhook', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'false',
},
});
const result = isFromWebhook(post);
expect(result).toBe(false);
});
});
describe('isEdited', () => {
it('should return true if the post is edited', () => {
const post = TestHelper.fakePostModel({
editAt: 1000,
});
const result = isEdited(post);
expect(result).toBe(true);
});
it('should return false if the post is not edited', () => {
const post = TestHelper.fakePostModel({
editAt: 0,
});
const result = isEdited(post);
expect(result).toBe(false);
});
});
describe('isPostEphemeral', () => {
it('should return true for an ephemeral post', () => {
const post = TestHelper.fakePostModel({
type: Post.POST_TYPES.EPHEMERAL,
});
const result = isPostEphemeral(post);
expect(result).toBe(true);
});
it('should return false for a non-ephemeral post', () => {
const post = TestHelper.fakePostModel({
type: '',
});
const result = isPostEphemeral(post);
expect(result).toBe(false);
});
});
describe('restoreEphemeralIdentityFieldsForEdit', () => {
it('should preserve create_at and root_id when an edited ephemeral arrives with zero create_at', () => {
const incoming = TestHelper.fakePost({
id: 'eph1',
type: Post.POST_TYPES.EPHEMERAL,
message: 'after',
create_at: 0,
update_at: 9000,
root_id: '',
user_id: 'user1',
});
const result = restoreEphemeralIdentityFieldsForEdit(incoming, {
create_at: 5000,
root_id: 'root1',
user_id: 'user1',
});
expect(result.create_at).toBe(5000);
expect(result.root_id).toBe('root1');
expect(result.message).toBe('after');
expect(result.update_at).toBe(9000);
});
it('should preserve user_id when an edited ephemeral arrives with empty user_id', () => {
const incoming = TestHelper.fakePost({
id: 'eph1',
type: Post.POST_TYPES.EPHEMERAL,
message: 'after',
create_at: 5000,
update_at: 9000,
user_id: '',
});
const result = restoreEphemeralIdentityFieldsForEdit(incoming, {
create_at: 5000,
user_id: 'creator1',
});
expect(result.user_id).toBe('creator1');
expect(result.message).toBe('after');
});
});
describe('isPostFailed', () => {
it('should return true if the post has failed prop', () => {
const post = TestHelper.fakePostModel({
props: {
failed: true,
},
pendingPostId: 'id',
id: 'id',
updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000,
});
const result = isPostFailed(post);
expect(result).toBe(true);
});
it('should return true if the post is pending and the update time has exceeded the failure time', () => {
const post = TestHelper.fakePostModel({
props: {},
pendingPostId: 'id',
id: 'id',
updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000,
});
const result = isPostFailed(post);
expect(result).toBe(true);
});
it('should return false if the post is not failed', () => {
const post = TestHelper.fakePostModel({
props: {},
pendingPostId: 'id',
id: 'id',
updateAt: Date.now(),
});
const result = isPostFailed(post);
expect(result).toBe(false);
});
});
describe('isPostPendingOrFailed', () => {
it('should return true if the post is pending', () => {
const post = TestHelper.fakePostModel({
pendingPostId: 'id',
id: 'id',
props: {},
});
const result = isPostPendingOrFailed(post);
expect(result).toBe(true);
});
it('should return true if the post has failed', () => {
const post = TestHelper.fakePostModel({
pendingPostId: 'id',
id: 'id',
updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000,
props: {},
});
const result = isPostPendingOrFailed(post);
expect(result).toBe(true);
});
it('should return false if the post is neither pending nor failed', () => {
const post = TestHelper.fakePostModel({
pendingPostId: 'differentId',
id: 'id',
props: {},
});
const result = isPostPendingOrFailed(post);
expect(result).toBe(false);
});
});
describe('isSystemMessage', () => {
it('should return true if the post is a system message', () => {
const post = TestHelper.fakePostModel({
type: `${Post.POST_TYPES.SYSTEM_MESSAGE_PREFIX}any_type` as PostType,
});
const result = isSystemMessage(post);
expect(result).toBe(true);
});
it('should return false if the post is not a system message', () => {
const post = TestHelper.fakePostModel({
type: 'add_bot_teams_channels',
});
const result = isSystemMessage(post);
expect(result).toBe(false);
});
});
describe('hasSpecialMentions', () => {
test.each([
['@here where is Jessica Hyde', true],
['@all where is Jessica Hyde', true],
['@channel where is Jessica Hyde', true],
['where is Jessica Hyde @here', true],
['where is Jessica Hyde @all', true],
['where is Jessica Hyde @channel', true],
['where is Jessica @here Hyde', true],
['where is Jessica @all Hyde', true],
['where is Jessica @channel Hyde', true],
['where is Jessica Hyde\n@here', true],
['where is Jessica Hyde\n@all', true],
['where is Jessica Hyde\n@channel', true],
['where is Jessica\n@here Hyde', true],
['where is Jessica\n@all Hyde', true],
['where is Jessica\n@channel Hyde', true],
['where is Jessica Hyde @her', false],
['where is Jessica Hyde @al', false],
['where is Jessica Hyde @chann', false],
['where is Jessica Hyde@here', false],
['where is Jessica Hyde@all', false],
['where is Jessica Hyde@channel', false],
['where is Jessica @hereHyde', false],
['where is Jessica @allHyde', false],
['where is Jessica @channelHyde', false],
['@herewhere is Jessica Hyde@here', false],
['@allwhere is Jessica Hyde@all', false],
['@channelwhere is Jessica Hyde@channel', false],
['where is Jessica Hyde here', false],
['where is Jessica Hyde all', false],
['where is Jessica Hyde channel', false],
['where is Jessica Hyde', false],
])('hasSpecialMentions: %s => %s', (message, expected) => {
expect(hasSpecialMentions(message)).toBe(expected);
});
});
describe('fromAutoResponder', () => {
it('should return true if the post is from an auto responder', () => {
const post = TestHelper.fakePostModel({
type: Post.POST_TYPES.SYSTEM_AUTO_RESPONDER,
});
const result = fromAutoResponder(post);
expect(result).toBe(true);
});
it('should return false if the post is not from an auto responder', () => {
const post = TestHelper.fakePostModel({
type: 'add_bot_teams_channels',
});
const result = fromAutoResponder(post);
expect(result).toBe(false);
});
});
describe('persistentNotificationsConfirmation', () => {
const serverUrl = 'http://server';
const value = '@user';
const mentionsList = ['@user'];
const sendMessage = jest.fn();
const persistentNotificationMaxRecipients = 10;
const persistentNotificationInterval = 5;
const currentUserId = 'current_user_id';
const channelName = 'channel_id__teammate_id';
const intl = createIntl({locale: DEFAULT_LOCALE, messages: getTranslations(DEFAULT_LOCALE)});
it('should show alert with DM channel description when channelType is DM_CHANNEL', async () => {
const mockUser = TestHelper.fakeUserModel({username: 'teammate'});
jest.mocked(getUserById).mockResolvedValue(mockUser);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
General.DM_CHANNEL,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Send persistent notifications',
`${mockUser.username} will be notified every ${persistentNotificationInterval} minutes until they’ve acknowledged or replied to the message.`,
expect.any(Array),
);
});
it('should show alert when special mentions are present', async () => {
await persistentNotificationsConfirmation(
serverUrl,
'@channel',
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'',
'Cannot use @channel, @all or @here to mention recipients of persistent notifications.',
expect.any(Array),
);
});
it('should show alert when no mentions found', async () => {
jest.mocked(getUsersCountFromMentions).mockResolvedValue(0);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Recipients must be @mentioned',
'There are no recipients mentioned in your message. You’ll need add mentions to be able to send persistent notifications.',
expect.any(Array),
);
});
it('should show alert when mentions exceed max recipients', async () => {
jest.mocked(getUsersCountFromMentions).mockResolvedValue(15);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Too many recipients',
`You can send persistent notifications to a maximum of ${persistentNotificationMaxRecipients} recipients. There are ${mentionsList.length} recipients mentioned in your message. You’ll need to change who you’ve mentioned before you can send.`,
expect.any(Array),
);
});
it('should show confirmation alert for valid mentions within limit', async () => {
jest.mocked(getUsersCountFromMentions).mockResolvedValue(5);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Send persistent notifications',
`Mentioned recipients will be notified every ${persistentNotificationInterval} minutes until they’ve acknowledged or replied to the message.`,
expect.any(Array),
);
});
});
describe('postUserDisplayName', () => {
it('should return the override username if from webhook and override is enabled', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'true',
override_username: 'webhook_user',
},
});
const result = postUserDisplayName(post, undefined, undefined, true);
expect(result).toBe('webhook_user');
});
it('should return the author’s display name if not from webhook or override is disabled', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'false',
},
});
const author = TestHelper.fakeUserModel({
username: 'user1',
locale: 'en',
});
const result = postUserDisplayName(post, author, undefined, false);
expect(result).toBe('user1');
});
it('should return the author’s display name using the teammate name display', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'false',
},
});
const author = TestHelper.fakeUserModel({
username: 'user1',
locale: 'en',
});
const result = postUserDisplayName(post, author, 'nickname', false);
expect(result).toBe('user1');
});
});
describe('shouldIgnorePost', () => {
it('should return true if the post type is in the ignore list', () => {
const post = TestHelper.fakePost({
type: Post.POST_TYPES.CHANNEL_DELETED,
});
const result = shouldIgnorePost(post);
expect(result).toBe(true);
});
it('should return false if the post type is not in the ignore list', () => {
const post = TestHelper.fakePost({
type: Post.POST_TYPES.EPHEMERAL,
});
const result = shouldIgnorePost(post);
expect(result).toBe(false);
});
});
describe('processPostsFetched', () => {
it('should process the fetched posts correctly', () => {
const data = {
order: ['post1', 'post2'],
posts: {
post1: TestHelper.fakePost({id: 'post1', message: 'First post'}),
post2: TestHelper.fakePost({id: 'post2', message: 'Second post'}),
},
prev_post_id: 'post0',
};
const result = processPostsFetched(data);
expect(result).toEqual({
posts: [
expect.objectContaining({id: 'post1', message: 'First post'}),
expect.objectContaining({id: 'post2', message: 'Second post'}),
],
order: ['post1', 'post2'],
previousPostId: 'post0',
});
});
});
describe('getLastFetchedAtFromPosts', () => {
it('should return the maximum timestamp from the posts', () => {
const posts = [
TestHelper.fakePost({create_at: 1000, update_at: 2000, delete_at: 0}),
TestHelper.fakePost({create_at: 1500, update_at: 2500, delete_at: 3000}),
];
const result = getLastFetchedAtFromPosts(posts);
expect(result).toBe(3000);
});
it('should return 0 if no posts are provided', () => {
const result = getLastFetchedAtFromPosts();
expect(result).toBe(0);
});
});
describe('moreThan5minAgo', () => {
it('should return true if the time is more than 5 minutes ago', () => {
const time = Date.now() - toMilliseconds({minutes: 6});
const result = moreThan5minAgo(time);
expect(result).toBe(true);
});
it('should return false if the time is within 5 minutes', () => {
const time = Date.now() - toMilliseconds({minutes: 4});
const result = moreThan5minAgo(time);
expect(result).toBe(false);
});
});
describe('hasInteractivePostContent', () => {
it('should return false when mmBlocksEnabled is false', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: [{type: 'text', text: {type: 'plain_text', text: 'Hello'}}],
},
});
expect(hasInteractivePostContent(post, false)).toBe(false);
});
it('should return false when mmBlocksEnabled is true but props are missing', () => {
const post = TestHelper.fakePostModel({props: undefined});
expect(hasInteractivePostContent(post, true)).toBe(false);
});
it('should return false when all interactive arrays are empty', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: [],
blocks: [],
cards: [],
attachments: [],
},
});
expect(hasInteractivePostContent(post, true)).toBe(false);
});
it('should return false when interactive props are not arrays', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: 'invalid',
blocks: {},
cards: null,
attachments: 'invalid',
},
});
expect(hasInteractivePostContent(post, true)).toBe(false);
});
it('should return true when mm_blocks are present', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: [{type: 'text', text: {type: 'plain_text', text: 'Hello'}}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should return true when blocks are present', () => {
const post = TestHelper.fakePostModel({
props: {
blocks: [{type: 'section', text: {type: 'mrkdwn', text: 'Block content'}}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should return true when cards are present', () => {
const post = TestHelper.fakePostModel({
props: {
cards: [{header: {title: 'Card title'}, sections: []}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should return true when attachments are present', () => {
const post = TestHelper.fakePostModel({
props: {
attachments: [{text: 'Attachment body'}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should work with Post objects', () => {
const post = TestHelper.fakePost({
props: {
attachments: [{text: 'Attachment body'}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
});
describe('scheduledPostFromPost', () => {
const post: Post = mockedPosts.posts[mockedPosts.order[0]];
const schedulingInfo: SchedulingInfo = {
scheduled_at: Date.now() + 10000,
};
const postPriority: PostPriority = {
priority: 'important',
};
const postFiles: FileInfo[] = [
TestHelper.fakeFileInfo({id: 'fileid1'}),
TestHelper.fakeFileInfo({id: 'fileid2'}),
];
it('should create a scheduled post with the given scheduling info', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo);
expect(result.scheduled_at).toBe(schedulingInfo.scheduled_at);
});
it('should not include the post priority if its a post in thread', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo, postPriority);
expect(result.priority).toBeUndefined();
});
it('should include the file IDs if post files are provided', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles);
expect(result.file_ids).toEqual(['fileid1', 'fileid2']);
});
it('should include the post files if provided', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles);
expect(result.metadata?.files).toEqual(postFiles);
});
it('should return a scheduled post with the same properties as the original post', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo);
expect(result.id).toBe(post.id);
expect(result.message).toBe(post.message);
expect(result.channel_id).toBe(post.channel_id);
expect(result.create_at).toBe(post.create_at);
expect(result.update_at).toBe(post.update_at);
expect(result.user_id).toBe(post.user_id);
});
});
describe('permalink redaction', () => {
const embed = (metadata: object): PermalinkEmbedData => ({
post_id: 'linked-post-id',
post: {metadata},
team_name: 'team',
channel_display_name: 'channel',
channel_type: 'O',
channel_id: 'channel-id',
} as PermalinkEmbedData);
it('should treat a non-zero embed redacted count as denied', () => {
expect(isPermalinkEmbedRedacted(embed({redacted_file_count: 2}))).toBe(true);
expect(isPermalinkEmbedRedacted(embed({redacted_file_count: 0}))).toBe(false);
expect(isPermalinkEmbedRedacted(undefined)).toBe(false);
});
it('should report the embed count when the embed denies access', () => {
expect(getPermalinkRedactedFileCount(embed({redacted_file_count: 2}), 0)).toBe(2);
});
it('should report nothing redacted when the embed lists files', () => {
// The embed is authoritative, so a stale stored count must not resurrect the placeholder.
expect(getPermalinkRedactedFileCount(embed({files: [{id: 'file-1'}]}), 3)).toBe(0);
});
it('should fall back to the linked post when the embed says neither', () => {
expect(getPermalinkRedactedFileCount(embed({}), 3)).toBe(3);
expect(getPermalinkRedactedFileCount(undefined, 3)).toBe(3);
});
});
describe('isAttachmentDecisionCurrent', () => {
it('should render a decision confirmed under the required epoch', () => {
expect(isAttachmentDecisionCurrent(true, 3, 3, 2)).toBe(true);
});
it('should gate a decision behind the required epoch while enforced', () => {
expect(isAttachmentDecisionCurrent(true, 2, 3, 0)).toBe(false);
});
it('should render allowed attachments behind the epoch once enforcement is off', () => {
// Nothing new is redacted, so without a cached denial there is nothing to re-check.
expect(isAttachmentDecisionCurrent(false, 0, 3, 0)).toBe(true);
});
it('should re-check a denial cached while enforced once enforcement is off', () => {
// Its file rows are gone and no since-fetch re-delivers it.
expect(isAttachmentDecisionCurrent(false, 2, 3, 1)).toBe(false);
});
});
});