mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-11 05:30:12 +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>
1024 lines
41 KiB
TypeScript
1024 lines
41 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
/* eslint-disable max-lines */
|
|
|
|
import RNUtils from '@mattermost/rnutils';
|
|
import {act} from '@testing-library/react-native';
|
|
|
|
import {fetchMyChannelsForTeam, fetchMissingDirectChannelsInfo, type MyChannelsRequest} from '@actions/remote/channel';
|
|
import {fetchGroupsForMember} from '@actions/remote/groups';
|
|
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
|
|
import {fetchRoles} from '@actions/remote/role';
|
|
import {fetchScheduledPosts} from '@actions/remote/scheduled_post';
|
|
import {fetchTeamsThreads, updateCanJoinTeams, type MyTeamsRequest} from '@actions/remote/team';
|
|
import {autoUpdateTimezone, updateAllUsersSince, type MyUserRequest} from '@actions/remote/user';
|
|
import {Preferences} from '@constants';
|
|
import DatabaseManager from '@database/manager';
|
|
import {processEntryModels, processEntryModelsForDeletion} from '@queries/servers/entry';
|
|
|
|
import {deferredAppEntryActions, restDeferredAppEntryActions, testExports} from './deferred';
|
|
|
|
jest.mock('@actions/remote/channel');
|
|
jest.mock('@actions/remote/role');
|
|
jest.mock('@actions/remote/scheduled_post');
|
|
jest.mock('@actions/remote/team');
|
|
jest.mock('@actions/remote/user');
|
|
jest.mock('@actions/remote/post');
|
|
jest.mock('@actions/remote/groups');
|
|
jest.mock('@queries/servers/entry');
|
|
|
|
describe('actions/remote/entry/deferred', () => {
|
|
const serverUrl = 'https://server.example.com';
|
|
|
|
const mockOperator = {
|
|
handleSystem: jest.fn(),
|
|
handleUsers: jest.fn(),
|
|
handlePreferences: jest.fn(),
|
|
handleTeams: jest.fn(),
|
|
handleChannels: jest.fn(),
|
|
handlePosts: jest.fn(),
|
|
handleCategories: jest.fn(),
|
|
handleGroups: jest.fn(),
|
|
handleRoles: jest.fn(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
jest.clearAllMocks();
|
|
|
|
// The automock returns undefined, which the real action never does.
|
|
jest.mocked(fetchMyChannelsForTeam).mockResolvedValue({channels: [], memberships: []});
|
|
|
|
DatabaseManager.getServerDatabaseAndOperator = jest.fn().mockReturnValue({
|
|
database: {},
|
|
operator: mockOperator,
|
|
});
|
|
});
|
|
|
|
describe('deferredAppEntryActions', () => {
|
|
it('should call autoUpdateTimezone', async () => {
|
|
const since = 123456789;
|
|
const currentUserId = 'user1';
|
|
const currentUserLocale = 'en';
|
|
const preferences = [{
|
|
category: 'advanced_settings',
|
|
name: 'feature_enabled',
|
|
value: 'true',
|
|
user_id: 'user1',
|
|
}];
|
|
const config = {
|
|
Version: '7.8.0',
|
|
CollapsedThreads: 'false',
|
|
FeatureFlagCollapsedThreads: 'true',
|
|
} as ClientConfig;
|
|
const license = {} as ClientLicense;
|
|
const teamData = {teams: [], memberships: []};
|
|
const chData = undefined;
|
|
const meData = undefined;
|
|
|
|
await deferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
preferences,
|
|
config,
|
|
license,
|
|
teamData,
|
|
chData,
|
|
meData,
|
|
);
|
|
|
|
expect(autoUpdateTimezone).toHaveBeenCalledWith(serverUrl, undefined);
|
|
});
|
|
});
|
|
|
|
describe('restDeferredAppEntryActions', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
global.setTimeout = jest.fn((cb) => cb()) as any;
|
|
|
|
const since = 123456789;
|
|
const currentUserId = 'user1';
|
|
const currentUserLocale = 'en';
|
|
const defaultConfig = {
|
|
Version: '7.8.0',
|
|
CollapsedThreads: 'false',
|
|
FeatureFlagCollapsedThreads: 'true',
|
|
LockTeammateNameDisplay: 'false',
|
|
TeammateNameDisplay: 'username',
|
|
} as ClientConfig;
|
|
const license = {} as ClientLicense;
|
|
const defaultTeamData = {
|
|
teams: [{id: 'team1', display_name: 'Team 1'}],
|
|
memberships: [{team_id: 'team1', user_id: 'user1'}],
|
|
} as MyTeamsRequest;
|
|
const defaultChData = {
|
|
channels: [
|
|
{id: 'dm1', type: 'D', name: 'dm-channel'} as Channel,
|
|
{id: 'channel1', type: 'O', name: 'channel1', team_id: 'team1'} as Channel,
|
|
],
|
|
memberships: [
|
|
{channel_id: 'dm1', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
categories: [],
|
|
} as MyChannelsRequest;
|
|
const defaultMeData = {
|
|
user: {id: 'user1', roles: '', username: 'user1'},
|
|
} as MyUserRequest;
|
|
const initialTeamId = 'team1';
|
|
const initialChannelId = 'channel1';
|
|
|
|
it('should execute deferred actions correctly', async () => {
|
|
const preferences = [{
|
|
category: 'advanced_settings',
|
|
name: 'feature_enabled',
|
|
value: 'true',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
await restDeferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
preferences,
|
|
defaultConfig,
|
|
license,
|
|
defaultTeamData,
|
|
defaultChData,
|
|
defaultMeData,
|
|
initialTeamId,
|
|
initialChannelId,
|
|
);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(fetchMissingDirectChannelsInfo).toHaveBeenCalled();
|
|
expect(updateAllUsersSince).toHaveBeenCalledWith(serverUrl, since, false, undefined);
|
|
expect(updateCanJoinTeams).toHaveBeenCalledWith(serverUrl);
|
|
expect(processEntryModelsForDeletion).toHaveBeenCalledWith({serverUrl, operator: mockOperator, teamData: defaultTeamData, chData: defaultChData, channelsFetchComplete: true});
|
|
expect(fetchPostsForUnreadChannels).toHaveBeenCalled();
|
|
expect(fetchGroupsForMember).toHaveBeenCalledWith(serverUrl, currentUserId, false, undefined);
|
|
expect(fetchScheduledPosts).toHaveBeenCalledWith(serverUrl, initialTeamId, true, undefined);
|
|
expect(fetchRoles).toHaveBeenCalledWith(serverUrl, defaultTeamData.memberships, defaultChData.memberships, defaultMeData.user, false, true, undefined);
|
|
expect(RNUtils.beginDatabaseActivity).toHaveBeenCalledWith(serverUrl, 'restDeferredAppEntryActions');
|
|
expect(RNUtils.endDatabaseActivity).toHaveBeenCalledWith('token-1');
|
|
});
|
|
|
|
it('should still end the database activity when processing teams fails', async () => {
|
|
jest.mocked(fetchMyChannelsForTeam).mockRejectedValueOnce(new Error('test'));
|
|
|
|
await restDeferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
[],
|
|
defaultConfig,
|
|
license,
|
|
defaultTeamData,
|
|
defaultChData,
|
|
defaultMeData,
|
|
initialTeamId,
|
|
initialChannelId,
|
|
);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(RNUtils.beginDatabaseActivity).toHaveBeenCalledWith(serverUrl, 'restDeferredAppEntryActions');
|
|
expect(RNUtils.endDatabaseActivity).toHaveBeenCalledWith('token-1');
|
|
});
|
|
|
|
it('should not end the database activity when no token was granted', async () => {
|
|
jest.mocked(RNUtils.beginDatabaseActivity).mockResolvedValueOnce(null);
|
|
|
|
await restDeferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
[],
|
|
defaultConfig,
|
|
license,
|
|
defaultTeamData,
|
|
defaultChData,
|
|
defaultMeData,
|
|
initialTeamId,
|
|
initialChannelId,
|
|
);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(RNUtils.endDatabaseActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle missing data gracefully', async () => {
|
|
const preferences = undefined;
|
|
const config = {
|
|
Version: '7.8.0',
|
|
CollapsedThreads: 'false',
|
|
} as ClientConfig;
|
|
const teamData = {
|
|
teams: [],
|
|
memberships: [],
|
|
};
|
|
const chData = undefined;
|
|
const meData = undefined;
|
|
|
|
await restDeferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
preferences,
|
|
config,
|
|
license,
|
|
teamData,
|
|
chData,
|
|
meData,
|
|
);
|
|
|
|
expect(updateAllUsersSince).toHaveBeenCalledWith(serverUrl, since, false, undefined);
|
|
expect(updateCanJoinTeams).toHaveBeenCalledWith(serverUrl);
|
|
expect(fetchGroupsForMember).toHaveBeenCalledWith(serverUrl, currentUserId, false, undefined);
|
|
expect(fetchMissingDirectChannelsInfo).not.toHaveBeenCalled();
|
|
expect(fetchPostsForUnreadChannels).not.toHaveBeenCalled();
|
|
expect(fetchTeamsThreads).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle teams order preference correctly', async () => {
|
|
const preferences = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team2,team1',
|
|
user_id: 'user1',
|
|
}];
|
|
const config = {
|
|
...defaultConfig,
|
|
CollapsedThreads: 'true',
|
|
} as ClientConfig;
|
|
const teamData = {
|
|
teams: [
|
|
{id: 'team1', display_name: 'Team 1'},
|
|
{id: 'team2', display_name: 'Team 2'},
|
|
],
|
|
memberships: [
|
|
{team_id: 'team1', user_id: 'user1'},
|
|
{team_id: 'team2', user_id: 'user1'},
|
|
],
|
|
} as MyTeamsRequest;
|
|
const chData = {
|
|
channels: [
|
|
{id: 'channel1', team_id: 'team1', type: 'O'},
|
|
{id: 'channel2', team_id: 'team2', type: 'O'},
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'},
|
|
{channel_id: 'channel2', user_id: 'user1'},
|
|
],
|
|
} as MyChannelsRequest;
|
|
|
|
await act(async () => {
|
|
await restDeferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
preferences,
|
|
config,
|
|
license,
|
|
teamData,
|
|
chData,
|
|
defaultMeData,
|
|
'team1',
|
|
'channel1',
|
|
);
|
|
});
|
|
|
|
expect(fetchTeamsThreads).toHaveBeenCalled();
|
|
expect(fetchPostsForUnreadChannels).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle direct channels info correctly', async () => {
|
|
const preferences = [] as PreferenceType[];
|
|
const teamData = {teams: [], memberships: []};
|
|
const chData = {
|
|
channels: [
|
|
{id: 'dm1', type: 'D', name: 'user1__user2'},
|
|
{id: 'gm1', type: 'G', name: 'group-message'},
|
|
],
|
|
memberships: [
|
|
{channel_id: 'dm1', user_id: 'user1'},
|
|
{channel_id: 'gm1', user_id: 'user1'},
|
|
],
|
|
} as MyChannelsRequest;
|
|
|
|
await restDeferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
preferences,
|
|
defaultConfig,
|
|
license,
|
|
teamData,
|
|
chData,
|
|
defaultMeData,
|
|
);
|
|
|
|
expect(fetchMissingDirectChannelsInfo).toHaveBeenCalledWith(
|
|
serverUrl,
|
|
expect.arrayContaining([
|
|
expect.objectContaining({id: 'dm1'}),
|
|
expect.objectContaining({id: 'gm1'}),
|
|
]),
|
|
currentUserLocale,
|
|
'username',
|
|
currentUserId,
|
|
false,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should show all teams in order even when fetchMyChannelsForTeam throws an error', async () => {
|
|
jest.mocked(fetchMyChannelsForTeam).mockRejectedValueOnce(new Error('test')).mockResolvedValueOnce({
|
|
channels: [],
|
|
memberships: [],
|
|
} as MyChannelsRequest);
|
|
|
|
const preferences = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
const config = {
|
|
...defaultConfig,
|
|
CollapsedThreads: 'true',
|
|
} as ClientConfig;
|
|
const teamData = {
|
|
teams: [
|
|
{id: 'team1', display_name: 'Team 1'},
|
|
{id: 'team2', display_name: 'Team 2'},
|
|
],
|
|
memberships: [
|
|
{team_id: 'team1', user_id: 'user1'},
|
|
{team_id: 'team2', user_id: 'user1'},
|
|
],
|
|
} as MyTeamsRequest;
|
|
const chData = {
|
|
channels: [
|
|
{id: 'channel1', team_id: 'team1', type: 'O'},
|
|
{id: 'channel2', team_id: 'team2', type: 'O'},
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'},
|
|
{channel_id: 'channel2', user_id: 'user1'},
|
|
],
|
|
} as MyChannelsRequest;
|
|
|
|
await act(async () => {
|
|
await restDeferredAppEntryActions(
|
|
serverUrl,
|
|
since,
|
|
currentUserId,
|
|
currentUserLocale,
|
|
preferences,
|
|
config,
|
|
license,
|
|
teamData,
|
|
chData,
|
|
defaultMeData,
|
|
);
|
|
});
|
|
|
|
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
|
|
expect.objectContaining({
|
|
teamData: expect.objectContaining({
|
|
teams: expect.arrayContaining([
|
|
expect.objectContaining({id: 'team1'}),
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
|
|
expect.objectContaining({
|
|
teamData: expect.objectContaining({
|
|
teams: expect.arrayContaining([
|
|
expect.objectContaining({id: 'team2'}),
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('sortTeamsByPreferences', () => {
|
|
let mockTeamMap: Map<string, Team>;
|
|
let mockTeamData: MyTeamsRequest;
|
|
const {sortTeamsByPreferences} = testExports;
|
|
|
|
beforeEach(() => {
|
|
mockTeamMap = new Map([
|
|
['team1', {id: 'team1', display_name: 'Team Alpha'} as Team],
|
|
['team2', {id: 'team2', display_name: 'Team Beta'} as Team],
|
|
['team3', {id: 'team3', display_name: 'Team Gamma'} as Team],
|
|
['team4', {id: 'team4', display_name: 'Team Delta'} as Team],
|
|
]);
|
|
|
|
mockTeamData = {
|
|
teams: [
|
|
{id: 'team1', display_name: 'Team Alpha'} as Team,
|
|
{id: 'team2', display_name: 'Team Beta'} as Team,
|
|
{id: 'team3', display_name: 'Team Gamma'} as Team,
|
|
{id: 'team4', display_name: 'Team Delta'} as Team,
|
|
],
|
|
memberships: [
|
|
{team_id: 'team1', user_id: 'user1', mention_count: 0, msg_count: 0, roles: '', delete_at: 0, scheme_user: false, scheme_admin: false},
|
|
{team_id: 'team2', user_id: 'user1', mention_count: 0, msg_count: 0, roles: '', delete_at: 0, scheme_user: false, scheme_admin: false},
|
|
{team_id: 'team3', user_id: 'user1', mention_count: 0, msg_count: 0, roles: '', delete_at: 0, scheme_user: false, scheme_admin: false},
|
|
{team_id: 'team4', user_id: 'user1', mention_count: 0, msg_count: 0, roles: '', delete_at: 0, scheme_user: false, scheme_admin: false},
|
|
],
|
|
};
|
|
});
|
|
|
|
it('should sort teams alphabetically when no preferences are provided', () => {
|
|
const preferences = undefined;
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Alpha');
|
|
expect(result[1].display_name).toBe('Team Beta');
|
|
expect(result[2].display_name).toBe('Team Delta');
|
|
expect(result[3].display_name).toBe('Team Gamma');
|
|
});
|
|
|
|
it('should sort teams alphabetically when preferences array is empty', () => {
|
|
const preferences: PreferenceType[] = [];
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Alpha');
|
|
expect(result[1].display_name).toBe('Team Beta');
|
|
expect(result[2].display_name).toBe('Team Delta');
|
|
expect(result[3].display_name).toBe('Team Gamma');
|
|
});
|
|
|
|
it('should sort teams according to preferences order when teams order preference exists', () => {
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team3,team1,team4',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Gamma'); // team3
|
|
expect(result[1].display_name).toBe('Team Alpha'); // team1
|
|
expect(result[2].display_name).toBe('Team Delta'); // team4
|
|
expect(result[3].display_name).toBe('Team Beta'); // team2 (extra team, sorted alphabetically)
|
|
});
|
|
|
|
it('should handle teams not in preferences by sorting them alphabetically', () => {
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team3', // Only team1 and team3 in preferences
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Alpha'); // team1 (from preferences)
|
|
expect(result[1].display_name).toBe('Team Gamma'); // team3 (from preferences)
|
|
expect(result[2].display_name).toBe('Team Beta'); // team2 (extra, sorted alphabetically)
|
|
expect(result[3].display_name).toBe('Team Delta'); // team4 (extra, sorted alphabetically)
|
|
});
|
|
|
|
it('should filter out teams that are not in memberships', () => {
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team5,team2', // team5 doesn't exist in memberships
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Alpha'); // team1 (from preferences)
|
|
expect(result[1].display_name).toBe('Team Beta'); // team2 (from preferences)
|
|
expect(result[2].display_name).toBe('Team Delta'); // team4 (extra, sorted alphabetically)
|
|
expect(result[3].display_name).toBe('Team Gamma'); // team3 (extra, sorted alphabetically)
|
|
expect(result.length).toBe(4); // team5 is filtered out
|
|
});
|
|
|
|
it('should handle case-insensitive sorting for extra teams', () => {
|
|
const teamDataWithMixedCase: MyTeamsRequest = {
|
|
teams: [
|
|
{id: 'team1', display_name: 'alpha team'} as Team,
|
|
{id: 'team2', display_name: 'BETA TEAM'} as Team,
|
|
{id: 'team3', display_name: 'Gamma Team'} as Team,
|
|
{id: 'team4', display_name: 'delta team'} as Team,
|
|
],
|
|
memberships: mockTeamData.memberships,
|
|
};
|
|
|
|
const teamMapWithMixedCase = new Map([
|
|
['team1', {id: 'team1', display_name: 'alpha team'} as Team],
|
|
['team2', {id: 'team2', display_name: 'BETA TEAM'} as Team],
|
|
['team3', {id: 'team3', display_name: 'Gamma Team'} as Team],
|
|
['team4', {id: 'team4', display_name: 'delta team'} as Team],
|
|
]);
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1', // Only team1 in preferences
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithMixedCase, preferences, teamMapWithMixedCase);
|
|
|
|
expect(result[0].display_name).toBe('alpha team'); // team1 (from preferences)
|
|
expect(result[1].display_name).toBe('BETA TEAM'); // team2 (extra, sorted alphabetically)
|
|
expect(result[2].display_name).toBe('delta team'); // team4 (extra, sorted alphabetically)
|
|
expect(result[3].display_name).toBe('Gamma Team'); // team3 (extra, sorted alphabetically)
|
|
});
|
|
|
|
it('should handle empty teams array', () => {
|
|
const emptyTeamData: MyTeamsRequest = {
|
|
teams: [],
|
|
memberships: [],
|
|
};
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(emptyTeamData, preferences, new Map());
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should handle teams with missing display_name', () => {
|
|
const teamDataWithMissingNames: MyTeamsRequest = {
|
|
teams: [
|
|
{id: 'team1', display_name: 'Team Alpha'} as Team,
|
|
{id: 'team2', display_name: ''} as Team,
|
|
{id: 'team3', display_name: 'Team Gamma'} as Team,
|
|
],
|
|
memberships: mockTeamData.memberships!.slice(0, 3),
|
|
};
|
|
|
|
const teamMapWithMissingNames = new Map([
|
|
['team1', {id: 'team1', display_name: 'Team Alpha'} as Team],
|
|
['team2', {id: 'team2', display_name: ''} as Team],
|
|
['team3', {id: 'team3', display_name: 'Team Gamma'} as Team],
|
|
]);
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team3',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithMissingNames, preferences, teamMapWithMissingNames);
|
|
|
|
expect(result[0].display_name).toBe('Team Alpha'); // team1 (from preferences)
|
|
expect(result[1].display_name).toBe('Team Gamma'); // team3 (from preferences)
|
|
expect(result[2].display_name).toBe(''); // team2 (extra, empty string sorts first)
|
|
});
|
|
|
|
it('should handle teams that exist in preferences but not in teamMap', () => {
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team5,team2', // team5 exists in preferences but not in teamMap
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Alpha'); // team1 (from preferences, exists in teamMap)
|
|
expect(result[1].display_name).toBe('Team Beta'); // team2 (from preferences, exists in teamMap)
|
|
expect(result[2].display_name).toBe('Team Delta'); // team4 (extra, sorted alphabetically)
|
|
expect(result[3].display_name).toBe('Team Gamma'); // team3 (extra, sorted alphabetically)
|
|
expect(result.length).toBe(4); // team5 is filtered out (not in teamMap)
|
|
});
|
|
|
|
it('should handle empty preferences value', () => {
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: '', // Empty value
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Alpha');
|
|
expect(result[1].display_name).toBe('Team Beta');
|
|
expect(result[2].display_name).toBe('Team Delta');
|
|
expect(result[3].display_name).toBe('Team Gamma');
|
|
});
|
|
|
|
it('should handle preferences with only one team', () => {
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team2', // Only one team in preferences
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(mockTeamData, preferences, mockTeamMap);
|
|
|
|
expect(result[0].display_name).toBe('Team Beta'); // team2 (from preferences)
|
|
expect(result[1].display_name).toBe('Team Alpha'); // team1 (extra, sorted alphabetically)
|
|
expect(result[2].display_name).toBe('Team Delta'); // team4 (extra, sorted alphabetically)
|
|
expect(result[3].display_name).toBe('Team Gamma'); // team3 (extra, sorted alphabetically)
|
|
});
|
|
|
|
it('should handle undefined teamData.memberships', () => {
|
|
const teamDataWithUndefinedMemberships: MyTeamsRequest = {
|
|
teams: mockTeamData.teams,
|
|
memberships: undefined,
|
|
};
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithUndefinedMemberships, preferences, mockTeamMap);
|
|
|
|
// When memberships is undefined, membershipSet becomes empty
|
|
// Since we have preferences (sortedTeamIds.size > 0), it goes to the first path
|
|
// But no teams match the membershipSet filter, so both sortedTeams and extraTeams are empty
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should handle undefined teamData.teams', () => {
|
|
const teamDataWithUndefinedTeams: MyTeamsRequest = {
|
|
teams: undefined,
|
|
memberships: mockTeamData.memberships,
|
|
};
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
// Create an empty teamMap since teamData.teams is undefined
|
|
const emptyTeamMap = new Map<string, Team>();
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithUndefinedTeams, preferences, emptyTeamMap);
|
|
|
|
// When teams is undefined, teamMap is empty, so no teams can be found
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should handle empty teamData.memberships array', () => {
|
|
const teamDataWithEmptyMemberships: MyTeamsRequest = {
|
|
teams: mockTeamData.teams,
|
|
memberships: [],
|
|
};
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithEmptyMemberships, preferences, mockTeamMap);
|
|
|
|
// When memberships is empty, membershipSet becomes empty
|
|
// Since we have preferences (sortedTeamIds.size > 0), it goes to the first path
|
|
// But no teams match the membershipSet filter, so both sortedTeams and extraTeams are empty
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should handle both undefined teams and memberships', () => {
|
|
const teamDataWithUndefinedBoth: MyTeamsRequest = {
|
|
teams: undefined,
|
|
memberships: undefined,
|
|
};
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithUndefinedBoth, preferences, mockTeamMap);
|
|
|
|
// When both teams and memberships are undefined, it should return empty array
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should handle empty teams array with undefined memberships', () => {
|
|
const teamDataWithEmptyTeamsAndUndefinedMemberships: MyTeamsRequest = {
|
|
teams: [],
|
|
memberships: undefined,
|
|
};
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithEmptyTeamsAndUndefinedMemberships, preferences, mockTeamMap);
|
|
|
|
// When teams is empty and memberships is undefined, it should return empty array
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should handle undefined teams with empty memberships', () => {
|
|
const teamDataWithUndefinedTeamsAndEmptyMemberships: MyTeamsRequest = {
|
|
teams: undefined,
|
|
memberships: [],
|
|
};
|
|
|
|
const preferences: PreferenceType[] = [{
|
|
category: Preferences.CATEGORIES.TEAMS_ORDER,
|
|
name: '',
|
|
value: 'team1,team2',
|
|
user_id: 'user1',
|
|
}];
|
|
|
|
const result = sortTeamsByPreferences(teamDataWithUndefinedTeamsAndEmptyMemberships, preferences, mockTeamMap);
|
|
|
|
// When teams is undefined and memberships is empty, it should return empty array
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('combineChannelsData', () => {
|
|
const {combineChannelsData} = testExports;
|
|
|
|
it('should combine channels when both target and source have channels', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
{id: 'channel2', name: 'Channel 2', type: 'O', team_id: 'team1'} as Channel,
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel2', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
categories: [
|
|
{id: 'category1', display_name: 'Category 1', team_id: 'team1', sort_order: 0, sorting: 'alpha', type: 'custom', muted: false, collapsed: false, channel_ids: ['channel1', 'channel2']} as CategoryWithChannels,
|
|
],
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: [
|
|
{id: 'channel3', name: 'Channel 3', type: 'O', team_id: 'team2'} as Channel,
|
|
{id: 'channel4', name: 'Channel 4', type: 'O', team_id: 'team2'} as Channel,
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel3', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel4', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
categories: [
|
|
{id: 'category2', display_name: 'Category 2', team_id: 'team2', sort_order: 1, sorting: 'alpha', type: 'custom', muted: false, collapsed: false, channel_ids: ['channel3', 'channel4']} as CategoryWithChannels,
|
|
],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.channels).toHaveLength(4);
|
|
expect(target.channels).toEqual([
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
{id: 'channel2', name: 'Channel 2', type: 'O', team_id: 'team1'} as Channel,
|
|
{id: 'channel3', name: 'Channel 3', type: 'O', team_id: 'team2'} as Channel,
|
|
{id: 'channel4', name: 'Channel 4', type: 'O', team_id: 'team2'} as Channel,
|
|
]);
|
|
|
|
expect(target.memberships).toHaveLength(4);
|
|
expect(target.memberships).toEqual([
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel2', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel3', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel4', user_id: 'user1'} as ChannelMembership,
|
|
]);
|
|
|
|
expect(target.categories).toHaveLength(2);
|
|
expect(target.categories).toEqual([
|
|
{id: 'category1', display_name: 'Category 1', team_id: 'team1', sort_order: 0, sorting: 'alpha', type: 'custom', muted: false, collapsed: false, channel_ids: ['channel1', 'channel2']} as CategoryWithChannels,
|
|
{id: 'category2', display_name: 'Category 2', team_id: 'team2', sort_order: 1, sorting: 'alpha', type: 'custom', muted: false, collapsed: false, channel_ids: ['channel3', 'channel4']} as CategoryWithChannels,
|
|
]);
|
|
});
|
|
|
|
it('should combine channels when target has no channels but source does', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: undefined,
|
|
memberships: [],
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: [
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
// When target.channels is undefined, concat returns undefined
|
|
expect(target.channels).toBeUndefined();
|
|
});
|
|
|
|
it('should combine channels when target has channels but source has no channels', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: undefined,
|
|
memberships: [],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.channels).toHaveLength(1);
|
|
expect(target.channels).toEqual([
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
]);
|
|
});
|
|
|
|
it('should combine memberships when both target and source have memberships', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel2', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: [
|
|
{channel_id: 'channel3', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel4', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.memberships).toHaveLength(4);
|
|
expect(target.memberships).toEqual([
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel2', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel3', user_id: 'user1'} as ChannelMembership,
|
|
{channel_id: 'channel4', user_id: 'user1'} as ChannelMembership,
|
|
]);
|
|
});
|
|
|
|
it('should combine memberships when target has no memberships but source does', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: undefined,
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
// When target.memberships is undefined, concat returns undefined
|
|
expect(target.memberships).toBeUndefined();
|
|
});
|
|
|
|
it('should combine memberships when target has memberships but source has no memberships', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: undefined,
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.memberships).toHaveLength(1);
|
|
expect(target.memberships).toEqual([
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
]);
|
|
});
|
|
|
|
it('should handle empty arrays in source', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: [],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.channels).toHaveLength(1);
|
|
expect(target.memberships).toHaveLength(1);
|
|
});
|
|
|
|
it('should handle empty arrays in target', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: [],
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: [
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
],
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.channels).toHaveLength(1);
|
|
expect(target.memberships).toHaveLength(1);
|
|
});
|
|
|
|
it('should handle undefined values in both target and source', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: undefined,
|
|
memberships: undefined,
|
|
categories: undefined,
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: undefined,
|
|
memberships: undefined,
|
|
categories: undefined,
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.channels).toBeUndefined();
|
|
expect(target.memberships).toBeUndefined();
|
|
expect(target.categories).toBeUndefined();
|
|
});
|
|
|
|
it('should handle mixed undefined and defined values', () => {
|
|
const target: MyChannelsRequest = {
|
|
channels: [
|
|
{id: 'channel1', name: 'Channel 1', type: 'O', team_id: 'team1'} as Channel,
|
|
],
|
|
memberships: undefined,
|
|
};
|
|
|
|
const source: MyChannelsRequest = {
|
|
channels: undefined,
|
|
memberships: [
|
|
{channel_id: 'channel1', user_id: 'user1'} as ChannelMembership,
|
|
],
|
|
};
|
|
|
|
combineChannelsData(target, source);
|
|
|
|
expect(target.channels).toHaveLength(1);
|
|
|
|
// When target.memberships is undefined, concat returns undefined
|
|
expect(target.memberships).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
/* eslint-enable max-lines */
|