Files
olons_server/web/src/mobile/im.test.ts
T
Your Name db18514e6d
Deploy olons-server production / deploy (push) Successful in 3m38s
fix: make support IM delivery recoverable
2026-10-19 17:23:34 +08:00

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',
);
});
});