test(client): high-level methods and types tests

This commit is contained in:
alina 🌸 2023-11-17 00:17:03 +03:00
parent 1b6e41a709
commit 42c3b2c809
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
13 changed files with 1100 additions and 110 deletions

View file

@ -8,7 +8,7 @@
"scripts": { "scripts": {
"prepare": "husky install .config/husky", "prepare": "husky install .config/husky",
"postinstall": "node scripts/validate-deps-versions.mjs", "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:dev": "vitest --config .config/vite.mts watch",
"test:ui": "vitest --config .config/vite.mts --ui", "test:ui": "vitest --config .config/vite.mts --ui",
"test:coverage": "vitest --config .config/vite.mts run --coverage", "test:coverage": "vitest --config .config/vite.mts run --coverage",

View file

@ -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([])
})
})

View file

@ -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)
})
})
})

View file

@ -11,6 +11,7 @@ import {
import { MtPeerNotFoundError } from '../../types/errors.js' import { MtPeerNotFoundError } from '../../types/errors.js'
import { InputPeerLike } from '../../types/peers/index.js' import { InputPeerLike } from '../../types/peers/index.js'
import { normalizeToInputPeer } from '../../utils/peer-utils.js' import { normalizeToInputPeer } from '../../utils/peer-utils.js'
import { getAuthState } from '../auth/_state.js'
// @available=both // @available=both
/** /**
@ -18,7 +19,7 @@ import { normalizeToInputPeer } from '../../utils/peer-utils.js'
* Useful when an `InputPeer` is needed in Raw API. * Useful when an `InputPeer` is needed in Raw API.
* *
* @param peerId The peer identifier that you want to extract the `InputPeer` from. * @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( export async function resolvePeer(
client: BaseTelegramClient, 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) const fromStorage = await client.storage.getPeerById(peerId)
if (fromStorage) return fromStorage if (fromStorage) return fromStorage
} }
@ -123,81 +124,32 @@ export async function resolvePeer(
throw new MtPeerNotFoundError(`Could not find a peer by ${peerId}`) 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) const peerType = getBasicPeerType(peerId)
// try fetching by id, with access_hash set to 0
switch (peerType) { switch (peerType) {
case 'user': { 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 { return {
_: 'inputPeerUser', _: 'inputPeerUser',
userId: found.id, userId: peerId,
accessHash: found.accessHash, accessHash: Long.ZERO,
} }
} case 'chat':
break
}
case 'chat': {
return { return {
_: 'inputPeerChat', _: 'inputPeerChat',
chatId: -peerId, chatId: -peerId,
} }
}
case 'channel': { case 'channel': {
const id = toggleChannelIdMark(peerId) 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 { return {
_: 'inputPeerChannel', _: 'inputPeerChannel',
channelId: found.id, channelId: id,
accessHash: found.accessHash ?? Long.ZERO, accessHash: Long.ZERO,
} }
} }
break
} }
}
throw new MtPeerNotFoundError(`Could not find a peer by ID ${peerId}`)
} }

View file

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

View file

@ -72,7 +72,7 @@ export class BotKeyboardBuilder {
this._buttons.length && this._buttons.length &&
(this.maxRowWidth === null || force || this._buttons[this._buttons.length - 1].length < this.maxRowWidth) (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 { } else {
this._buttons.push([btn]) this._buttons.push([btn])
} }

View file

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

View file

@ -437,6 +437,8 @@ export namespace BotKeyboard {
resize: obj.resize, resize: obj.resize,
singleUse: obj.singleUse, singleUse: obj.singleUse,
selective: obj.selective, selective: obj.selective,
persistent: obj.persistent,
placeholder: obj.placeholder,
rows: _2dToRows(obj.buttons, false), rows: _2dToRows(obj.buttons, false),
} }
case 'reply_hide': case 'reply_hide':
@ -449,6 +451,7 @@ export namespace BotKeyboard {
_: 'replyKeyboardForceReply', _: 'replyKeyboardForceReply',
singleUse: obj.singleUse, singleUse: obj.singleUse,
selective: obj.selective, selective: obj.selective,
placeholder: obj.placeholder,
} }
case 'inline': case 'inline':
return { return {

View file

@ -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))
})
})
})

View file

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

View file

@ -145,7 +145,7 @@ export class User {
return this.raw.lastName ?? null 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 ret: UserStatus
let date: Date let date: Date
@ -188,7 +188,7 @@ export class User {
} }
private get _parsedStatus() { 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 */ /** User's Last Seen & Online status */
@ -214,18 +214,27 @@ export class User {
/** User's or bot's username */ /** User's or bot's username */
get username(): string | null { 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) */ /** User's or bot's usernames (including collectibles) */
get usernames(): ReadonlyArray<tl.RawUsername> | null { get usernames(): ReadonlyArray<tl.RawUsername> | null {
return ( if (this.raw.username) {
this.raw.usernames ?? return [{ _: 'username', username: this.raw.username, active: true }]
(this.raw.username ? [{ _: 'username', username: this.raw.username, active: true }] : null)
)
} }
/** IETF language tag of the user's language */ if (!this.raw.usernames?.length) {
return null
}
return this.raw.usernames
}
/**
* IETF language tag of the user's language
*
* Only available in some contexts
*/
get language(): string | null { get language(): string | null {
return this.raw.langCode ?? null return this.raw.langCode ?? null
} }
@ -354,7 +363,7 @@ export class User {
* *
* > **Note**: This method doesn't format anything on its own. * > **Note**: This method doesn't format anything on its own.
* > Instead, it returns a {@link MessageEntity} that can later * > 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. * @param text Text of the mention.
* @example * @example
@ -393,13 +402,16 @@ export class User {
* to actually make it permanent is to send it as a message * to actually make it permanent is to send it as a message
* somewhere and load it from there if needed. * 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 * This method is only needed when the result will be
* stored somewhere outside current mtcute instance (e.g. saved for later use), * stored somewhere outside current mtcute instance (e.g. saved for later use),
* otherwise {@link mention} will be enough. * otherwise {@link mention} will be enough.
* *
* > **Note**: This method doesn't format anything on its own. * > **Note**: This method doesn't format anything on its own.
* > Instead, it returns a {@link MessageEntity} that can later * > 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 * > **Note**: the resulting text can only be used by clients
* > that support mtcute notation of permanent * > that support mtcute notation of permanent

View file

@ -9,7 +9,7 @@ describe('client stub', () => {
const client = new StubTelegramClient() const client = new StubTelegramClient()
const stubConfig = createStub('config') const stubConfig = createStub('config')
client.respondWith('help.getConfig', stubConfig) client.respondWith('help.getConfig', () => stubConfig)
await client.with(async () => { await client.with(async () => {
const result = await client.call({ _: 'help.getConfig' }) const result = await client.call({ _: 'help.getConfig' })

View file

@ -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 // // some fake peers handling //
readonly _knownChats = new Map<number, tl.TypeChat>() readonly _knownChats = new Map<number, tl.TypeChat>()
readonly _knownUsers = new Map<number, tl.TypeUser>() readonly _knownUsers = new Map<number, tl.TypeUser>()
_selfId = 0 _selfId = 0
registerChat(chat: tl.TypeChat | tl.TypeChat[]): void { async registerPeers(...peers: (tl.TypeUser | tl.TypeChat)[]): Promise<void> {
if (Array.isArray(chat)) { for (const peer of peers) {
for (const c of chat) { if (tl.isAnyUser(peer)) {
this.registerChat(c) this._knownUsers.set(peer.id, peer)
} else {
this._knownChats.set(peer.id, peer)
} }
return await this._cachePeersFrom(peer)
}
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
} }
} }
@ -150,17 +149,14 @@ export class StubTelegramClient extends BaseTelegramClient {
this.respondWith(method, responder) this.respondWith(method, responder)
} }
respondWith<T extends tl.RpcMethod['_']>( respondWith<
method: T, T extends tl.RpcMethod['_'],
response: tl.RpcCallReturn[T] | ((data: tl.FindByName<tl.RpcMethod, T>) => tl.RpcCallReturn[T]), Fn extends(data: tl.FindByName<tl.RpcMethod, T>) => MaybeAsync<tl.RpcCallReturn[T]>,
) { >(method: T, response: Fn): Fn {
if (typeof response !== 'function') {
const res = response
response = () => res
}
// eslint-disable-next-line // eslint-disable-next-line
this._responders.set(method, response as any) this._responders.set(method, response as any)
return response
} }
async call<T extends tl.RpcMethod>( async call<T extends tl.RpcMethod>(