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