mattermost-mobile-upstream/app/actions/remote/team.test.ts
JG Heithcock fde1f2af8b
MM-68575: Use server getMyTeams for canJoinOtherTeams check (#9736)
* MM-68575: Base canJoinOtherTeams on getMyTeams instead of local DB

updateCanJoinTeams compared paginated getTeams() against queryMyTeams(),
which could lag sync. Sysadmins see all teams from getTeams(), so stale
local membership made join affordances appear until a later refresh.

Use client.getMyTeams() for membership IDs and keep recCanJoinTeams
unchanged. Add regression tests for full membership vs joinable teams and
groupLabel forwarding.
2026-05-13 10:23:21 -07:00

499 lines
20 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getActiveServerUrl} from '@queries/app/servers';
import EphemeralStore from '@store/ephemeral_store';
import {fetchScheduledPosts} from './scheduled_post';
import {
addCurrentUserToTeam,
addUserToTeam,
addUsersToTeam,
sendEmailInvitesToTeam,
sendGuestEmailInvitesToTeam,
fetchMyTeams,
fetchMyTeam,
fetchTeamById,
fetchAllTeams,
fetchTeamsForComponent,
updateCanJoinTeams,
fetchTeamByName,
removeCurrentUserFromTeam,
removeUserFromTeam,
handleTeamChange,
handleKickFromTeam,
getTeamMembersByIds,
buildTeamIconUrl,
fetchTeamsThreads,
} from './team';
jest.mock('./scheduled_post', () => ({
fetchScheduledPosts: jest.fn(),
}));
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const user: UserProfile = {
id: 'userid',
username: 'username',
roles: '',
} as UserProfile;
let mockIsTablet: jest.Mock;
jest.mock('@utils/helpers', () => {
const original = jest.requireActual('@utils/helpers');
mockIsTablet = jest.fn(() => false);
return {
...original,
isTablet: mockIsTablet,
};
});
let mockGetActiveServer: jest.Mock;
jest.mock('@queries/app/servers', () => {
const original = jest.requireActual('@queries/app/servers');
mockGetActiveServer = jest.fn(() => false);
return {
...original,
getActiveServer: mockGetActiveServer,
};
});
jest.mock('@queries/app/servers', () => ({
getActiveServerUrl: jest.fn(),
}));
const channelId = 'channelid1';
const teamId = 'teamid1';
const team: Team = {
id: teamId,
name: 'team1',
} as Team;
const mockClient = {
getTeam: jest.fn((id: string) => ({id, name: 'team1'})),
getTeamMember: jest.fn((id: string, userId: string) => ({id: userId + '-' + id, user_id: userId === 'me' ? 'userid1' : userId, team_id: id, roles: ''})),
addToTeam: jest.fn((id: string, userId: string) => ({id: userId + '-' + id, user_id: userId, team_id: id, roles: ''})),
addUsersToTeamGracefully: jest.fn((id: string, userIds: string[]) => (userIds.map((userId) => ({member: {id: userId + '-' + id, user_id: userId, team_id: id, roles: ''}, error: undefined, user_id: userId})))),
sendEmailInvitesToTeamGracefully: jest.fn((id: string, emails: string[]) => (emails.map((email) => ({email, error: undefined})))),
sendGuestEmailInvitesToTeamGracefully: jest.fn(() => Promise.resolve<TeamInviteWithError[]>([{email: 'guest1@example.com', error: {message: '', status_code: 0}}])),
getRolesByNames: jest.fn((roles: string[]) => roles.map((r) => ({id: r, name: r} as Role))),
getMyTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
getMyTeamMembers: jest.fn(() => ([{id: 'userid1-' + teamId, user_id: 'userid1', team_id: teamId, roles: ''}])),
getTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
getMyChannels: jest.fn((id: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: id, total_msg_count: 1}])),
getMyChannelMembers: jest.fn(() => ([{id: user.id + '-' + channelId, user_id: user.id, channel_id: channelId, roles: '', msg_count: 0}])),
getCategories: jest.fn((userId: string, id: string) => ({categories: [{id: 'categoryid', channel_ids: [channelId], team_id: id}], order: ['categoryid']})),
getTeamByName: jest.fn((name: string) => ({id: teamId, name})),
removeFromTeam: jest.fn(),
getTeamMembersByIds: jest.fn((id: string, userIds: string[]) => (userIds.map((userId) => ({id: userId + '-' + id, user_id: userId, team_id: id, roles: ''})))),
getPosts: jest.fn(() => ({
order: ['yocj9xgkh78exk1uhx9yny1zxy', 'ad6yoisgh385fy7rkph49zpfqa', 'o5qqa4ntdigp7rbnf75f8hgeaw'],
posts: [{
channel_id: channelId,
create_at: 1726107604522,
delete_at: 0,
edit_at: 0,
hashtags: '',
id: 'yocj9xgkh78exk1uhx9yny1zxy',
is_pinned: false,
last_reply_at: 0,
message: 'Message ead863',
metadata: {},
original_id: '',
participants: null,
pending_post_id: '',
props: {},
remote_id: '',
reply_count: 0,
root_id: '',
type: '',
update_at: 1726107604522,
user_id: 'k479wsypafgjddz8cerqx4m1ha',
}],
previousPostId: '',
})),
};
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('teamMember', () => {
it('addCurrentUserToTeam - handle not found database', async () => {
const result = await addCurrentUserToTeam('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('addCurrentUserToTeam - no current user', async () => {
const result = await addCurrentUserToTeam(serverUrl, teamId);
expect(result?.error).toBeDefined();
expect(result.error).toBe('no current user');
});
it('addCurrentUserToTeam - base case', async () => {
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const result = await addCurrentUserToTeam(serverUrl, teamId) as {member: TeamMembership};
expect(result).toBeDefined();
expect(result.member).toBeDefined();
});
it('addUserToTeam - handle not found database', async () => {
const result = await addUserToTeam('foo', '', '') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('addUserToTeam - base case', async () => {
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const result = await addUserToTeam(serverUrl, teamId, 'userid1') as {member: TeamMembership};
expect(result).toBeDefined();
expect(result.member).toBeDefined();
});
it('addUsersToTeam - handle not found database', async () => {
const result = await addUsersToTeam('foo', '', []) as {error: unknown};
expect(result?.error).toBeDefined();
});
it('addUsersToTeam - base case', async () => {
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const result = await addUsersToTeam(serverUrl, teamId, ['userid1']) as {members: TeamMemberWithError[]};
expect(result).toBeDefined();
expect(result.members).toBeDefined();
});
it('sendEmailInvitesToTeam - base case', async () => {
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const result = await sendEmailInvitesToTeam(serverUrl, teamId, ['user1@mattermost.com']) as {members: TeamInviteWithError[]};
expect(result).toBeDefined();
expect(result.members).toBeDefined();
});
it('handleKickFromTeam - handle not found database', async () => {
const result = await handleKickFromTeam('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('removeCurrentUserFromTeam - handle not found database', async () => {
const result = await removeCurrentUserFromTeam('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('removeCurrentUserFromTeam - base case', async () => {
const result = await removeCurrentUserFromTeam(serverUrl, teamId);
expect(result).toBeDefined();
});
it('removeUserFromTeam - base case', async () => {
const result = await removeUserFromTeam(serverUrl, 'userid1', teamId);
expect(result).toBeDefined();
});
it('getTeamMembersByIds - handle not found database', async () => {
const result = await getTeamMembersByIds('foo', '', []) as {error: unknown};
expect(result?.error).toBeDefined();
});
it('getTeamMembersByIds - base case', async () => {
const result = await getTeamMembersByIds(serverUrl, teamId, ['userid1']);
expect(result).toBeDefined();
expect(result?.members).toBeDefined();
});
});
describe('sendGuestEmailInvitesToTeam', () => {
const emails = ['guest1@example.com', 'guest2@example.com'];
const channels = ['channel-id-1', 'channel-id-2'];
const message = 'Welcome to the team!';
const throwFunc = () => {
throw Error('error');
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should send guest email invites successfully with all parameters', async () => {
const mockMembers: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
{email: 'guest2@example.com', error: {message: '', status_code: 0}},
];
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.members).toEqual(mockMembers);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
});
it('should send guest email invites successfully without message parameter', async () => {
const mockMembers: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
];
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, ['guest1@example.com'], channels);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.members).toEqual(mockMembers);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, ['guest1@example.com'], channels, '', false);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
});
it('should handle client error', async () => {
const clientError = new Error('Client error');
mockClient.sendGuestEmailInvitesToTeamGracefully.mockRejectedValueOnce(clientError);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.members).toEqual([]);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, false);
});
it('should handle network manager error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.members).toEqual([]);
});
it('should send guest email invites with guest magic link', async () => {
const mockMembers: TeamInviteWithError[] = [
{email: 'guest1@example.com', error: {message: '', status_code: 0}},
];
mockClient.sendGuestEmailInvitesToTeamGracefully.mockResolvedValueOnce(mockMembers);
const result = await sendGuestEmailInvitesToTeam(serverUrl, teamId, emails, channels, message, true);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.members).toEqual(mockMembers);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledWith(teamId, emails, channels, message, true);
expect(mockClient.sendGuestEmailInvitesToTeamGracefully).toHaveBeenCalledTimes(1);
});
});
describe('teams', () => {
it('fetchMyTeams - handle not found database', async () => {
const result = await fetchMyTeams('foo') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('fetchMyTeams - base case', async () => {
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const result = await fetchMyTeams(serverUrl);
expect(result).toBeDefined();
expect(result.teams).toBeDefined();
expect(result.memberships).toBeDefined();
});
it('fetchMyTeam - handle not found database', async () => {
const result = await fetchMyTeam('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('fetchMyTeam - base case', async () => {
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const result = await fetchMyTeam(serverUrl, teamId);
expect(result).toBeDefined();
expect(result.teams).toBeDefined();
expect(result.memberships).toBeDefined();
});
it('fetchTeamById - base case', async () => {
const result = await fetchTeamById(serverUrl, teamId);
expect(result).toBeDefined();
expect(result.team).toBeDefined();
expect(result.team?.id).toBe(teamId);
expect(mockClient.getTeam).toHaveBeenCalledWith(teamId);
});
it('fetchAllTeams - base case', async () => {
const result = await fetchAllTeams(serverUrl);
expect(result).toBeDefined();
expect(result.teams).toBeDefined();
});
it('fetchTeamsForComponent - base case', async () => {
await operator.handleTeam({teams: [team], prepareRecordsOnly: false});
const result = await fetchTeamsForComponent(serverUrl, 0);
expect(result).toBeDefined();
expect(result.teams).toBeDefined();
expect(result.hasMore).toBeFalsy();
});
it('updateCanJoinTeams - base case', async () => {
const result = await updateCanJoinTeams(serverUrl);
expect(result).toBeDefined();
});
describe('updateCanJoinTeams server membership (MM-68575)', () => {
let setCanJoinSpy: jest.SpiedFunction<typeof EphemeralStore.setCanJoinOtherTeams>;
const activeTeam = (id: string): Team => ({id, name: id, delete_at: 0} as Team);
beforeEach(() => {
setCanJoinSpy = jest.spyOn(EphemeralStore, 'setCanJoinOtherTeams');
});
afterEach(() => {
setCanJoinSpy.mockRestore();
mockClient.getMyTeams.mockImplementation(() => [{id: teamId, name: 'team1'}]);
mockClient.getTeams.mockImplementation(() => [{id: teamId, name: 'team1'}]);
});
it('should set canJoin false when getMyTeams lists full membership while getTeams returns all server teams', async () => {
(mockClient.getMyTeams as jest.Mock).mockResolvedValue([
activeTeam('t1'),
activeTeam('t2'),
activeTeam('t3'),
]);
(mockClient.getTeams as jest.Mock).mockResolvedValue([
activeTeam('t1'),
activeTeam('t2'),
activeTeam('t3'),
]);
await updateCanJoinTeams(serverUrl);
expect(setCanJoinSpy).toHaveBeenCalledWith(serverUrl, false);
});
it('should set canJoin true when an active server team is not in getMyTeams', async () => {
(mockClient.getMyTeams as jest.Mock).mockResolvedValue([activeTeam('t1')]);
(mockClient.getTeams as jest.Mock).mockResolvedValue([activeTeam('t1'), activeTeam('t2')]);
await updateCanJoinTeams(serverUrl);
expect(setCanJoinSpy).toHaveBeenCalledWith(serverUrl, true);
});
it('should pass groupLabel to getMyTeams and getTeams', async () => {
const groupLabel = 'Cold Start Deferred' as RequestGroupLabel;
(mockClient.getMyTeams as jest.Mock).mockResolvedValue([activeTeam('t1')]);
(mockClient.getTeams as jest.Mock).mockResolvedValue([activeTeam('t1')]);
await updateCanJoinTeams(serverUrl, groupLabel);
expect(mockClient.getMyTeams).toHaveBeenCalledWith(groupLabel);
expect(mockClient.getTeams).toHaveBeenCalledWith(0, PER_PAGE_DEFAULT, false, groupLabel);
});
it('should preserve the last known canJoin value when getMyTeams fails', async () => {
const err = new Error('network unavailable');
(mockClient.getMyTeams as jest.Mock).mockRejectedValue(err);
const result = await updateCanJoinTeams(serverUrl) as {error: unknown};
expect(result?.error).toBeDefined();
expect(setCanJoinSpy).not.toHaveBeenCalled();
});
it('should preserve the last known canJoin value when getTeams fails', async () => {
const err = new Error('server timeout');
(mockClient.getMyTeams as jest.Mock).mockResolvedValue([activeTeam('t1')]);
(mockClient.getTeams as jest.Mock).mockRejectedValue(err);
const result = await updateCanJoinTeams(serverUrl) as {error: unknown};
expect(result?.error).toBeDefined();
expect(setCanJoinSpy).not.toHaveBeenCalled();
});
});
it('fetchTeamsThreads - handle not found database', async () => {
const result = await fetchTeamsThreads('foo', 0, []) as {error: unknown};
expect(result?.error).toBeDefined();
});
it('fetchTeamsThreads - base case', async () => {
const result = await fetchTeamsThreads(
serverUrl, 0,
[team], true, false);
expect(result).toBeDefined();
});
it('fetchTeamsThreads - fetch only case', async () => {
const result = await fetchTeamsThreads(
serverUrl, 0,
[team], true, true);
expect(result.models).toBeDefined();
});
it('fetchTeamByName - handle not found database', async () => {
const result = await fetchTeamByName('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('fetchTeamByName - base case', async () => {
const result = await fetchTeamByName(serverUrl, teamId);
expect(result).toBeDefined();
expect(result.team).toBeDefined();
});
it('handleTeamChange - handle not found database', async () => {
const result = await handleTeamChange('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();
});
it('handleTeamChange - base case', async () => {
const result = await handleTeamChange(serverUrl, teamId);
expect(result).toBeDefined();
expect(result?.error).toBeUndefined();
expect(fetchScheduledPosts).toHaveBeenCalledWith(serverUrl, teamId, false);
});
it('handleKickFromTeam - base case', async () => {
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce(serverUrl);
const result = await handleKickFromTeam(serverUrl, teamId);
expect(result).toBeDefined();
expect(result?.error).toBeUndefined();
});
it('buildTeamIconUrl - base case', async () => {
const url = await buildTeamIconUrl(serverUrl, teamId);
expect(url).toBeDefined();
});
});