From 42c3b2c809ecd5b0761c3e95a6b016b1e1bdf257 Mon Sep 17 00:00:00 2001 From: Alina Sireneva Date: Fri, 17 Nov 2023 00:17:03 +0300 Subject: [PATCH] test(client): high-level methods and types tests --- package.json | 2 +- .../src/methods/users/get-users.test.ts | 37 ++ .../src/methods/users/resolve-peer.test.ts | 315 ++++++++++++++++++ .../client/src/methods/users/resolve-peer.ts | 82 +---- .../src/types/bots/keyboard-builder.test.ts | 196 +++++++++++ .../client/src/types/bots/keyboard-builder.ts | 2 +- .../client/src/types/bots/keyboards.test.ts | 167 ++++++++++ packages/client/src/types/bots/keyboards.ts | 3 + .../src/types/peers/peers-index.test.ts | 86 +++++ packages/client/src/types/peers/user.test.ts | 226 +++++++++++++ packages/client/src/types/peers/user.ts | 32 +- packages/test/src/client.test.ts | 2 +- packages/test/src/client.ts | 60 ++-- 13 files changed, 1100 insertions(+), 110 deletions(-) create mode 100644 packages/client/src/methods/users/get-users.test.ts create mode 100644 packages/client/src/methods/users/resolve-peer.test.ts create mode 100644 packages/client/src/types/bots/keyboard-builder.test.ts create mode 100644 packages/client/src/types/bots/keyboards.test.ts create mode 100644 packages/client/src/types/peers/peers-index.test.ts create mode 100644 packages/client/src/types/peers/user.test.ts diff --git a/package.json b/package.json index 68bf4b90..2a1a86ea 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "prepare": "husky install .config/husky", "postinstall": "node scripts/validate-deps-versions.mjs", - "test": "vitest --config .config/vite.ts run && pnpm run -r test", + "test": "vitest --config .config/vite.mts run && pnpm run -r test", "test:dev": "vitest --config .config/vite.mts watch", "test:ui": "vitest --config .config/vite.mts --ui", "test:coverage": "vitest --config .config/vite.mts run --coverage", diff --git a/packages/client/src/methods/users/get-users.test.ts b/packages/client/src/methods/users/get-users.test.ts new file mode 100644 index 00000000..14fc5063 --- /dev/null +++ b/packages/client/src/methods/users/get-users.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { Long } from '@mtcute/core' +import { assertTypeIs } from '@mtcute/core/utils.js' +import { createStub, StubTelegramClient } from '@mtcute/test' + +import { User } from '../../types/index.js' +import { getUsers } from './get-users.js' + +describe('getUsers', () => { + const client = new StubTelegramClient() + + client.respondWith('users.getUsers', ({ id }) => + id.map((it) => { + assertTypeIs('', it, 'inputUser') + + if (it.userId === 1) return { _: 'userEmpty', id: 1 } + + return createStub('user', { id: it.userId, accessHash: Long.ZERO }) + }), + ) + + it('should return users returned by users.getUsers', async () => { + expect(await getUsers(client, [123, 456])).toEqual([ + new User(createStub('user', { id: 123, accessHash: Long.ZERO })), + new User(createStub('user', { id: 456, accessHash: Long.ZERO })), + ]) + }) + + it('should work for one user', async () => { + expect(await getUsers(client, 123)).toEqual([new User(createStub('user', { id: 123, accessHash: Long.ZERO }))]) + }) + + it('should ignore userEmpty', async () => { + expect(await getUsers(client, 1)).toEqual([]) + }) +}) diff --git a/packages/client/src/methods/users/resolve-peer.test.ts b/packages/client/src/methods/users/resolve-peer.test.ts new file mode 100644 index 00000000..1dfc792f --- /dev/null +++ b/packages/client/src/methods/users/resolve-peer.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it, vi } from 'vitest' + +import { Long } from '@mtcute/core' +import { createStub, StubTelegramClient } from '@mtcute/test' + +import { Chat, MtPeerNotFoundError, User } from '../../types/index.js' +import { getAuthState } from '../auth/_state.js' +import { resolvePeer } from './resolve-peer.js' + +describe('resolvePeer', () => { + it('should extract input peer from User/Chat', async () => { + const user = new User( + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ) + const chat = new Chat( + createStub('channel', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ) + + expect(await resolvePeer(StubTelegramClient.offline(), user)).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + expect(await resolvePeer(StubTelegramClient.offline(), chat)).toEqual({ + _: 'inputPeerChannel', + channelId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + + it('should extract input peer from tl objects', async () => { + const user = createStub('inputPeerUser', { + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + + expect(await resolvePeer(StubTelegramClient.offline(), user)).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + + it('should return inputPeerSelf for me/self', async () => { + expect(await resolvePeer(StubTelegramClient.offline(), 'me')).toEqual({ _: 'inputPeerSelf' }) + expect(await resolvePeer(StubTelegramClient.offline(), 'self')).toEqual({ _: 'inputPeerSelf' }) + }) + + describe('resolving by id', () => { + describe('users', () => { + it('should first try checking in storage', async () => { + const client = StubTelegramClient.offline() + + await client.registerPeers( + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ) + + const resolved = await resolvePeer(client, 123) + + expect(resolved).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + + it('should return user with zero hash if not in storage', async () => { + const client = new StubTelegramClient() + + const resolved = await resolvePeer(client, 123) + + expect(resolved).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.ZERO, + }) + }) + + it('should return user with zero hash for bots', async () => { + const client = new StubTelegramClient() + + getAuthState(client).isBot = true + + await client.registerPeers( + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ) + + const resolved = await resolvePeer(client, 123) + + expect(resolved).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.ZERO, + }) + }) + }) + + describe('channels', () => { + it('should first try checking in storage', async () => { + const client = StubTelegramClient.offline() + + await client.registerPeers( + createStub('channel', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ) + + const resolved = await resolvePeer(client, -1000000000123) + + expect(resolved).toEqual({ + _: 'inputPeerChannel', + channelId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + + it('should return channel with zero hash if not in storage', async () => { + const client = new StubTelegramClient() + + const resolved = await resolvePeer(client, -1000000000123) + + expect(resolved).toEqual({ + _: 'inputPeerChannel', + channelId: 123, + accessHash: Long.ZERO, + }) + }) + }) + + describe('chats', () => { + it('should always return zero hash', async () => { + const client = StubTelegramClient.offline() + + const resolved = await resolvePeer(client, -123) + + expect(resolved).toEqual({ + _: 'inputPeerChat', + chatId: 123, + }) + }) + }) + + it('should accept Peer objects', async () => { + const client = new StubTelegramClient() + + await client.registerPeers( + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ) + + const resolved = await resolvePeer(client, { _: 'peerUser', userId: 123 }) + + expect(resolved).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + }) + + describe('resolving by phone number', () => { + it('should first try checking in storage', async () => { + const client = StubTelegramClient.offline() + + await client.registerPeers( + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + phone: '123456789', + }), + ) + + const resolved = await resolvePeer(client, '+123456789') + + expect(resolved).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + + it('should call contacts.resolvePhone if not in storage', async () => { + const client = new StubTelegramClient() + + const resolvePhoneFn = client.respondWith( + 'contacts.resolvePhone', + vi.fn().mockReturnValue({ + _: 'contacts.resolvedPeer', + peer: { + _: 'peerUser', + userId: 123, + }, + users: [ + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ], + }), + ) + + const resolved = await resolvePeer(client, '+123456789') + + expect(resolved).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + expect(resolvePhoneFn).toHaveBeenCalledWith({ + _: 'contacts.resolvePhone', + phone: '123456789', + }) + }) + + it('should handle empty response from contacts.resolvePhone', async () => { + const client = new StubTelegramClient() + + const resolvePhoneFn = vi.fn().mockReturnValue({ + _: 'contacts.resolvedPeer', + peer: { + _: 'peerUser', + userId: 123, + }, + users: [], + }) + client.respondWith('contacts.resolvePhone', resolvePhoneFn) + + await expect(() => resolvePeer(client, '+123456789')).rejects.toThrow(MtPeerNotFoundError) + }) + }) + + describe('resolving by username', () => { + it('should first try checking in storage', async () => { + const client = StubTelegramClient.offline() + + await client.registerPeers( + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + username: 'test', + }), + ) + + const resolved = await resolvePeer(client, 'test') + + expect(resolved).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + + it('should call contacts.resolveUsername if not in storage', async () => { + const client = new StubTelegramClient() + + const resolveUsernameFn = vi.fn().mockReturnValue({ + _: 'contacts.resolvedPeer', + peer: { + _: 'peerChannel', + channelId: 123, + }, + chats: [ + createStub('channel', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ], + }) + client.respondWith('contacts.resolveUsername', resolveUsernameFn) + + const resolved = await resolvePeer(client, 'test') + + expect(resolved).toEqual({ + _: 'inputPeerChannel', + channelId: 123, + accessHash: Long.fromBits(456, 789), + }) + expect(resolveUsernameFn).toHaveBeenCalledWith({ + _: 'contacts.resolveUsername', + username: 'test', + }) + }) + + it('should handle empty response from contacts.resolveUsername', async () => { + const client = new StubTelegramClient() + + const resolveUsernameFn = vi.fn().mockReturnValue({ + _: 'contacts.resolvedPeer', + peer: { + _: 'peerChannel', + channelId: 123, + }, + chats: [], + }) + client.respondWith('contacts.resolveUsername', resolveUsernameFn) + + await expect(() => resolvePeer(client, 'test')).rejects.toThrow(MtPeerNotFoundError) + }) + }) +}) diff --git a/packages/client/src/methods/users/resolve-peer.ts b/packages/client/src/methods/users/resolve-peer.ts index 02f82784..a04c5740 100644 --- a/packages/client/src/methods/users/resolve-peer.ts +++ b/packages/client/src/methods/users/resolve-peer.ts @@ -11,6 +11,7 @@ import { import { MtPeerNotFoundError } from '../../types/errors.js' import { InputPeerLike } from '../../types/peers/index.js' import { normalizeToInputPeer } from '../../utils/peer-utils.js' +import { getAuthState } from '../auth/_state.js' // @available=both /** @@ -18,7 +19,7 @@ import { normalizeToInputPeer } from '../../utils/peer-utils.js' * Useful when an `InputPeer` is needed in Raw API. * * @param peerId The peer identifier that you want to extract the `InputPeer` from. - * @param force Whether to force re-fetch the peer from the server + * @param force Whether to force re-fetch the peer from the server (only applicable for usernames and phone numbers) */ export async function resolvePeer( client: BaseTelegramClient, @@ -37,7 +38,7 @@ export async function resolvePeer( } } - if (typeof peerId === 'number' && !force) { + if (typeof peerId === 'number' && !force && !getAuthState(client).isBot) { const fromStorage = await client.storage.getPeerById(peerId) if (fromStorage) return fromStorage } @@ -123,81 +124,32 @@ export async function resolvePeer( throw new MtPeerNotFoundError(`Could not find a peer by ${peerId}`) } + // in some cases, the server allows us to use access_hash=0. + // particularly, when we're a bot or we're referencing a user + // who we have "seen" recently + // if it's not the case, we'll get an `PEER_ID_INVALID` error anyways const peerType = getBasicPeerType(peerId) - // try fetching by id, with access_hash set to 0 switch (peerType) { - case 'user': { - const res = await client.call({ - _: 'users.getUsers', - id: [ - { - _: 'inputUser', - userId: peerId, - accessHash: Long.ZERO, - }, - ], - }) - - const found = res.find((it) => it.id === peerId) - - if (found && found._ === 'user') { - if (!found.accessHash) { - // shouldn't happen? but just in case - throw new MtPeerNotFoundError( - `Peer (user) with username ${peerId} was found, but it has no access hash`, - ) - } - - return { - _: 'inputPeerUser', - userId: found.id, - accessHash: found.accessHash, - } + case 'user': + return { + _: 'inputPeerUser', + userId: peerId, + accessHash: Long.ZERO, } - - break - } - case 'chat': { + case 'chat': return { _: 'inputPeerChat', chatId: -peerId, } - } case 'channel': { const id = toggleChannelIdMark(peerId) - const res = await client.call({ - _: 'channels.getChannels', - id: [ - { - _: 'inputChannel', - channelId: id, - accessHash: Long.ZERO, - }, - ], - }) - - const found = res.chats.find((it) => it.id === id) - - if (found && (found._ === 'channel' || found._ === 'channelForbidden')) { - if (!found.accessHash) { - // shouldn't happen? but just in case - throw new MtPeerNotFoundError( - `Peer (channel) with username ${peerId} was found, but it has no access hash`, - ) - } - - return { - _: 'inputPeerChannel', - channelId: found.id, - accessHash: found.accessHash ?? Long.ZERO, - } + return { + _: 'inputPeerChannel', + channelId: id, + accessHash: Long.ZERO, } - - break } } - - throw new MtPeerNotFoundError(`Could not find a peer by ID ${peerId}`) } diff --git a/packages/client/src/types/bots/keyboard-builder.test.ts b/packages/client/src/types/bots/keyboard-builder.test.ts new file mode 100644 index 00000000..5a7df4b2 --- /dev/null +++ b/packages/client/src/types/bots/keyboard-builder.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' + +import { BotKeyboardBuilder } from './keyboard-builder.js' + +describe('BotKeyboardBuilder', () => { + describe('#push', () => { + it('should add buttons', () => { + const builder = new BotKeyboardBuilder() + + builder.push( + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + ) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + ], + ], + }) + }) + + it('should wrap long rows buttons', () => { + const builder = new BotKeyboardBuilder(3) + + builder.push( + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + { _: 'keyboardButton', text: '4' }, + ) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + ], + [{ _: 'keyboardButton', text: '4' }], + ], + }) + }) + + it('should always add a new row', () => { + const builder = new BotKeyboardBuilder(3) + + builder.push({ _: 'keyboardButton', text: '1' }) + builder.push({ _: 'keyboardButton', text: '2' }) + builder.push({ _: 'keyboardButton', text: '3' }) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [ + [{ _: 'keyboardButton', text: '1' }], + [{ _: 'keyboardButton', text: '2' }], + [{ _: 'keyboardButton', text: '3' }], + ], + }) + }) + + it('should accept functions and falsy values', () => { + const builder = new BotKeyboardBuilder(3) + + builder.push({ _: 'keyboardButton', text: '1' }) + builder.push(() => ({ _: 'keyboardButton', text: '2' })) + builder.push(1 > 1 && { _: 'keyboardButton', text: '3' }) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [[{ _: 'keyboardButton', text: '1' }], [{ _: 'keyboardButton', text: '2' }]], + }) + }) + }) + + describe('#append', () => { + it('should append (or wrap) to the last row', () => { + const builder = new BotKeyboardBuilder(3) + + builder.append({ _: 'keyboardButton', text: '1' }) + builder.append({ _: 'keyboardButton', text: '2' }) + builder.append({ _: 'keyboardButton', text: '3' }) + builder.append({ _: 'keyboardButton', text: '4' }) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + ], + [{ _: 'keyboardButton', text: '4' }], + ], + }) + }) + + it('accept functions and falsy values', () => { + const builder = new BotKeyboardBuilder(3) + + builder.append({ _: 'keyboardButton', text: '1' }) + builder.append(() => ({ _: 'keyboardButton', text: '2' })) + builder.append(1 > 1 && { _: 'keyboardButton', text: '3' }) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + ], + ], + }) + }) + }) + + it('should accept custom row size', () => { + const builder = new BotKeyboardBuilder(5) + + builder.append({ _: 'keyboardButton', text: '1' }) + builder.append({ _: 'keyboardButton', text: '2' }) + builder.append({ _: 'keyboardButton', text: '3' }) + builder.append({ _: 'keyboardButton', text: '4' }) + builder.append({ _: 'keyboardButton', text: '5' }) + builder.append({ _: 'keyboardButton', text: '6' }) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + { _: 'keyboardButton', text: '4' }, + { _: 'keyboardButton', text: '5' }, + ], + [{ _: 'keyboardButton', text: '6' }], + ], + }) + }) + + it('#row should add entire rows of buttons', () => { + const builder = new BotKeyboardBuilder(3) + + builder.row([ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + { _: 'keyboardButton', text: '4' }, + { _: 'keyboardButton', text: '5' }, + ]) + builder.append({ _: 'keyboardButton', text: '6' }) + + expect(builder.asInline()).toEqual({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + { _: 'keyboardButton', text: '4' }, + { _: 'keyboardButton', text: '5' }, + ], + [{ _: 'keyboardButton', text: '6' }], + ], + }) + }) + + it('should support reply keyboards', () => { + const builder = new BotKeyboardBuilder(3) + + builder.append({ _: 'keyboardButton', text: '1' }) + builder.append({ _: 'keyboardButton', text: '2' }) + builder.append({ _: 'keyboardButton', text: '3' }) + builder.append({ _: 'keyboardButton', text: '4' }) + + expect(builder.asReply({ resize: true })).toEqual({ + type: 'reply', + resize: true, + buttons: [ + [ + { _: 'keyboardButton', text: '1' }, + { _: 'keyboardButton', text: '2' }, + { _: 'keyboardButton', text: '3' }, + ], + [{ _: 'keyboardButton', text: '4' }], + ], + }) + }) +}) diff --git a/packages/client/src/types/bots/keyboard-builder.ts b/packages/client/src/types/bots/keyboard-builder.ts index 936815e8..f46b8b3d 100644 --- a/packages/client/src/types/bots/keyboard-builder.ts +++ b/packages/client/src/types/bots/keyboard-builder.ts @@ -72,7 +72,7 @@ export class BotKeyboardBuilder { this._buttons.length && (this.maxRowWidth === null || force || this._buttons[this._buttons.length - 1].length < this.maxRowWidth) ) { - this._buttons[this._buttons.length - 1].push() + this._buttons[this._buttons.length - 1].push(btn) } else { this._buttons.push([btn]) } diff --git a/packages/client/src/types/bots/keyboards.test.ts b/packages/client/src/types/bots/keyboards.test.ts new file mode 100644 index 00000000..be1f75fe --- /dev/null +++ b/packages/client/src/types/bots/keyboards.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' + +import { tl } from '@mtcute/core' + +import { BotKeyboard } from './keyboards.js' + +describe('findButton', () => { + const kb: tl.TypeKeyboardButton[][] = [ + [{ _: 'keyboardButton', text: 'aaa' }], + [{ _: 'keyboardButton', text: 'a' }], + [{ _: 'keyboardButton', text: 'b' }], + ] + + it('should find buttons by text', () => { + expect(BotKeyboard.findButton(kb, 'a')).toEqual({ + _: 'keyboardButton', + text: 'a', + }) + expect(BotKeyboard.findButton(kb, 'c')).toBeNull() + }) + + it('should find buttons by predicate', () => { + expect(BotKeyboard.findButton(kb, (s) => s._ === 'keyboardButton')).toEqual({ + _: 'keyboardButton', + text: 'aaa', + }) + expect(BotKeyboard.findButton(kb, 'c')).toBeNull() + }) +}) + +describe('_convertToTl', () => { + it('should convert reply markup', () => { + expect( + BotKeyboard._convertToTl({ + type: 'reply', + buttons: [ + [ + { _: 'keyboardButton', text: 'a' }, + { _: 'keyboardButton', text: 'b' }, + ], + ], + resize: true, + singleUse: true, + selective: true, + persistent: true, + placeholder: 'whatever', + }), + ).toEqual({ + _: 'replyKeyboardMarkup', + rows: [ + { + _: 'keyboardButtonRow', + buttons: [ + { _: 'keyboardButton', text: 'a' }, + { _: 'keyboardButton', text: 'b' }, + ], + }, + ], + resize: true, + singleUse: true, + selective: true, + persistent: true, + placeholder: 'whatever', + }) + }) + + it('should convert inline markup', () => { + expect( + BotKeyboard._convertToTl({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButton', text: 'a' }, + { _: 'keyboardButton', text: 'b' }, + ], + ], + }), + ).toEqual({ + _: 'replyInlineMarkup', + rows: [ + { + _: 'keyboardButtonRow', + buttons: [ + { _: 'keyboardButton', text: 'a' }, + { _: 'keyboardButton', text: 'b' }, + ], + }, + ], + }) + }) + + it('should convert reply hide markup', () => { + expect( + BotKeyboard._convertToTl({ + type: 'reply_hide', + selective: true, + }), + ).toEqual({ + _: 'replyKeyboardHide', + selective: true, + }) + }) + + it('should convert force reply markup', () => { + expect( + BotKeyboard._convertToTl({ + type: 'force_reply', + selective: true, + }), + ).toEqual({ + _: 'replyKeyboardForceReply', + selective: true, + }) + }) + + describe('webview', () => { + it('should replace keyboardButtonWebView with keyboardButtonSimpleWebView for reply keyboards', () => { + expect( + BotKeyboard._convertToTl({ + type: 'reply', + buttons: [ + [ + { _: 'keyboardButtonWebView', text: 'a', url: 'https://google.com' }, + { _: 'keyboardButtonWebView', text: 'b', url: 'https://google.com' }, + ], + ], + }), + ).toEqual({ + _: 'replyKeyboardMarkup', + rows: [ + { + _: 'keyboardButtonRow', + buttons: [ + { _: 'keyboardButtonSimpleWebView', text: 'a', url: 'https://google.com' }, + { _: 'keyboardButtonSimpleWebView', text: 'b', url: 'https://google.com' }, + ], + }, + ], + }) + }) + + it('should keep keyboardButtonWebView for inline keyboards', () => { + expect( + BotKeyboard._convertToTl({ + type: 'inline', + buttons: [ + [ + { _: 'keyboardButtonWebView', text: 'a', url: 'https://google.com' }, + { _: 'keyboardButtonWebView', text: 'b', url: 'https://google.com' }, + ], + ], + }), + ).toEqual({ + _: 'replyInlineMarkup', + rows: [ + { + _: 'keyboardButtonRow', + buttons: [ + { _: 'keyboardButtonWebView', text: 'a', url: 'https://google.com' }, + { _: 'keyboardButtonWebView', text: 'b', url: 'https://google.com' }, + ], + }, + ], + }) + }) + }) +}) diff --git a/packages/client/src/types/bots/keyboards.ts b/packages/client/src/types/bots/keyboards.ts index 37344dee..5f6c4fd3 100644 --- a/packages/client/src/types/bots/keyboards.ts +++ b/packages/client/src/types/bots/keyboards.ts @@ -437,6 +437,8 @@ export namespace BotKeyboard { resize: obj.resize, singleUse: obj.singleUse, selective: obj.selective, + persistent: obj.persistent, + placeholder: obj.placeholder, rows: _2dToRows(obj.buttons, false), } case 'reply_hide': @@ -449,6 +451,7 @@ export namespace BotKeyboard { _: 'replyKeyboardForceReply', singleUse: obj.singleUse, selective: obj.selective, + placeholder: obj.placeholder, } case 'inline': return { diff --git a/packages/client/src/types/peers/peers-index.test.ts b/packages/client/src/types/peers/peers-index.test.ts new file mode 100644 index 00000000..56749751 --- /dev/null +++ b/packages/client/src/types/peers/peers-index.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { MtArgumentError } from '@mtcute/core/src/index.js' +import { createStub } from '@mtcute/test' + +import { PeersIndex } from './peers-index.js' + +describe('PeersIndex', () => { + it('should build the index from an object with users/chats fields', () => { + const obj = { + users: [createStub('user', { id: 1 }), createStub('user', { id: 2 })], + chats: [createStub('chat', { id: 1 }), createStub('channel', { id: 2 })], + } + + const peers = PeersIndex.from(obj) + + expect(peers.users.size).toBe(2) + expect(peers.chats.size).toBe(2) + expect(peers.users.get(1)).toBe(obj.users[0]) + expect(peers.users.get(2)).toBe(obj.users[1]) + expect(peers.chats.get(1)).toBe(obj.chats[0]) + expect(peers.chats.get(2)).toBe(obj.chats[1]) + }) + + it('should detect min peers and set hasMin', () => { + const obj = { + users: [createStub('user', { id: 1 }), createStub('user', { id: 2, min: true })], + chats: [createStub('chat', { id: 1 }), createStub('channel', { id: 2, min: true })], + } + + const peers = PeersIndex.from(obj) + + expect(peers.hasMin).toBe(true) + }) + + describe('#user', () => { + it('should find user info by its id', () => { + const peers = PeersIndex.from({ + users: [createStub('user', { id: 1 }), createStub('user', { id: 2 })], + }) + + expect(peers.user(1)).toBe(peers.users.get(1)) + expect(peers.user(2)).toBe(peers.users.get(2)) + }) + + it('should throw if user is not found', () => { + const peers = PeersIndex.from({ + users: [createStub('user', { id: 1 }), createStub('user', { id: 2 })], + }) + + expect(() => peers.user(3)).toThrow(MtArgumentError) + }) + }) + + describe('#chat', () => { + it('should find chat info by its id', () => { + const peers = PeersIndex.from({ + chats: [createStub('chat', { id: 1 }), createStub('channel', { id: 2 })], + }) + + expect(peers.chat(1)).toBe(peers.chats.get(1)) + expect(peers.chat(2)).toBe(peers.chats.get(2)) + }) + + it('should throw if chat is not found', () => { + const peers = PeersIndex.from({ + chats: [createStub('chat', { id: 1 }), createStub('channel', { id: 2 })], + }) + + expect(() => peers.chat(3)).toThrow(MtArgumentError) + }) + }) + + describe('#get', () => { + it('should find peer info by Peer', () => { + const peers = PeersIndex.from({ + users: [createStub('user', { id: 1 }), createStub('user', { id: 2 })], + chats: [createStub('chat', { id: 1 }), createStub('channel', { id: 2 })], + }) + + expect(peers.get({ _: 'peerUser', userId: 1 })).toBe(peers.users.get(1)) + expect(peers.get({ _: 'peerChat', chatId: 1 })).toBe(peers.chats.get(1)) + expect(peers.get({ _: 'peerChannel', channelId: 2 })).toBe(peers.chats.get(2)) + }) + }) +}) diff --git a/packages/client/src/types/peers/user.test.ts b/packages/client/src/types/peers/user.test.ts new file mode 100644 index 00000000..94590433 --- /dev/null +++ b/packages/client/src/types/peers/user.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest' + +import { Long } from '@mtcute/core' +import { createStub } from '@mtcute/test' + +import { MessageEntity } from '../messages/index.js' +import { User } from './user.js' + +describe('User', () => { + describe('inputPeer', () => { + it('should return correct input peer', () => { + const user = new User( + createStub('user', { + id: 123, + accessHash: Long.fromBits(456, 789), + }), + ) + + expect(user.inputPeer).toEqual({ + _: 'inputPeerUser', + userId: 123, + accessHash: Long.fromBits(456, 789), + }) + }) + + it('should throw if user has no access hash', () => { + const user = new User( + createStub('user', { + id: 123, + accessHash: undefined, + }), + ) + + expect(() => user.inputPeer).toThrow() + }) + }) + + describe('status', () => { + it('should correctly handle bot online status', () => { + const user = new User( + createStub('user', { + bot: true, + }), + ) + + expect(user.status).toEqual('bot') + expect(user.lastOnline).toBeNull() + expect(user.nextOffline).toBeNull() + }) + + it('should correctly handle online status', () => { + const user = new User( + createStub('user', { + status: { + _: 'userStatusOnline', + expires: 1000, + }, + }), + ) + + expect(user.status).toEqual('online') + expect(user.lastOnline).toBeNull() + expect(user.nextOffline).toEqual(new Date(1000_000)) + }) + + it('should correctly handle offline status', () => { + const user = new User( + createStub('user', { + status: { + _: 'userStatusOffline', + wasOnline: 1000, + }, + }), + ) + + expect(user.status).toEqual('offline') + expect(user.lastOnline).toEqual(new Date(1000_000)) + expect(user.nextOffline).toBeNull() + }) + + it.each([ + ['userStatusRecently', 'recently'], + ['userStatusLastWeek', 'within_week'], + ['userStatusLastMonth', 'within_month'], + ['userStatusEmpty', 'long_time_ago'], + ] as const)('should correctly handle %s status', (status, expected) => { + const user = new User( + createStub('user', { + status: { + _: status, + }, + }), + ) + + expect(user.status).toEqual(expected) + expect(user.lastOnline).toBeNull() + expect(user.nextOffline).toBeNull() + }) + }) + + describe('usernames', () => { + it('should handle users with one username', () => { + const user = new User( + createStub('user', { + username: 'test', + }), + ) + + expect(user.username).toEqual('test') + expect(user.usernames).toEqual([{ _: 'username', username: 'test', active: true }]) + }) + + it('should handle users with multiple usernames', () => { + const user = new User( + createStub('user', { + usernames: [ + { _: 'username', username: 'test', active: true }, + { _: 'username', username: 'test2', active: false }, + ], + }), + ) + + expect(user.username).toEqual('test') + expect(user.usernames).toEqual([ + { _: 'username', username: 'test', active: true }, + { _: 'username', username: 'test2', active: false }, + ]) + }) + + it('should handle users with both username and usernames', () => { + // according to docs, this shouldn't ever actually happen, + // but just in case. let's just ignore the usernames field + const user = new User( + createStub('user', { + username: 'test1', + usernames: [ + { _: 'username', username: 'test2', active: true }, + { _: 'username', username: 'test3', active: false }, + ], + }), + ) + + expect(user.username).toEqual('test1') + expect(user.usernames).toEqual([{ _: 'username', username: 'test1', active: true }]) + }) + }) + + describe('displayName', () => { + it('should work for users without first name', () => { + const user = new User( + createStub('user', { + firstName: undefined, + }), + ) + + expect(user.displayName).toEqual('Deleted Account') + }) + + it('should work for users with only first name', () => { + const user = new User( + createStub('user', { + firstName: 'John', + }), + ) + + expect(user.displayName).toEqual('John') + }) + + it('should work for users with first and last name', () => { + const user = new User( + createStub('user', { + firstName: 'John', + lastName: 'Doe', + }), + ) + + expect(user.displayName).toEqual('John Doe') + }) + }) + + describe('mention', () => { + it('should work for users with a username', () => { + const user = new User( + createStub('user', { + username: 'test', + }), + ) + + expect(user.mention()).toEqual('@test') + }) + + it('should work for users with multiple usernames', () => { + const user = new User( + createStub('user', { + usernames: [ + { _: 'username', username: 'test', active: true }, + { _: 'username', username: 'test2', active: false }, + ], + }), + ) + + expect(user.mention()).toEqual('@test') + }) + + it('should work for users without a username', () => { + const user = new User( + createStub('user', { + firstName: 'John', + lastName: 'Doe', + }), + ) + + expect(user.mention()).toEqual( + new MessageEntity( + { + _: 'messageEntityMentionName', + userId: user.id, + offset: 0, + length: 8, + }, + 'John Doe', + ), + ) + }) + }) +}) diff --git a/packages/client/src/types/peers/user.ts b/packages/client/src/types/peers/user.ts index 59241b2f..f0803e67 100644 --- a/packages/client/src/types/peers/user.ts +++ b/packages/client/src/types/peers/user.ts @@ -145,7 +145,7 @@ export class User { return this.raw.lastName ?? null } - static parseStatus(status: tl.TypeUserStatus, bot = false): UserParsedStatus { + static parseStatus(status?: tl.TypeUserStatus, bot = false): UserParsedStatus { let ret: UserStatus let date: Date @@ -188,7 +188,7 @@ export class User { } private get _parsedStatus() { - return User.parseStatus(this.raw.status!, this.raw.bot) + return User.parseStatus(this.raw.status, this.raw.bot) } /** User's Last Seen & Online status */ @@ -214,18 +214,27 @@ export class User { /** User's or bot's username */ get username(): string | null { - return this.raw.username ?? this.raw.usernames?.[0].username ?? null + return this.raw.username ?? this.raw.usernames?.[0]?.username ?? null } /** User's or bot's usernames (including collectibles) */ get usernames(): ReadonlyArray | null { - return ( - this.raw.usernames ?? - (this.raw.username ? [{ _: 'username', username: this.raw.username, active: true }] : null) - ) + if (this.raw.username) { + return [{ _: 'username', username: this.raw.username, active: true }] + } + + if (!this.raw.usernames?.length) { + return null + } + + return this.raw.usernames } - /** IETF language tag of the user's language */ + /** + * IETF language tag of the user's language + * + * Only available in some contexts + */ get language(): string | null { return this.raw.langCode ?? null } @@ -354,7 +363,7 @@ export class User { * * > **Note**: This method doesn't format anything on its own. * > Instead, it returns a {@link MessageEntity} that can later - * > be used with `html` or `md` template tags, or `unparse` method directly. + * > be used with `html` or `md` template tags * * @param text Text of the mention. * @example @@ -393,13 +402,16 @@ export class User { * to actually make it permanent is to send it as a message * somewhere and load it from there if needed. * + * Note that some users (particularly, users with hidden forwards) + * may not be mentioned like this outside the chats you have in common. + * * This method is only needed when the result will be * stored somewhere outside current mtcute instance (e.g. saved for later use), * otherwise {@link mention} will be enough. * * > **Note**: This method doesn't format anything on its own. * > Instead, it returns a {@link MessageEntity} that can later - * > be used with `html` or `md` template tags, or `unparse` method directly. + * > be used with `html` or `md` template tags * * > **Note**: the resulting text can only be used by clients * > that support mtcute notation of permanent diff --git a/packages/test/src/client.test.ts b/packages/test/src/client.test.ts index 730f7ac5..aa5ca6ce 100644 --- a/packages/test/src/client.test.ts +++ b/packages/test/src/client.test.ts @@ -9,7 +9,7 @@ describe('client stub', () => { const client = new StubTelegramClient() const stubConfig = createStub('config') - client.respondWith('help.getConfig', stubConfig) + client.respondWith('help.getConfig', () => stubConfig) await client.with(async () => { const result = await client.call({ _: 'help.getConfig' }) diff --git a/packages/test/src/client.ts b/packages/test/src/client.ts index b4c9a9bd..2f8e72d9 100644 --- a/packages/test/src/client.ts +++ b/packages/test/src/client.ts @@ -50,37 +50,36 @@ export class StubTelegramClient extends BaseTelegramClient { }) } + /** + * Create a fake client that may not actually be used for API calls + * + * Useful for testing "offline" methods + */ + static offline() { + const client = new StubTelegramClient() + + client.call = (obj) => { + throw new Error(`Expected offline client to not make any API calls (method called: ${obj._})`) + } + + return client + } + // some fake peers handling // readonly _knownChats = new Map() readonly _knownUsers = new Map() _selfId = 0 - registerChat(chat: tl.TypeChat | tl.TypeChat[]): void { - if (Array.isArray(chat)) { - for (const c of chat) { - this.registerChat(c) + async registerPeers(...peers: (tl.TypeUser | tl.TypeChat)[]): Promise { + for (const peer of peers) { + if (tl.isAnyUser(peer)) { + this._knownUsers.set(peer.id, peer) + } else { + this._knownChats.set(peer.id, peer) } - return - } - - this._knownChats.set(chat.id, chat) - } - - registerUser(user: tl.TypeUser | tl.TypeUser[]): void { - if (Array.isArray(user)) { - for (const u of user) { - this.registerUser(u) - } - - return - } - - this._knownUsers.set(user.id, user) - - if (user._ === 'user' && user.self) { - this._selfId = user.id + await this._cachePeersFrom(peer) } } @@ -150,17 +149,14 @@ export class StubTelegramClient extends BaseTelegramClient { this.respondWith(method, responder) } - respondWith( - method: T, - response: tl.RpcCallReturn[T] | ((data: tl.FindByName) => tl.RpcCallReturn[T]), - ) { - if (typeof response !== 'function') { - const res = response - response = () => res - } - + respondWith< + T extends tl.RpcMethod['_'], + Fn extends(data: tl.FindByName) => MaybeAsync, + >(method: T, response: Fn): Fn { // eslint-disable-next-line this._responders.set(method, response as any) + + return response } async call(