269 lines
9.3 KiB
TypeScript
269 lines
9.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({ instances: [] as any[] }));
|
|
|
|
vi.mock('tinode-sdk', () => {
|
|
class QueryBuilder {
|
|
private kind = '';
|
|
private since?: number;
|
|
private limit?: number;
|
|
|
|
constructor(private topic?: { maxMsgSeq?: () => number }) {}
|
|
|
|
withData(_since?: number, _before?: number, limit?: number) {
|
|
this.kind = 'initial';
|
|
this.limit = limit;
|
|
return this;
|
|
}
|
|
|
|
withLaterData(limit?: number) {
|
|
this.kind = 'later';
|
|
this.since = (this.topic?.maxMsgSeq?.() || 0) + 1;
|
|
this.limit = limit;
|
|
return this;
|
|
}
|
|
|
|
build() {
|
|
return { kind: this.kind, since: this.since, limit: this.limit };
|
|
}
|
|
}
|
|
|
|
class MockTinode {
|
|
onConnect?: () => Promise<void> | void;
|
|
onDisconnect?: () => void;
|
|
onAutoreconnectIteration?: (timeoutMs: number) => void;
|
|
_connection = { sendText: vi.fn() };
|
|
connect = vi.fn(async () => undefined);
|
|
loginToken = vi.fn(async () => ({ code: 200 }));
|
|
disconnect = vi.fn();
|
|
authorizeURL = vi.fn((value: string) => value);
|
|
topic = {
|
|
onData: undefined as (() => void) | undefined,
|
|
onAllMessagesReceived: undefined as ((count: number) => void) | undefined,
|
|
maxSeq: 0,
|
|
maxMsgSeq: vi.fn(() => this.topic.maxSeq),
|
|
startMetaQuery: () => new QueryBuilder(this.topic),
|
|
subscribe: vi.fn(async () => {
|
|
setTimeout(() => this.topic.onAllMessagesReceived?.(0), 0);
|
|
return { code: 200, params: { what: 'data', count: 0 } };
|
|
}),
|
|
getMeta: vi.fn(async () => ({ code: 200, params: { what: 'data', count: 0 } })),
|
|
leave: vi.fn(),
|
|
messages: vi.fn(),
|
|
createMessage: vi.fn(),
|
|
publishDraft: vi.fn(),
|
|
};
|
|
|
|
constructor(public options: Record<string, unknown>) {
|
|
mocks.instances.push(this);
|
|
}
|
|
|
|
getTopic() {
|
|
return this.topic;
|
|
}
|
|
}
|
|
|
|
return {
|
|
Tinode: MockTinode,
|
|
Drafty: { appendImage: vi.fn(), insertVideo: vi.fn() },
|
|
};
|
|
});
|
|
|
|
import {
|
|
CustomerServiceChat,
|
|
resolveCustomerServiceEndpoints,
|
|
type CustomerServiceSession,
|
|
} from './im';
|
|
|
|
const session = (overrides: Partial<CustomerServiceSession> = {}): CustomerServiceSession => ({
|
|
tenant_code: 'default',
|
|
api_base_url: '/v1/im',
|
|
file_url: '/v0/file',
|
|
websocket_url: '/v0/channels',
|
|
api_key: 'public-key',
|
|
token: 'token-1',
|
|
current_user_id: 'usr_member',
|
|
topic: 'p2p-topic',
|
|
...overrides,
|
|
});
|
|
|
|
describe('customer service IM endpoints', () => {
|
|
it('resolves API, file and websocket paths against the current page origin', () => {
|
|
expect(resolveCustomerServiceEndpoints(session(), 'https://www.yuexuan2.shop')).toEqual({
|
|
apiBaseURL: 'https://www.yuexuan2.shop/v1/im',
|
|
fileURL: 'https://www.yuexuan2.shop/v0/file',
|
|
websocketURL: 'wss://www.yuexuan2.shop/v0/channels',
|
|
});
|
|
});
|
|
|
|
it('does not follow legacy absolute IM hosts from a cached session response', () => {
|
|
expect(
|
|
resolveCustomerServiceEndpoints(
|
|
session({
|
|
api_base_url: 'https://im-api.yuexuan.shop/v1/im',
|
|
file_url: 'https://im-api.yuexuan.shop/v0/file',
|
|
websocket_url: 'wss://im-api.yuexuan.shop/v0/channels',
|
|
}),
|
|
'https://www.yuexuan3.shop',
|
|
),
|
|
).toEqual({
|
|
apiBaseURL: 'https://www.yuexuan3.shop/v1/im',
|
|
fileURL: 'https://www.yuexuan3.shop/v0/file',
|
|
websocketURL: 'wss://www.yuexuan3.shop/v0/channels',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('customer service reconnect', () => {
|
|
beforeEach(() => {
|
|
mocks.instances.length = 0;
|
|
});
|
|
|
|
it('reauthenticates and resubscribes with later data after reconnect', async () => {
|
|
const states: string[] = [];
|
|
const chat = new CustomerServiceChat(session(), vi.fn(), {
|
|
origin: 'https://www.yuexuan1.shop',
|
|
onConnectionState: (state) => states.push(state),
|
|
});
|
|
const tinode = mocks.instances[0];
|
|
|
|
await chat.connect('token-1');
|
|
tinode.onDisconnect?.();
|
|
await tinode.onConnect?.();
|
|
|
|
expect(tinode.loginToken).toHaveBeenCalledTimes(2);
|
|
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(2);
|
|
expect(tinode.topic.subscribe.mock.calls[0][0]).toEqual({ kind: 'initial', limit: 50, since: undefined });
|
|
expect(tinode.topic.subscribe.mock.calls[1][0]).toEqual({ kind: 'later', limit: 50, since: 1 });
|
|
expect(states).toEqual(expect.arrayContaining(['connecting', 'online', 'reconnecting']));
|
|
expect(states.at(-1)).toBe('online');
|
|
});
|
|
|
|
it('does not restore a deliberately disconnected chat', async () => {
|
|
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://olons.yuexuan.shop' });
|
|
const tinode = mocks.instances[0];
|
|
|
|
await chat.connect('token-1');
|
|
chat.disconnect();
|
|
await tinode.onConnect?.();
|
|
|
|
expect(tinode.loginToken).toHaveBeenCalledTimes(1);
|
|
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reports offline without leaking a rejected reconnect callback', async () => {
|
|
const states: string[] = [];
|
|
const chat = new CustomerServiceChat(session(), vi.fn(), {
|
|
origin: 'https://www.yuexuan3.shop',
|
|
onConnectionState: (state) => states.push(state),
|
|
});
|
|
const tinode = mocks.instances[0];
|
|
|
|
await chat.connect('token-1');
|
|
tinode.loginToken.mockRejectedValueOnce(new Error('network unavailable'));
|
|
tinode.onDisconnect?.();
|
|
|
|
await expect(tinode.onConnect?.()).resolves.toBeUndefined();
|
|
expect(states.at(-1)).toBe('offline');
|
|
});
|
|
|
|
it('uses rotated login tokens for reconnect and media upload', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
|
const tinode = mocks.instances[0];
|
|
tinode.loginToken
|
|
.mockResolvedValueOnce({ code: 200, params: { token: 'token-2' } })
|
|
.mockResolvedValueOnce({ code: 200, params: { token: 'token-3' } });
|
|
|
|
await chat.connect('token-1');
|
|
await tinode.onConnect?.();
|
|
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
|
|
|
|
expect(tinode.loginToken.mock.calls.map((call: unknown[]) => call[0])).toEqual(['token-1', 'token-2']);
|
|
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Token token-3' });
|
|
fetchMock.mockRestore();
|
|
});
|
|
|
|
it('paginates all messages accumulated while disconnected', async () => {
|
|
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
|
const tinode = mocks.instances[0];
|
|
await chat.connect('token-1');
|
|
|
|
tinode.topic.subscribe.mockImplementationOnce(async () => {
|
|
setTimeout(() => {
|
|
tinode.topic.maxSeq = 50;
|
|
tinode.topic.onAllMessagesReceived?.(50);
|
|
}, 10);
|
|
return { code: 200, params: { what: 'data', count: 50 } };
|
|
});
|
|
tinode.topic.getMeta
|
|
.mockImplementationOnce(async () => {
|
|
setTimeout(() => {
|
|
tinode.topic.maxSeq = 100;
|
|
tinode.topic.onAllMessagesReceived?.(50);
|
|
}, 10);
|
|
return { code: 200, params: { what: 'data', count: 50 } };
|
|
})
|
|
.mockImplementationOnce(async () => {
|
|
setTimeout(() => {
|
|
tinode.topic.maxSeq = 120;
|
|
tinode.topic.onAllMessagesReceived?.(20);
|
|
}, 10);
|
|
return { code: 200, params: { what: 'data', count: 20 } };
|
|
});
|
|
|
|
await tinode.onConnect?.();
|
|
|
|
expect(tinode.topic.getMeta).toHaveBeenCalledTimes(2);
|
|
expect(tinode.topic.getMeta.mock.calls.map((call: unknown[]) => call[0])).toEqual([
|
|
{ kind: 'later', limit: 50, since: 51 },
|
|
{ kind: 'later', limit: 50, since: 101 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('customer service media upload', () => {
|
|
beforeEach(() => {
|
|
mocks.instances.length = 0;
|
|
});
|
|
|
|
it('keeps credentials in headers and out of the upload URL', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
|
|
|
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
|
|
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(String(url)).toBe('https://www.yuexuan1.shop/v0/file/u/');
|
|
expect(String(url)).not.toContain('secret=');
|
|
expect(init?.headers).toEqual({
|
|
'X-MeetingIM-APIKey': 'public-key',
|
|
Authorization: 'Token token-1',
|
|
});
|
|
fetchMock.mockRestore();
|
|
});
|
|
|
|
it('rewrites historical absolute IM file references to the current frontend origin', () => {
|
|
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan3.shop' });
|
|
const tinode = mocks.instances[0];
|
|
tinode.authorizeURL.mockImplementation((value: string) => `${value}?auth=token-1`);
|
|
|
|
expect((chat as any).mediaURL('https://im-api.yuexuan.shop/v0/file/s/legacy-image')).toBe(
|
|
'https://www.yuexuan3.shop/v0/file/s/legacy-image?auth=token-1',
|
|
);
|
|
expect((chat as any).mediaURL('https://cdn.example.com/public/image.png')).toBe(
|
|
'https://cdn.example.com/public/image.png',
|
|
);
|
|
});
|
|
});
|