mirror of https://github.com/msgbyte/tailchat
fix(chat): enforce converse membership, add leave and friend-only invite (#318)
parent
2d3c789749
commit
dc02b00d1d
@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { showAlert } from 'tailchat-shared';
|
||||
import { request } from 'tailchat-shared/api/request';
|
||||
import { ChatConverseType } from 'tailchat-shared/model/converse';
|
||||
import { appReducer, chatActions } from 'tailchat-shared/redux/slices';
|
||||
import { getReduxStore, ReduxProvider } from 'tailchat-shared/redux/store';
|
||||
import { ConversePanel } from './ConversePanel';
|
||||
import { queryClient } from 'tailchat-shared/cache';
|
||||
import { refreshDMConverse } from 'tailchat-shared/helper/converse-helper';
|
||||
|
||||
jest.mock('tailchat-shared/api/request', () => ({
|
||||
request: { get: jest.fn(), post: jest.fn() },
|
||||
}));
|
||||
jest.mock('tailchat-shared', () => ({
|
||||
...jest.requireActual('tailchat-shared/redux/hooks/useAppSelector'),
|
||||
model: { converse: jest.requireActual('tailchat-shared/model/converse') },
|
||||
t: (text: string) => text,
|
||||
useUserId: () => 'me',
|
||||
useUserInfoList: () => [],
|
||||
useDMConverseName: () => 'Conversation',
|
||||
showAlert: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/components/ChatBox', () => ({
|
||||
ChatBox: () => <div>Messages</div>,
|
||||
}));
|
||||
jest.mock('@/components/ChatBox/ChatInputBox/context', () => ({
|
||||
ChatInputMentionsContextProvider: ({ children }: any) => children,
|
||||
}));
|
||||
jest.mock('@/components/IconBtn', () => ({
|
||||
IconBtn: ({ title, onClick }: any) => (
|
||||
<button onClick={onClick}>{title}</button>
|
||||
),
|
||||
}));
|
||||
jest.mock('@/components/UserListItem', () => ({ UserListItem: () => null }));
|
||||
jest.mock('@/components/Modal', () => ({ openModal: jest.fn() }));
|
||||
jest.mock('@/components/modals/AppendDMConverseMembers', () => ({
|
||||
AppendDMConverseMembers: () => null,
|
||||
}));
|
||||
jest.mock('@/components/modals/CreateDMConverse', () => ({
|
||||
CreateDMConverse: () => null,
|
||||
}));
|
||||
jest.mock('@/components/OpenedPanelTip', () => ({
|
||||
OpenedPanelTip: () => null,
|
||||
}));
|
||||
jest.mock('@/hooks/usePanelWindow', () => ({
|
||||
usePanelWindow: () => ({ hasOpenedPanel: false }),
|
||||
}));
|
||||
jest.mock('@/plugin/common', () => ({ pluginPanelActions: [] }));
|
||||
jest.mock('../common/MessageSearch', () => ({
|
||||
MessageSearchPanel: () => null,
|
||||
}));
|
||||
jest.mock('../common/Wrapper', () => ({
|
||||
CommonPanelWrapper: ({ children, actions }: any) => (
|
||||
<div>
|
||||
{actions({ setRightPanel: jest.fn() })}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function Location() {
|
||||
return <output>{useLocation().pathname}</output>;
|
||||
}
|
||||
|
||||
describe('leaving a private conversation', () => {
|
||||
const store = getReduxStore();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
queryClient.clear();
|
||||
store.replaceReducer((state, action) =>
|
||||
appReducer(action.type === 'test/reset' ? undefined : state, action)
|
||||
);
|
||||
store.dispatch({ type: 'test/reset' });
|
||||
(request.post as jest.Mock).mockResolvedValue({ data: {} });
|
||||
(request.get as jest.Mock).mockRejectedValue({ code: 403 });
|
||||
});
|
||||
|
||||
afterEach(() => queryClient.clear());
|
||||
|
||||
function open(type = ChatConverseType.Multi) {
|
||||
store.dispatch(
|
||||
chatActions.setConverseInfo({
|
||||
_id: 'conversation',
|
||||
name: '',
|
||||
type,
|
||||
members: ['me', 'other'],
|
||||
})
|
||||
);
|
||||
return render(
|
||||
<ReduxProvider store={store}>
|
||||
<MemoryRouter initialEntries={['/main/personal/converse/conversation']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/main/personal/converse/:id"
|
||||
element={<ConversePanel converseId="conversation" />}
|
||||
/>
|
||||
<Route path="/main/personal/friends" element={<div>Friends</div>} />
|
||||
</Routes>
|
||||
<Location />
|
||||
</MemoryRouter>
|
||||
</ReduxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
test('a two-member Multi keeps invite, member list and leave actions', () => {
|
||||
open();
|
||||
expect(screen.getByRole('button', { name: '邀请成员' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '成员列表' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '退出会话' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '创建会话' })).toBeNull();
|
||||
});
|
||||
|
||||
test('a direct message does not offer leaving', () => {
|
||||
open(ChatConverseType.DM);
|
||||
expect(screen.queryByRole('button', { name: '退出会话' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '创建会话' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('leaving waits for confirmation and server success before removing or navigating', async () => {
|
||||
open();
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出会话' }));
|
||||
expect(request.post).not.toHaveBeenCalled();
|
||||
expect(store.getState().chat.converses.conversation).toBeDefined();
|
||||
|
||||
const { onConfirm } = (showAlert as jest.Mock).mock.calls[0][0];
|
||||
(request.post as jest.Mock).mockRejectedValueOnce(new Error('failed'));
|
||||
await act(async () => {
|
||||
await expect(onConfirm()).rejects.toThrow('failed');
|
||||
});
|
||||
expect(store.getState().chat.converses.conversation).toBeDefined();
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'/main/personal/converse/conversation'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await onConfirm();
|
||||
});
|
||||
expect(request.post).toHaveBeenLastCalledWith(
|
||||
'/api/chat/converse/leaveDMConverse',
|
||||
{ converseId: 'conversation' }
|
||||
);
|
||||
expect(store.getState().chat.converses.conversation).toBeUndefined();
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'/main/personal/friends'
|
||||
);
|
||||
expect(screen.queryByText('Messages')).toBeNull();
|
||||
});
|
||||
|
||||
test('a leave notification on another session closes the active conversation', async () => {
|
||||
open();
|
||||
await act(async () => {
|
||||
store.dispatch(
|
||||
chatActions.removeDMConverse({ converseId: 'conversation' })
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'/main/personal/friends'
|
||||
);
|
||||
expect(screen.queryByText('Messages')).toBeNull();
|
||||
});
|
||||
|
||||
test('a delayed leave response preserves a newer verified invitation', async () => {
|
||||
let finishLeave!: (value: unknown) => void;
|
||||
(request.post as jest.Mock).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishLeave = resolve;
|
||||
})
|
||||
);
|
||||
open();
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出会话' }));
|
||||
const leaving = (showAlert as jest.Mock).mock.calls[0][0].onConfirm();
|
||||
(request.get as jest.Mock).mockResolvedValue({
|
||||
data: {
|
||||
_id: 'conversation',
|
||||
name: '',
|
||||
type: ChatConverseType.Multi,
|
||||
members: ['me', 'other'],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
await refreshDMConverse('conversation', 'me');
|
||||
});
|
||||
await act(async () => {
|
||||
finishLeave({ data: true });
|
||||
await leaving;
|
||||
});
|
||||
|
||||
expect(store.getState().chat.converses.conversation).toBeDefined();
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'/main/personal/converse/conversation'
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { request } from 'tailchat-shared/api/request';
|
||||
import { setToasts } from 'tailchat-shared/manager/ui';
|
||||
import { closeModal } from '../Modal';
|
||||
import { AppendDMConverseMembers } from './AppendDMConverseMembers';
|
||||
|
||||
jest.mock('tailchat-shared/api/request', () => ({
|
||||
request: { post: jest.fn() },
|
||||
}));
|
||||
jest.mock('tailchat-shared', () => ({
|
||||
...jest.requireActual('tailchat-shared/model/converse'),
|
||||
...jest.requireActual('tailchat-shared/hooks/useAsyncFn'),
|
||||
...jest.requireActual('tailchat-shared/hooks/useAsyncRequest'),
|
||||
t: (text: string) => text,
|
||||
}));
|
||||
jest.mock('../Modal', () => ({
|
||||
closeModal: jest.fn(),
|
||||
ModalWrapper: ({ children }: any) => children,
|
||||
}));
|
||||
jest.mock('../UserPicker/FriendPicker', () => ({
|
||||
FriendPicker: ({ onChange }: any) => (
|
||||
<button onClick={() => onChange(['other'])}>Select</button>
|
||||
),
|
||||
}));
|
||||
|
||||
test('an invitation rejected by the recipient setting shows the server error and keeps the dialog open', async () => {
|
||||
const toast = jest.fn();
|
||||
setToasts(toast);
|
||||
(request.post as jest.Mock).mockRejectedValue(
|
||||
new Error('Only friends may invite this user')
|
||||
);
|
||||
const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<AppendDMConverseMembers converseId="conversation" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /确\s*认/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
'Only friends may invite this user',
|
||||
'error'
|
||||
)
|
||||
);
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
errorLog.mockRestore();
|
||||
});
|
||||
@ -0,0 +1,258 @@
|
||||
import { request } from 'tailchat-shared/api/request';
|
||||
import type { AppSocket } from 'tailchat-shared/api/socket';
|
||||
import { queryClient } from 'tailchat-shared/cache';
|
||||
import { CacheKey } from 'tailchat-shared/cache/cache';
|
||||
import { sharedEvent } from 'tailchat-shared/event';
|
||||
import {
|
||||
ensureDMConverse,
|
||||
refreshDMConverse,
|
||||
} from 'tailchat-shared/helper/converse-helper';
|
||||
import { ChatConverseType } from 'tailchat-shared/model/converse';
|
||||
import { setupRedux } from 'tailchat-shared/redux/setup';
|
||||
import {
|
||||
appReducer,
|
||||
chatActions,
|
||||
userActions,
|
||||
} from 'tailchat-shared/redux/slices';
|
||||
import * as storeModule from 'tailchat-shared/redux/store';
|
||||
|
||||
jest.mock('tailchat-shared/api/request', () => ({
|
||||
request: { get: jest.fn(), post: jest.fn() },
|
||||
}));
|
||||
|
||||
const converse = {
|
||||
_id: 'conversation',
|
||||
type: ChatConverseType.Multi,
|
||||
name: '',
|
||||
members: ['me', 'other'],
|
||||
};
|
||||
const message = {
|
||||
_id: 'message',
|
||||
converseId: converse._id,
|
||||
author: 'other',
|
||||
content: 'hello',
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('private conversation membership synchronization', () => {
|
||||
let store: storeModule.AppStore;
|
||||
let listeners: Record<string, (data: any) => unknown>;
|
||||
let receiveMessage: jest.Mock;
|
||||
let socketRequest: jest.Mock;
|
||||
let reconnect: () => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
queryClient.clear();
|
||||
jest.clearAllMocks();
|
||||
store = storeModule.getReduxStore();
|
||||
store.replaceReducer((state, action) =>
|
||||
appReducer(action.type === 'test/reset' ? undefined : state, action)
|
||||
);
|
||||
store.dispatch({ type: 'test/reset' });
|
||||
store.dispatch(userActions.setUserInfo({ _id: 'me' } as any));
|
||||
listeners = {};
|
||||
receiveMessage = jest.fn();
|
||||
sharedEvent.on('receiveMessage', receiveMessage);
|
||||
(request.get as jest.Mock).mockResolvedValue({ data: converse });
|
||||
(request.post as jest.Mock).mockResolvedValue({ data: {} });
|
||||
socketRequest = jest.fn().mockResolvedValue([]);
|
||||
setupRedux(
|
||||
{
|
||||
request: socketRequest,
|
||||
listen: (name: string, listener: (data: any) => unknown) => {
|
||||
listeners[name] = listener;
|
||||
},
|
||||
onReconnect: (callback: () => void) => {
|
||||
reconnect = callback;
|
||||
},
|
||||
} as unknown as AppSocket,
|
||||
store
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sharedEvent.off('receiveMessage', receiveMessage);
|
||||
queryClient.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
// Drain query and socket continuations without advancing query retry timers.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('leaving clears the conversation, unread state and cache, and suppresses late messages', async () => {
|
||||
store.dispatch(chatActions.setConverseInfo(converse));
|
||||
store.dispatch(chatActions.updateCurrentConverseId(converse._id));
|
||||
store.dispatch(
|
||||
chatActions.setConverseAck({
|
||||
converseId: converse._id,
|
||||
lastMessageId: 'old',
|
||||
})
|
||||
);
|
||||
store.dispatch(
|
||||
chatActions.setLastMessageMap([
|
||||
{ converseId: converse._id, lastMessageId: 'new' },
|
||||
])
|
||||
);
|
||||
queryClient.setQueryData([CacheKey.converse, converse._id], converse);
|
||||
|
||||
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
|
||||
listeners['chat.message.add'](message);
|
||||
await flush();
|
||||
|
||||
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
|
||||
expect(store.getState().chat.currentConverseId).toBeNull();
|
||||
expect(store.getState().chat.ack[converse._id]).toBeUndefined();
|
||||
expect(store.getState().chat.lastMessageMap[converse._id]).toBeUndefined();
|
||||
expect(
|
||||
queryClient.getQueryData([CacheKey.converse, converse._id])
|
||||
).toBeUndefined();
|
||||
expect(request.post).not.toHaveBeenCalled();
|
||||
expect(receiveMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an in-flight message lookup cannot restore membership after leaving', async () => {
|
||||
const response = deferred<{ data: typeof converse }>();
|
||||
(request.get as jest.Mock).mockReturnValue(response.promise);
|
||||
listeners['chat.message.add'](message);
|
||||
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
|
||||
response.resolve({ data: converse });
|
||||
await flush();
|
||||
|
||||
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
|
||||
expect(request.post).not.toHaveBeenCalled();
|
||||
expect(receiveMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reopening a cached conversation checks current membership before adding it to the list', async () => {
|
||||
queryClient.setQueryData([CacheKey.converse, converse._id], converse);
|
||||
(request.get as jest.Mock).mockResolvedValue({
|
||||
data: { ...converse, members: ['other'] },
|
||||
});
|
||||
|
||||
await expect(ensureDMConverse(converse._id, 'me')).rejects.toThrow();
|
||||
|
||||
expect(request.get).toHaveBeenCalled();
|
||||
expect(request.post).not.toHaveBeenCalled();
|
||||
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a stale update cannot restore membership but a verified new invitation can', async () => {
|
||||
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
|
||||
(request.get as jest.Mock).mockResolvedValue({
|
||||
data: { ...converse, members: ['other'] },
|
||||
});
|
||||
listeners['chat.converse.updateDMConverse'](converse);
|
||||
await flush();
|
||||
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
|
||||
|
||||
(request.get as jest.Mock).mockResolvedValue({ data: converse });
|
||||
listeners['chat.converse.updateDMConverse'](converse);
|
||||
await flush();
|
||||
expect(store.getState().chat.converses[converse._id]?.members).toEqual([
|
||||
'me',
|
||||
'other',
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaving while an invitation refresh is pending wins over the old response', async () => {
|
||||
const response = deferred<{ data: typeof converse }>();
|
||||
(request.get as jest.Mock).mockReturnValue(response.promise);
|
||||
listeners['chat.converse.updateDMConverse'](converse);
|
||||
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
|
||||
response.resolve({ data: converse });
|
||||
await flush();
|
||||
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('closing only hides the conversation and a later message can reopen it', async () => {
|
||||
store.dispatch(chatActions.setConverseInfo(converse));
|
||||
store.dispatch(chatActions.removeConverse({ converseId: converse._id }));
|
||||
listeners['chat.message.add'](message);
|
||||
await flush();
|
||||
expect(store.getState().chat.converses[converse._id]?.messages).toEqual([
|
||||
message,
|
||||
]);
|
||||
expect(receiveMessage).toHaveBeenCalledWith(message);
|
||||
});
|
||||
|
||||
test('a list membership lookup started before leaving cannot restore the conversation', async () => {
|
||||
const response = deferred<{ data: typeof converse }>();
|
||||
(request.get as jest.Mock).mockReturnValue(response.promise);
|
||||
socketRequest.mockImplementation(async (name) =>
|
||||
name === 'user.dmlist.getAllConverse' ? [converse._id] : []
|
||||
);
|
||||
reconnect();
|
||||
await flush();
|
||||
expect(request.get).toHaveBeenCalled();
|
||||
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
|
||||
response.resolve({ data: converse });
|
||||
await flush();
|
||||
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('reconnect restores an invitation received while offline', async () => {
|
||||
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
|
||||
socketRequest.mockImplementation(async (name) =>
|
||||
name === 'user.dmlist.getAllConverse' ? [converse._id] : []
|
||||
);
|
||||
reconnect();
|
||||
await flush();
|
||||
expect(store.getState().chat.converses[converse._id]?.members).toEqual(
|
||||
converse.members
|
||||
);
|
||||
expect(store.getState().chat.converseMembership[converse._id].removed).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('a pending list-add response cannot restore or notify after leaving', async () => {
|
||||
const response = deferred<{ data: unknown }>();
|
||||
(request.post as jest.Mock).mockReturnValue(response.promise);
|
||||
listeners['chat.message.add'](message);
|
||||
await flush();
|
||||
expect(request.post).toHaveBeenCalled();
|
||||
listeners['chat.converse.removeDMConverse']({ converseId: converse._id });
|
||||
response.resolve({ data: {} });
|
||||
await flush();
|
||||
expect(store.getState().chat.converses[converse._id]).toBeUndefined();
|
||||
expect(receiveMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opening a conversation and a concurrent invitation refresh both complete', async () => {
|
||||
const response = deferred<{ data: typeof converse }>();
|
||||
(request.get as jest.Mock)
|
||||
.mockReturnValueOnce(response.promise)
|
||||
.mockResolvedValue({ data: converse });
|
||||
const opening = ensureDMConverse(converse._id, 'me');
|
||||
const updated = refreshDMConverse(converse._id, 'me');
|
||||
response.resolve({ data: converse });
|
||||
await expect(updated).resolves.toEqual(converse);
|
||||
await expect(opening).resolves.toEqual(converse);
|
||||
});
|
||||
|
||||
test('an older denied membership response cannot erase a newer invitation', async () => {
|
||||
const response = deferred<{ data: typeof converse }>();
|
||||
(request.get as jest.Mock)
|
||||
.mockReturnValueOnce(response.promise)
|
||||
.mockResolvedValue({ data: converse });
|
||||
const oldRefresh = refreshDMConverse(converse._id, 'me');
|
||||
await refreshDMConverse(converse._id, 'me');
|
||||
response.reject({ code: 403 });
|
||||
await oldRefresh;
|
||||
expect(store.getState().chat.converses[converse._id]?.members).toEqual(
|
||||
converse.members
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { request } from 'tailchat-shared/api/request';
|
||||
import { queryClient } from 'tailchat-shared/cache';
|
||||
import { CacheKey } from 'tailchat-shared/cache/cache';
|
||||
import { CacheProvider } from 'tailchat-shared/cache/Provider';
|
||||
import { useUserSettings } from 'tailchat-shared/hooks/model/useUserSettings';
|
||||
import { setStorage } from 'tailchat-shared/manager/storage';
|
||||
import { setToasts } from 'tailchat-shared/manager/ui';
|
||||
import { SettingsSystem } from '../src/components/modals/SettingsView/System';
|
||||
|
||||
jest.mock('tailchat-shared/api/request', () => ({
|
||||
request: { get: jest.fn(), post: jest.fn() },
|
||||
}));
|
||||
jest.mock('tailchat-shared', () => ({
|
||||
...jest.requireActual('tailchat-shared/hooks/model/useUserSettings'),
|
||||
t: (text: string) => text,
|
||||
useColorScheme: () => ({ colorScheme: 'dark', setColorScheme: jest.fn() }),
|
||||
useAlphaMode: () => ({ isAlphaMode: false, setAlphaMode: jest.fn() }),
|
||||
}));
|
||||
jest.mock('@/components/FullModal/Factory', () => ({
|
||||
FullModalFactory: () => null,
|
||||
}));
|
||||
jest.mock('@/components/FullModal/Field', () => ({
|
||||
FullModalField: ({ title, content, tip }: any) => (
|
||||
<section>
|
||||
{title}
|
||||
{content}
|
||||
{tip}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
jest.mock('@/components/LanguageSelect', () => ({
|
||||
LanguageSelect: () => null,
|
||||
}));
|
||||
jest.mock('@/plugin/common', () => ({
|
||||
pluginColorScheme: [],
|
||||
pluginSettings: [],
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
setStorage(() => ({
|
||||
get: async () => undefined,
|
||||
set: async () => {},
|
||||
save: async () => {},
|
||||
remove: async () => {},
|
||||
}));
|
||||
setToasts(jest.fn());
|
||||
queryClient.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('a failed privacy setting save restores the last saved value', async () => {
|
||||
queryClient.setQueryData([CacheKey.userSettings], {
|
||||
onlyAllowFriendInvite: false,
|
||||
});
|
||||
(request.post as jest.Mock).mockRejectedValueOnce(new Error('save failed'));
|
||||
const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result, unmount } = renderHook(() => useUserSettings(), {
|
||||
wrapper: CacheProvider,
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setSettings({ onlyAllowFriendInvite: true });
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData([CacheKey.userSettings])).toEqual({
|
||||
onlyAllowFriendInvite: false,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.settings.onlyAllowFriendInvite).toBe(false)
|
||||
);
|
||||
unmount();
|
||||
errorLog.mockRestore();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
test('the invitation preference defaults off and persists both switch values', async () => {
|
||||
queryClient.setQueryData([CacheKey.userSettings], {});
|
||||
(request.post as jest.Mock)
|
||||
.mockResolvedValueOnce({ data: { onlyAllowFriendInvite: true } })
|
||||
.mockResolvedValueOnce({ data: { onlyAllowFriendInvite: false } });
|
||||
render(
|
||||
<CacheProvider>
|
||||
<SettingsSystem />
|
||||
</CacheProvider>
|
||||
);
|
||||
const toggle = screen.getByRole('switch', { name: '仅允许好友邀请创建会话' });
|
||||
await waitFor(() => expect(toggle.hasAttribute('disabled')).toBe(false));
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(toggle.getAttribute('aria-checked')).toBe('true'));
|
||||
expect(request.post).toHaveBeenLastCalledWith('/api/user/setUserSettings', {
|
||||
settings: { onlyAllowFriendInvite: true },
|
||||
});
|
||||
await waitFor(() => expect(toggle.hasAttribute('disabled')).toBe(false));
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() =>
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
);
|
||||
expect(request.post).toHaveBeenLastCalledWith('/api/user/setUserSettings', {
|
||||
settings: { onlyAllowFriendInvite: false },
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,682 @@
|
||||
import { Types } from 'mongoose';
|
||||
import { TcBroker } from 'tailchat-server-sdk';
|
||||
import ConverseService from '../../../services/core/chat/converse.service';
|
||||
import MessageService from '../../../services/core/chat/message.service';
|
||||
import UserService from '../../../services/core/user/user.service';
|
||||
import FriendService from '../../../services/core/user/friend.service';
|
||||
import UserDMListService from '../../../services/core/user/dmlist.service';
|
||||
|
||||
describe('Private conversation membership and invitation preferences', () => {
|
||||
const broker = new TcBroker({ logger: false, cacher: 'Memory' });
|
||||
const converse = broker.createService(ConverseService) as ConverseService;
|
||||
const message = broker.createService(MessageService) as MessageService;
|
||||
const user = broker.createService(UserService) as UserService;
|
||||
const friend = broker.createService(FriendService) as FriendService;
|
||||
const dmlist = broker.createService(UserDMListService) as UserDMListService;
|
||||
const notifications: any[] = [];
|
||||
const rooms = new Map<string, Set<string>>();
|
||||
let users: string[];
|
||||
let converseIds: string[];
|
||||
let beforeJoin: (userId: string) => Promise<void>;
|
||||
let beforeLeave: (userId: string) => Promise<void>;
|
||||
|
||||
broker.createService({
|
||||
name: 'gateway',
|
||||
actions: {
|
||||
notify(ctx) {
|
||||
notifications.push(ctx.params);
|
||||
},
|
||||
async joinRoom(ctx) {
|
||||
const userId = ctx.params.userId ?? ctx.meta.userId;
|
||||
await beforeJoin?.(userId);
|
||||
const joined = rooms.get(userId) ?? new Set<string>();
|
||||
ctx.params.roomIds.forEach((id: string) => joined.add(id));
|
||||
rooms.set(userId, joined);
|
||||
},
|
||||
async leaveRoom(ctx) {
|
||||
await beforeLeave?.(ctx.params.userId ?? ctx.meta.userId);
|
||||
ctx.params.roomIds.forEach((id: string) =>
|
||||
rooms.get(ctx.params.userId ?? ctx.meta.userId)?.delete(id)
|
||||
);
|
||||
},
|
||||
checkUserOnline(ctx) {
|
||||
return ctx.params.userIds.map(() => false);
|
||||
},
|
||||
},
|
||||
});
|
||||
let joinedPanelIds: string[];
|
||||
broker.createService({
|
||||
name: 'group',
|
||||
actions: {
|
||||
getGroupInfo: () => ({
|
||||
members: users.map((userId) => ({ userId })),
|
||||
panels: [],
|
||||
}),
|
||||
getJoinedGroupAndPanelIds: () => ({
|
||||
groupIds: [],
|
||||
textPanelIds: joinedPanelIds,
|
||||
subscribeFeaturePanelIds: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const asUser = (userId: string) => ({
|
||||
meta: { userId, user: { nickname: 'Test member' } },
|
||||
});
|
||||
const invoke = <T = any>(action: string, userId: string, params = {}) =>
|
||||
broker.call<T, object>(action, params, asUser(userId));
|
||||
const create = async (memberIds = users.slice(1)) => {
|
||||
const result = await invoke('chat.converse.createDMConverse', users[0], {
|
||||
memberIds,
|
||||
});
|
||||
converseIds.push(result._id);
|
||||
return result._id as string;
|
||||
};
|
||||
const setPreference = (userId: string, enabled: boolean) =>
|
||||
invoke('user.setUserSettings', userId, {
|
||||
settings: { onlyAllowFriendInvite: enabled },
|
||||
});
|
||||
|
||||
beforeAll(() => broker.start());
|
||||
beforeEach(async () => {
|
||||
converseIds = [];
|
||||
notifications.length = 0;
|
||||
rooms.clear();
|
||||
beforeJoin = undefined;
|
||||
beforeLeave = undefined;
|
||||
joinedPanelIds = [];
|
||||
users = await Promise.all(
|
||||
Array.from({ length: 4 }, async () => {
|
||||
const id = new Types.ObjectId();
|
||||
await user.adapter.model.create({
|
||||
_id: id,
|
||||
email: `${id}@conversation.test`,
|
||||
nickname: 'Test member',
|
||||
password: 'unused-test-password',
|
||||
discriminator: '0000',
|
||||
});
|
||||
return String(id);
|
||||
})
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
const created = await converse.adapter.model.find({
|
||||
members: { $in: users.map((id) => new Types.ObjectId(id)) },
|
||||
});
|
||||
converseIds.push(...created.map((record) => String(record._id)));
|
||||
await Promise.all([
|
||||
message.adapter.model.deleteMany({ converseId: { $in: converseIds } }),
|
||||
converse.adapter.model.deleteMany({ _id: { $in: converseIds } }),
|
||||
dmlist.adapter.model.deleteMany({ userId: { $in: users } }),
|
||||
friend.adapter.model.deleteMany({
|
||||
$or: [{ from: { $in: users } }, { to: { $in: users } }],
|
||||
}),
|
||||
user.adapter.model.deleteMany({ _id: { $in: users } }),
|
||||
]);
|
||||
});
|
||||
afterAll(() => broker.stop());
|
||||
|
||||
test.each([undefined, false])(
|
||||
'allows nonfriend invitations when preference is %s',
|
||||
async (enabled) => {
|
||||
if (enabled !== undefined) {
|
||||
await setPreference(users[1], enabled);
|
||||
}
|
||||
const id = await create(users.slice(1, 3));
|
||||
await invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[3]],
|
||||
});
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String).sort()
|
||||
).toEqual([...users].sort());
|
||||
}
|
||||
);
|
||||
|
||||
test('rejects an opted-in nonfriend carried over from an existing direct message', async () => {
|
||||
await setPreference(users[1], true);
|
||||
await create([users[1]]); // One-to-one messages keep their existing policy.
|
||||
await expect(create(users.slice(1, 3))).rejects.toThrow();
|
||||
expect(
|
||||
await converse.adapter.model.countDocuments({
|
||||
type: 'Multi',
|
||||
members: new Types.ObjectId(users[0]),
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('uses the invited user friendship direction and respects preference changes', async () => {
|
||||
await setPreference(users[1], true);
|
||||
await friend.adapter.model.create({ from: users[0], to: users[1] });
|
||||
await expect(create(users.slice(1, 3))).rejects.toThrow();
|
||||
await friend.adapter.model.create({ from: users[1], to: users[0] });
|
||||
await expect(create(users.slice(1, 3))).resolves.toEqual(
|
||||
expect.any(String)
|
||||
);
|
||||
await friend.adapter.model.deleteMany({ from: users[1], to: users[0] });
|
||||
await expect(create(users.slice(1, 3))).rejects.toThrow();
|
||||
await setPreference(users[1], false);
|
||||
await expect(create(users.slice(1, 3))).resolves.toEqual(
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
test('checks append invitations before mutating membership', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
await setPreference(users[3], true);
|
||||
await expect(
|
||||
invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[3]],
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).not.toContain(users[3]);
|
||||
await friend.adapter.model.create({ from: users[3], to: users[0] });
|
||||
await invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[3], users[3]],
|
||||
});
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members
|
||||
.map(String)
|
||||
.filter((id) => id === users[3])
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('append retries a failed socket join after membership was already added', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
const append = () =>
|
||||
invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[3]],
|
||||
});
|
||||
beforeJoin = async () => {
|
||||
throw new Error('Gateway unavailable');
|
||||
};
|
||||
await expect(append()).rejects.toThrow('Gateway unavailable');
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).toContain(users[3]);
|
||||
expect(rooms.get(users[3])?.has(id)).not.toBe(true);
|
||||
|
||||
beforeJoin = undefined;
|
||||
await setPreference(users[3], true);
|
||||
const previousMessageCount = await message.adapter.model.countDocuments({
|
||||
converseId: id,
|
||||
});
|
||||
await append();
|
||||
expect(rooms.get(users[3])?.has(id)).toBe(true);
|
||||
expect(
|
||||
(await invoke('user.dmlist.getAllConverse', users[3])).map(String)
|
||||
).toContain(id);
|
||||
expect(await message.adapter.model.countDocuments({ converseId: id })).toBe(
|
||||
previousMessageCount
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves membership, sockets and lists and cannot restore access by reconnecting or sending messages', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
|
||||
const stored = await converse.adapter.model.findById(id);
|
||||
expect(stored.type).toBe('Multi');
|
||||
expect(stored.members.map(String)).toEqual([users[0], users[2]]);
|
||||
expect(rooms.get(users[1])?.has(id)).toBe(false);
|
||||
expect(notifications).toContainEqual({
|
||||
type: 'unicast',
|
||||
target: users[1],
|
||||
eventName: 'notify:chat.converse.removeDMConverse',
|
||||
eventData: { converseId: id },
|
||||
});
|
||||
await expect(
|
||||
invoke('chat.converse.findConverseInfo', users[1], { converseId: id })
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
invoke('chat.message.fetchConverseMessage', users[1], { converseId: id })
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
invoke('chat.message.searchMessage', users[1], {
|
||||
converseId: id,
|
||||
text: 'member',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
const previous = await message.adapter.model.findOne({ converseId: id });
|
||||
for (const action of ['addReaction', 'removeReaction']) {
|
||||
await expect(
|
||||
invoke(`chat.message.${action}`, users[1], {
|
||||
messageId: String(previous._id),
|
||||
emoji: ':smile:',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
}
|
||||
await expect(
|
||||
invoke('chat.message.sendMessage', users[1], {
|
||||
converseId: id,
|
||||
content: 'Not a member',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
invoke('user.dmlist.addConverse', users[1], { converseId: id })
|
||||
).rejects.toThrow();
|
||||
await invoke('chat.message.sendMessage', users[0], {
|
||||
converseId: id,
|
||||
content: 'Still a member',
|
||||
});
|
||||
expect(
|
||||
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
|
||||
).not.toContain(id);
|
||||
expect(
|
||||
(await invoke('chat.converse.findAndJoinRoom', users[1])).dmConverseIds
|
||||
).not.toContain(id);
|
||||
expect(rooms.get(users[1])?.has(id)).toBe(false);
|
||||
await expect(
|
||||
invoke('chat.message.fetchConverseMessage', users[0], { converseId: id })
|
||||
).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
test('rejects nearby private history even when a departed member supplies an unrelated joined group', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
const previous = await message.adapter.model.findOne({ converseId: id });
|
||||
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
|
||||
await expect(
|
||||
invoke('chat.message.fetchNearbyMessage', users[1], {
|
||||
converseId: id,
|
||||
messageId: String(previous._id),
|
||||
groupId: String(new Types.ObjectId()),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('checks joined group panels for panel ids without a private converse document', async () => {
|
||||
const panelId = new Types.ObjectId();
|
||||
converseIds.push(String(panelId));
|
||||
const stored = await message.adapter.model.create({
|
||||
converseId: panelId,
|
||||
groupId: new Types.ObjectId(),
|
||||
author: new Types.ObjectId(users[0]),
|
||||
content: 'Group history',
|
||||
});
|
||||
await expect(
|
||||
invoke('chat.message.fetchConverseMessage', users[0], {
|
||||
converseId: String(panelId),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
joinedPanelIds = [String(panelId)];
|
||||
const result = await invoke('chat.message.fetchConverseMessage', users[0], {
|
||||
converseId: String(panelId),
|
||||
});
|
||||
expect(result.map((item) => String(item._id))).toEqual([
|
||||
String(stored._id),
|
||||
]);
|
||||
});
|
||||
|
||||
test('leave retries cleanup and does not affect another member', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
const leave = () =>
|
||||
invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
|
||||
await leave();
|
||||
await leave();
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).toEqual([users[0], users[2]]);
|
||||
expect(
|
||||
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
|
||||
).not.toContain(id);
|
||||
});
|
||||
|
||||
test('leave by a nonmember changes nothing and does not notify the room', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
notifications.length = 0;
|
||||
await invoke('chat.converse.leaveDMConverse', users[3], { converseId: id });
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).toEqual(users.slice(0, 3));
|
||||
expect(notifications.filter((event) => event.type === 'roomcast')).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('does not mutate direct messages or groups through append or leave', async () => {
|
||||
const direct = await create([users[1]]);
|
||||
const group = await converse.adapter.model.create({
|
||||
type: 'Group',
|
||||
members: users.slice(0, 2),
|
||||
});
|
||||
converseIds.push(String(group._id));
|
||||
for (const id of [direct, String(group._id)]) {
|
||||
await expect(
|
||||
invoke('chat.converse.leaveDMConverse', users[0], { converseId: id })
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[2]],
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).toEqual(users.slice(0, 2));
|
||||
}
|
||||
});
|
||||
|
||||
test('does not reuse a multi conversation reduced to two members as a direct message', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
await invoke('chat.converse.leaveDMConverse', users[2], { converseId: id });
|
||||
const direct = await create([users[1]]);
|
||||
expect(direct).not.toBe(id);
|
||||
expect((await converse.adapter.model.findById(direct)).type).toBe('DM');
|
||||
});
|
||||
|
||||
test('concurrent appends and leaves preserve unrelated membership updates', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
await Promise.all([
|
||||
invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[3]],
|
||||
}),
|
||||
invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }),
|
||||
]);
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String).sort()
|
||||
).toEqual([users[0], users[2], users[3]].sort());
|
||||
});
|
||||
test('removes a delayed list insertion that races with leaving', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
await invoke('user.dmlist.removeConverse', users[1], { converseId: id });
|
||||
const record = await dmlist.adapter.model.findOne({ userId: users[1] });
|
||||
let resume: () => void;
|
||||
let started: () => void;
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const inserting = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const original = dmlist.adapter.model.findByIdAndUpdate.bind(
|
||||
dmlist.adapter.model
|
||||
);
|
||||
jest.spyOn(dmlist.adapter.model, 'findByIdAndUpdate').mockImplementation(((
|
||||
recordId,
|
||||
...args
|
||||
) => {
|
||||
if (String(recordId) === String(record._id)) {
|
||||
return paused.then(() => original(recordId, ...args));
|
||||
}
|
||||
return original(recordId, ...args);
|
||||
}) as any);
|
||||
const originalFind = dmlist.adapter.model.findOrCreate.bind(
|
||||
dmlist.adapter.model
|
||||
);
|
||||
jest.spyOn(dmlist.adapter.model, 'findOrCreate').mockImplementation((async (
|
||||
...args
|
||||
) => {
|
||||
const result = await originalFind(...args);
|
||||
started();
|
||||
return result;
|
||||
}) as any);
|
||||
const pending = invoke('user.dmlist.addConverse', users[1], {
|
||||
converseId: id,
|
||||
});
|
||||
const rejected = expect(pending).rejects.toThrow();
|
||||
await inserting;
|
||||
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
|
||||
resume();
|
||||
await rejected;
|
||||
expect(
|
||||
(await dmlist.adapter.model.findById(record._id)).converseIds.map(String)
|
||||
).not.toContain(id);
|
||||
});
|
||||
|
||||
test('a delayed list compensation cannot remove a successful re-invitation', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
const record = await dmlist.adapter.model.findOne({ userId: users[1] });
|
||||
let resumeInsert: () => void;
|
||||
let inserted: () => void;
|
||||
const insertPaused = new Promise<void>((resolve) => {
|
||||
resumeInsert = resolve;
|
||||
});
|
||||
const insertion = new Promise<void>((resolve) => {
|
||||
inserted = resolve;
|
||||
});
|
||||
const originalAdd = dmlist.adapter.model.findByIdAndUpdate.bind(
|
||||
dmlist.adapter.model
|
||||
);
|
||||
let pauseInsert = true;
|
||||
jest.spyOn(dmlist.adapter.model, 'findByIdAndUpdate').mockImplementation(((
|
||||
recordId,
|
||||
...args
|
||||
) => {
|
||||
if (pauseInsert && String(recordId) === String(record._id)) {
|
||||
pauseInsert = false;
|
||||
return (async () => {
|
||||
const result = await originalAdd(recordId, ...args);
|
||||
inserted();
|
||||
await insertPaused;
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
return originalAdd(recordId, ...args);
|
||||
}) as any);
|
||||
const pending = invoke('user.dmlist.addConverse', users[1], {
|
||||
converseId: id,
|
||||
});
|
||||
const rejected = expect(pending).rejects.toThrow();
|
||||
await insertion;
|
||||
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
|
||||
|
||||
let resumeRemove: () => void;
|
||||
let removing: () => void;
|
||||
const removePaused = new Promise<void>((resolve) => {
|
||||
resumeRemove = resolve;
|
||||
});
|
||||
const removal = new Promise<void>((resolve) => {
|
||||
removing = resolve;
|
||||
});
|
||||
const originalRemove = dmlist.adapter.model.updateOne.bind(
|
||||
dmlist.adapter.model
|
||||
);
|
||||
let pauseRemove = true;
|
||||
jest.spyOn(dmlist.adapter.model, 'updateOne').mockImplementation(((
|
||||
filter,
|
||||
...args
|
||||
) => {
|
||||
if (pauseRemove && String(filter.userId) === users[1]) {
|
||||
pauseRemove = false;
|
||||
return {
|
||||
exec: async () => {
|
||||
removing();
|
||||
await removePaused;
|
||||
return originalRemove(filter, ...args).exec();
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalRemove(filter, ...args);
|
||||
}) as any);
|
||||
resumeInsert();
|
||||
await removal;
|
||||
await invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[1]],
|
||||
});
|
||||
resumeRemove();
|
||||
await rejected;
|
||||
expect(
|
||||
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
|
||||
).toContain(id);
|
||||
expect(rooms.get(users[1]).has(id)).toBe(true);
|
||||
});
|
||||
|
||||
test('removes a delayed reconnect join after the member leaves', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
let resume: () => void;
|
||||
let started: () => void;
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const joining = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
beforeJoin = async (userId) => {
|
||||
if (userId === users[1]) {
|
||||
started();
|
||||
await paused;
|
||||
}
|
||||
};
|
||||
const reconnect = invoke('chat.converse.findAndJoinRoom', users[1]);
|
||||
await joining;
|
||||
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
|
||||
resume();
|
||||
expect((await reconnect).dmConverseIds).not.toContain(id);
|
||||
expect(rooms.get(users[1]).has(id)).toBe(false);
|
||||
});
|
||||
|
||||
test('cleans a delayed invitation join when the invited member leaves', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
let resume: () => void;
|
||||
let started: () => void;
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const joining = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
beforeJoin = async (userId) => {
|
||||
if (userId === users[3]) {
|
||||
started();
|
||||
await paused;
|
||||
}
|
||||
};
|
||||
const append = invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[3]],
|
||||
});
|
||||
await joining;
|
||||
await invoke('chat.converse.leaveDMConverse', users[3], { converseId: id });
|
||||
resume();
|
||||
await append;
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).not.toContain(users[3]);
|
||||
expect(rooms.get(users[3]).has(id)).toBe(false);
|
||||
expect(
|
||||
(await invoke('user.dmlist.getAllConverse', users[3])).map(String)
|
||||
).not.toContain(id);
|
||||
});
|
||||
|
||||
test('rejects an append if its actor leaves during preference validation', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
let resume: () => void;
|
||||
let started: () => void;
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const checking = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const original = user.adapter.model.findOne.bind(user.adapter.model);
|
||||
jest.spyOn(user.adapter.model, 'findOne').mockImplementation(((
|
||||
filter,
|
||||
...args
|
||||
) => {
|
||||
if (String(filter?._id) === users[3]) {
|
||||
started();
|
||||
return paused.then(() => original(filter, ...args));
|
||||
}
|
||||
return original(filter, ...args);
|
||||
}) as any);
|
||||
const append = invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[3]],
|
||||
});
|
||||
const rejected = expect(append).rejects.toThrow();
|
||||
await checking;
|
||||
await invoke('chat.converse.leaveDMConverse', users[0], { converseId: id });
|
||||
resume();
|
||||
await rejected;
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).toEqual(users.slice(1, 3));
|
||||
});
|
||||
|
||||
test('restores current membership when a re-invite finishes before delayed leave cleanup', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
let resume: () => void;
|
||||
let started: () => void;
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const leaving = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
beforeLeave = async (userId) => {
|
||||
if (userId === users[1]) {
|
||||
started();
|
||||
await paused;
|
||||
}
|
||||
};
|
||||
const leave = invoke('chat.converse.leaveDMConverse', users[1], {
|
||||
converseId: id,
|
||||
});
|
||||
await leaving;
|
||||
await invoke('chat.converse.appendDMConverseMembers', users[0], {
|
||||
converseId: id,
|
||||
memberIds: [users[1]],
|
||||
});
|
||||
resume();
|
||||
await leave;
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).toContain(users[1]);
|
||||
expect(rooms.get(users[1]).has(id)).toBe(true);
|
||||
expect(
|
||||
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
|
||||
).toContain(id);
|
||||
expect(
|
||||
notifications.filter((event) => event.target === users[1]).slice(-1)[0]
|
||||
.eventName
|
||||
).toBe('notify:chat.converse.updateDMConverse');
|
||||
});
|
||||
|
||||
test('leave can retry a socket cleanup failure without restoring membership', async () => {
|
||||
const id = await create(users.slice(1, 3));
|
||||
beforeLeave = async () => {
|
||||
throw new Error('Gateway unavailable');
|
||||
};
|
||||
await expect(
|
||||
invoke('chat.converse.leaveDMConverse', users[1], { converseId: id })
|
||||
).rejects.toThrow('Gateway unavailable');
|
||||
expect(
|
||||
(await converse.adapter.model.findById(id)).members.map(String)
|
||||
).not.toContain(users[1]);
|
||||
beforeLeave = undefined;
|
||||
await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id });
|
||||
expect(rooms.get(users[1]).has(id)).toBe(false);
|
||||
expect(
|
||||
(await invoke('user.dmlist.getAllConverse', users[1])).map(String)
|
||||
).not.toContain(id);
|
||||
});
|
||||
|
||||
test('fails closed when the invitation preference cannot be loaded', async () => {
|
||||
const original = user.adapter.model.findOne.bind(user.adapter.model);
|
||||
jest.spyOn(user.adapter.model, 'findOne').mockImplementation(((
|
||||
filter,
|
||||
...args
|
||||
) => {
|
||||
if (String(filter?._id) === users[1]) {
|
||||
throw new Error('Settings unavailable');
|
||||
}
|
||||
return original(filter, ...args);
|
||||
}) as any);
|
||||
await expect(create(users.slice(1, 3))).rejects.toThrow(
|
||||
'Settings unavailable'
|
||||
);
|
||||
expect(
|
||||
await converse.adapter.model.countDocuments({
|
||||
type: 'Multi',
|
||||
members: new Types.ObjectId(users[0]),
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Private conversations
|
||||
---
|
||||
|
||||
## Leave a multi-person conversation
|
||||
|
||||
Use **Leave conversation** in a multi-person conversation to remove yourself from
|
||||
its members. After leaving, you no longer receive its messages or have access to
|
||||
the conversation. Your other connected devices also remove it from the list.
|
||||
|
||||
Closing a conversation in the sidebar only hides it. You remain a member, and a
|
||||
new message can bring it back. Leaving is available even when a multi-person
|
||||
conversation has only one or two members left.
|
||||
|
||||
## Allow invitations only from friends
|
||||
|
||||
In **Settings → System settings**, enable **Only allow friends to invite me to
|
||||
conversations**. This setting is off by default.
|
||||
|
||||
When enabled, someone must be in your friend list to include you in a new
|
||||
multi-person conversation or add you to an existing one. This also applies when
|
||||
an existing direct message is used to create a multi-person conversation.
|
||||
|
||||
The setting does not remove you from existing conversations or block one-to-one
|
||||
direct messages. Leaving a conversation does not prevent a later invitation;
|
||||
enable this setting if you want to reject invitations from non-friends.
|
||||
@ -0,0 +1,17 @@
|
||||
---
|
||||
title: 私人会话
|
||||
---
|
||||
|
||||
## 退出多人会话
|
||||
|
||||
在多人会话中使用**退出会话**,即可将自己从成员中移除。退出后,你将不再收到该会话的消息,也无法继续访问会话。其他已连接的设备也会将它从会话列表中移除。
|
||||
|
||||
侧边栏中的关闭操作只会隐藏会话,你仍然是成员,新消息可能使会话重新出现。即使多人会话只剩一两名成员,仍然可以退出。
|
||||
|
||||
## 仅允许好友邀请
|
||||
|
||||
在**设置 → 系统设置**中启用**仅允许好友邀请创建会话**。这个开关默认关闭。
|
||||
|
||||
开启后,只有在你的好友列表中的用户,才能将你加入新建的多人会话或已有的多人会话。从已有私聊创建多人会话时,同样遵守这个设置。
|
||||
|
||||
这个设置不会让你退出已经加入的会话,也不影响一对一私聊。退出会话本身不会阻止后续邀请;如果希望拒绝非好友的邀请,请开启此设置。
|
||||
Loading…
Reference in New Issue