diff --git a/.eslintrc.js b/.eslintrc.js index 4869ff14..10e56c08 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -215,7 +215,13 @@ module.exports = { '@typescript-eslint/no-dynamic-delete': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', 'no-restricted-globals': ['error', 'Buffer', '__dirname', 'require'], - 'no-restricted-imports': ['error', 'buffer', 'crypto', 'fs', 'path', 'stream'], + 'no-restricted-imports': [ + 'error', + { + paths: ['buffer', 'crypto', 'fs', 'path', 'stream'], + patterns: ['@mtcute/*/dist/**'], + }, + ], }, reportUnusedDisableDirectives: false, settings: { @@ -229,7 +235,12 @@ module.exports = { files: ['**/scripts/**', '*.test.ts', 'packages/create-*/**', '**/build.config.cjs'], rules: { 'no-console': 'off', - 'no-restricted-imports': 'off', + 'no-restricted-imports': [ + 'error', + { + patterns: ['@mtcute/*/dist/**'], + }, + ], }, }, { diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs index 0bd80c35..027814cc 100644 --- a/.lintstagedrc.cjs +++ b/.lintstagedrc.cjs @@ -13,10 +13,10 @@ module.exports = { } return [ + ...[...modifiedPackages].map((pkg) => `pnpm -C packages/${pkg} exec tsc --build`), `prettier --write ${filenames.join(' ')}`, `eslint -c ${eslintCiConfig} --fix ${filenames.join(' ')}`, 'pnpm run lint:dpdm', - ...[...modifiedPackages].map((pkg) => `pnpm -C packages/${pkg} run --if-present build --noEmit`) ] } } diff --git a/packages/core/package.json b/packages/core/package.json index 8d3fe741..0ecc6d05 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,6 +53,8 @@ "devDependencies": { "@types/ws": "8.5.4", "node-forge": "1.3.1", + "@mtcute/test": "workspace:^", + "@mtcute/dispatcher": "workspace:^", "ws": "8.13.0" } } diff --git a/packages/core/src/network/transports/intermediate.test.ts b/packages/core/src/network/transports/intermediate.test.ts index fc017d36..fb9c7a33 100644 --- a/packages/core/src/network/transports/intermediate.test.ts +++ b/packages/core/src/network/transports/intermediate.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { hexDecodeToBuffer, hexEncode } from '@mtcute/tl-runtime' -import { IntermediatePacketCodec, TransportError } from '../../index.js' +import { IntermediatePacketCodec, PaddedIntermediatePacketCodec, TransportError } from '../../index.js' +import { concatBuffers, dataViewFromBuffer } from '../../utils/index.js' describe('IntermediatePacketCodec', () => { it('should return correct tag', () => { @@ -76,4 +77,35 @@ describe('IntermediatePacketCodec', () => { codec.reset() codec.feed(hexDecodeToBuffer('050000000102030405')) })) + + it('should correctly frame packets', () => { + const data = hexDecodeToBuffer('6cfeffff') + + // eslint-disable-next-line no-restricted-globals + expect(Buffer.from(new IntermediatePacketCodec().encode(data))).toEqual( + concatBuffers([new Uint8Array([0x04, 0x00, 0x00, 0x00]), data]), + ) + }) +}) + +describe('PaddedIntermediatePacketCodec', () => { + it('should return correct tag', () => { + expect(hexEncode(new PaddedIntermediatePacketCodec().tag())).eq('dddddddd') + }) + + it('should correctly frame packets', () => { + // todo: once we have predictable random, test this properly + + const data = hexDecodeToBuffer('6cfeffff') + const encoded = new PaddedIntermediatePacketCodec().encode(data) + const dv = dataViewFromBuffer(encoded) + + const packetSize = dv.getUint32(0, true) + const paddingSize = packetSize - data.length + + // padding size, 0-15 + expect(paddingSize).toBeGreaterThanOrEqual(0) + expect(paddingSize).toBeLessThanOrEqual(15) + expect([...encoded.slice(4, 4 + packetSize - paddingSize)]).toEqual([...data]) + }) }) diff --git a/packages/core/src/storage/json.test.ts b/packages/core/src/storage/json.test.ts new file mode 100644 index 00000000..7a9a0b3f --- /dev/null +++ b/packages/core/src/storage/json.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { JsonMemoryStorage } from './json.js' +import { stubPeerUser } from './storage.test-utils.js' + +describe('JsonMemoryStorage', () => { + class ExtJsonMemoryStorage extends JsonMemoryStorage { + loadJson(json: string): void { + this._loadJson(json) + } + + saveJson(): string { + return this._saveJson() + } + + getInternalState() { + return this._state + } + } + + it('should allow importing and exporting to json', () => { + const s = new ExtJsonMemoryStorage() + + s.setUpdatesPts(123) + s.setUpdatesQts(456) + // eslint-disable-next-line no-restricted-globals + s.setAuthKeyFor(1, Buffer.from([1, 2, 3])) + // eslint-disable-next-line no-restricted-globals + s.setTempAuthKeyFor(2, 0, Buffer.from([4, 5, 6]), 1234567890) + s.setState('someState', 'someValue') + s.updatePeers([{ ...stubPeerUser, updated: 0 }]) + + const json = s.saveJson() + const s2 = new ExtJsonMemoryStorage() + s2.loadJson(json) + + expect(s2.getInternalState()).toEqual({ + ...s.getInternalState(), + entities: new Map(), // entities are not saved + }) + }) +}) diff --git a/packages/core/src/storage/json.ts b/packages/core/src/storage/json.ts index 21922cb2..14801874 100644 --- a/packages/core/src/storage/json.ts +++ b/packages/core/src/storage/json.ts @@ -1,8 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import { tl } from '@mtcute/tl' import { base64DecodeToBuffer, base64Encode } from '@mtcute/tl-runtime' -import { longFromFastString, longToFastString } from '../utils/long-utils.js' import { MemorySessionState, MemoryStorage } from './memory.js' /** @@ -16,25 +14,26 @@ export class JsonMemoryStorage extends MemoryStorage { switch (key) { case 'authKeys': case 'authKeysTemp': { - const ret: Record = {} + const ret = new Map() ;(value as string).split('|').forEach((pair: string) => { const [dcId, b64] = pair.split(',') - ret[dcId] = base64DecodeToBuffer(b64) + const mapKey = key === 'authKeysTemp' ? dcId : parseInt(dcId) + + ret.set(mapKey, base64DecodeToBuffer(b64)) }) return ret } case 'authKeysTempExpiry': - case 'entities': case 'phoneIndex': case 'usernameIndex': case 'pts': case 'fsm': case 'rl': return new Map(Object.entries(value as Record)) - case 'accessHash': - return longFromFastString(value as string) + case 'entities': + return new Map() } return value @@ -55,15 +54,14 @@ export class JsonMemoryStorage extends MemoryStorage { .join('|') } case 'authKeysTempExpiry': - case 'entities': case 'phoneIndex': case 'usernameIndex': case 'pts': case 'fsm': case 'rl': return Object.fromEntries([...(value as Map).entries()]) - case 'accessHash': - return longToFastString(value as tl.Long) + case 'entities': + return {} } return value diff --git a/packages/core/src/storage/localstorage.test.ts b/packages/core/src/storage/localstorage.test.ts new file mode 100644 index 00000000..3e041277 --- /dev/null +++ b/packages/core/src/storage/localstorage.test.ts @@ -0,0 +1,26 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { LocalstorageStorage } from './localstorage.js' + +const localStorageStub = { + getItem: vi.fn().mockImplementation(() => null), + setItem: vi.fn(), +} +describe('LocalstorageStorage', () => { + beforeAll(() => void vi.stubGlobal('localStorage', localStorageStub)) + afterAll(() => void vi.unstubAllGlobals()) + + it('should load from localstorage', () => { + const s = new LocalstorageStorage('test') + s.load() + + expect(localStorageStub.getItem).toHaveBeenCalledWith('test') + }) + + it('should save to localstorage', () => { + const s = new LocalstorageStorage('test') + s.save() + + expect(localStorageStub.setItem).toHaveBeenCalledWith('test', expect.any(String)) + }) +}) diff --git a/packages/core/src/storage/localstorage.ts b/packages/core/src/storage/localstorage.ts index beef3a29..d041740c 100644 --- a/packages/core/src/storage/localstorage.ts +++ b/packages/core/src/storage/localstorage.ts @@ -16,11 +16,14 @@ export class LocalstorageStorage extends JsonMemoryStorage { load(): void { try { - this._loadJson(localStorage[this._key] as string) + const val = localStorage.getItem(this._key) + if (val === null) return + + this._loadJson(val) } catch (e) {} } save(): void { - localStorage[this._key] = this._saveJson() + localStorage.setItem(this._key, this._saveJson()) } } diff --git a/packages/core/src/storage/memory.test.ts b/packages/core/src/storage/memory.test.ts new file mode 100644 index 00000000..dc74cd18 --- /dev/null +++ b/packages/core/src/storage/memory.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { MemoryStorage } from './memory.js' +import { testStateStorage, testStorage } from './storage.test-utils.js' + +describe('MemoryStorage', () => { + testStorage(new MemoryStorage()) + testStateStorage(new MemoryStorage()) + + describe('extending', () => { + it('should allow populating from an object', () => { + class ExtendedMemoryStorage extends MemoryStorage { + constructor() { + super() + this._setStateFrom({ + $version: 1, + defaultDcs: null, + authKeys: new Map(), + authKeysTemp: new Map(), + authKeysTempExpiry: new Map(), + entities: new Map(), + phoneIndex: new Map(), + usernameIndex: new Map(), + gpts: [1, 2, 3, 4], + pts: new Map(), + fsm: new Map(), + rl: new Map(), + self: null, + }) + } + } + + const s = new ExtendedMemoryStorage() + + expect(s.getUpdatesState()).toEqual([1, 2, 3, 4]) + }) + + it('should silently fail if version is wrong', () => { + class ExtendedMemoryStorage extends MemoryStorage { + constructor() { + super() + // eslint-disable-next-line + this._setStateFrom({ $version: 0 } as any) + } + } + + const s = new ExtendedMemoryStorage() + + expect(s.getUpdatesState()).toEqual(null) + }) + }) +}) diff --git a/packages/core/src/storage/memory.ts b/packages/core/src/storage/memory.ts index 0467b17d..8541590d 100644 --- a/packages/core/src/storage/memory.ts +++ b/packages/core/src/storage/memory.ts @@ -1,6 +1,6 @@ +import { IStateStorage } from '@mtcute/dispatcher' import { tl } from '@mtcute/tl' -import { MaybeAsync } from '../types/index.js' import { LruMap, toggleChannelIdMark } from '../utils/index.js' import { ITelegramStorage } from './abstract.js' @@ -56,7 +56,7 @@ export interface MemorySessionState { const USERNAME_TTL = 86400000 // 24 hours -export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ { +export class MemoryStorage implements ITelegramStorage, IStateStorage { protected _state!: MemorySessionState private _cachedInputPeers: LruMap = new LruMap(100) @@ -131,11 +131,11 @@ export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ { // populate indexes if needed let populate = false - if (!obj.phoneIndex) { + if (!obj.phoneIndex?.size) { obj.phoneIndex = new Map() populate = true } - if (!obj.usernameIndex) { + if (!obj.usernameIndex?.size) { obj.usernameIndex = new Map() populate = true } @@ -229,7 +229,7 @@ export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ { } } - updatePeers(peers: PeerInfoWithUpdated[]): MaybeAsync { + updatePeers(peers: PeerInfoWithUpdated[]): void { for (const peer of peers) { this._cachedFull.set(peer.id, peer.full) @@ -326,26 +326,26 @@ export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ { return this._state.pts.get(entityId) ?? null } - getUpdatesState(): MaybeAsync<[number, number, number, number] | null> { + getUpdatesState(): [number, number, number, number] | null { return this._state.gpts ?? null } - setUpdatesPts(val: number): MaybeAsync { + setUpdatesPts(val: number): void { if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] this._state.gpts[0] = val } - setUpdatesQts(val: number): MaybeAsync { + setUpdatesQts(val: number): void { if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] this._state.gpts[1] = val } - setUpdatesDate(val: number): MaybeAsync { + setUpdatesDate(val: number): void { if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] this._state.gpts[2] = val } - setUpdatesSeq(val: number): MaybeAsync { + setUpdatesSeq(val: number): void { if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] this._state.gpts[3] = val } diff --git a/packages/core/src/storage/storage.test-utils.ts b/packages/core/src/storage/storage.test-utils.ts new file mode 100644 index 00000000..cbed3ee7 --- /dev/null +++ b/packages/core/src/storage/storage.test-utils.ts @@ -0,0 +1,332 @@ +import Long from 'long' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { IStateStorage } from '@mtcute/dispatcher' +import { createStub } from '@mtcute/test' +import { tl } from '@mtcute/tl' +import { __tlReaderMap } from '@mtcute/tl/binary/reader.js' +import { __tlWriterMap } from '@mtcute/tl/binary/writer.js' + +import { defaultProductionDc } from '../utils/default-dcs.js' +import { LogManager } from '../utils/index.js' +import { ITelegramStorage } from './abstract.js' + +export const stubPeerUser: ITelegramStorage.PeerInfo = { + id: 123123, + accessHash: Long.fromBits(123, 456), + type: 'user', + username: 'some_user', + phone: '78005553535', + full: createStub('user', { id: 123123 }), +} +const peerUserInput: tl.TypeInputPeer = { + _: 'inputPeerUser', + userId: 123123, + accessHash: Long.fromBits(123, 456), +} + +const peerChannel: ITelegramStorage.PeerInfo = { + id: -1001183945448, + accessHash: Long.fromBits(666, 555), + type: 'channel', + username: 'some_channel', + full: createStub('channel', { id: 123123 }), +} + +const peerChannelInput: tl.TypeInputPeer = { + _: 'inputPeerChannel', + channelId: 1183945448, + accessHash: Long.fromBits(666, 555), +} + +export function testStorage(s: ITelegramStorage): void { + beforeAll(async () => { + await s.load?.() + + const logger = new LogManager() + logger.level = 0 + s.setup?.(logger, __tlReaderMap, __tlWriterMap) + }) + + afterAll(() => s.destroy?.()) + beforeEach(() => s.reset?.()) + + describe('default dc', () => { + it('should store', async () => { + await s.setDefaultDcs(defaultProductionDc) + expect(await s.getDefaultDcs()).toBe(defaultProductionDc) + }) + + it('should remove', async () => { + await s.setDefaultDcs(null) + expect(await s.getDefaultDcs()).toBeNull() + }) + }) + + describe('auth keys', () => { + beforeAll(() => void vi.useFakeTimers()) + afterAll(() => void vi.useRealTimers()) + + const key2 = new Uint8Array(256).fill(0x42) + const key3 = new Uint8Array(256).fill(0x43) + + const key2i0 = new Uint8Array(256).fill(0x44) + const key2i1 = new Uint8Array(256).fill(0x45) + const key3i0 = new Uint8Array(256).fill(0x46) + const key3i1 = new Uint8Array(256).fill(0x47) + + it('should store perm auth key', async () => { + await s.setAuthKeyFor(2, key2) + await s.setAuthKeyFor(3, key3) + + expect(await s.getAuthKeyFor(2)).toEqual(key2) + expect(await s.getAuthKeyFor(3)).toEqual(key3) + }) + + it('should store temp auth keys', async () => { + const expire = Date.now() + 1000 + + await s.setTempAuthKeyFor(2, 0, key2i0, expire) + await s.setTempAuthKeyFor(2, 1, key2i1, expire) + await s.setTempAuthKeyFor(3, 0, key3i0, expire) + await s.setTempAuthKeyFor(3, 1, key3i1, expire) + + expect(await s.getAuthKeyFor(2, 0)).toEqual(key2i0) + expect(await s.getAuthKeyFor(2, 1)).toEqual(key2i1) + expect(await s.getAuthKeyFor(3, 0)).toEqual(key3i0) + expect(await s.getAuthKeyFor(3, 1)).toEqual(key3i1) + }) + + it('should expire temp auth keys', async () => { + const expire = Date.now() + 1000 + + await s.setTempAuthKeyFor(2, 0, key2i0, expire) + await s.setTempAuthKeyFor(2, 1, key2i1, expire) + await s.setTempAuthKeyFor(3, 0, key3i0, expire) + await s.setTempAuthKeyFor(3, 1, key3i1, expire) + + vi.advanceTimersByTime(10000) + + expect(await s.getAuthKeyFor(2, 0)).toBeNull() + expect(await s.getAuthKeyFor(2, 1)).toBeNull() + expect(await s.getAuthKeyFor(3, 0)).toBeNull() + expect(await s.getAuthKeyFor(3, 1)).toBeNull() + }) + + it('should remove auth keys', async () => { + const expire = Date.now() + 1000 + + await s.setTempAuthKeyFor(2, 0, key2i0, expire) + await s.setTempAuthKeyFor(2, 1, key2i1, expire) + await s.setAuthKeyFor(2, key2) + await s.setAuthKeyFor(3, key3) + + await s.setAuthKeyFor(2, null) + await s.setTempAuthKeyFor(2, 0, null, 0) + await s.setTempAuthKeyFor(2, 1, null, 0) + + expect(await s.getAuthKeyFor(2)).toBeNull() + expect(await s.getAuthKeyFor(2, 0)).toBeNull() + expect(await s.getAuthKeyFor(2, 1)).toBeNull() + expect(await s.getAuthKeyFor(3)).toEqual(key3) // should not be removed + }) + + it('should remove all auth keys with dropAuthKeysFor', async () => { + const expire = Date.now() + 1000 + + await s.setTempAuthKeyFor(2, 0, key2i0, expire) + await s.setTempAuthKeyFor(2, 1, key2i1, expire) + await s.setAuthKeyFor(2, key2) + await s.setAuthKeyFor(3, key3) + + await s.dropAuthKeysFor(2) + + expect(await s.getAuthKeyFor(2)).toBeNull() + expect(await s.getAuthKeyFor(2, 0)).toBeNull() + expect(await s.getAuthKeyFor(2, 1)).toBeNull() + expect(await s.getAuthKeyFor(3)).toEqual(key3) // should not be removed + }) + }) + + describe('peers', () => { + it('should cache and return peers', async () => { + await s.updatePeers([stubPeerUser, peerChannel]) + + expect(await s.getPeerById(stubPeerUser.id)).toEqual(peerUserInput) + expect(await s.getPeerById(peerChannel.id)).toEqual(peerChannelInput) + }) + + it('should cache and return peers by username', async () => { + await s.updatePeers([stubPeerUser, peerChannel]) + + expect(await s.getPeerByUsername(stubPeerUser.username!)).toEqual(peerUserInput) + expect(await s.getPeerByUsername(peerChannel.username!)).toEqual(peerChannelInput) + }) + + it('should cache and return peers by phone', async () => { + await s.updatePeers([stubPeerUser]) + + expect(await s.getPeerByPhone(stubPeerUser.phone!)).toEqual(peerUserInput) + }) + + it('should overwrite existing cached peers', async () => { + await s.updatePeers([stubPeerUser]) + await s.updatePeers([{ ...stubPeerUser, username: 'whatever' }]) + + expect(await s.getPeerById(stubPeerUser.id)).toEqual(peerUserInput) + expect(await s.getPeerByUsername(stubPeerUser.username!)).toBeNull() + expect(await s.getPeerByUsername('whatever')).toEqual(peerUserInput) + }) + + it('should cache full peer info', async () => { + await s.updatePeers([stubPeerUser, peerChannel]) + + expect(await s.getFullPeerById(stubPeerUser.id)).toEqual(stubPeerUser.full) + expect(await s.getFullPeerById(peerChannel.id)).toEqual(peerChannel.full) + }) + }) + + describe('current user', () => { + const self: ITelegramStorage.SelfInfo = { + userId: 123123, + isBot: false, + } + + it('should store and return self info', async () => { + await s.setSelf(self) + expect(await s.getSelf()).toEqual(self) + }) + + it('should remove self info', async () => { + await s.setSelf(self) + await s.setSelf(null) + expect(await s.getSelf()).toBeNull() + }) + }) + + describe('updates state', () => { + it('should store and return updates state', async () => { + await s.setUpdatesPts(1) + await s.setUpdatesQts(2) + await s.setUpdatesDate(3) + await s.setUpdatesSeq(4) + expect(await s.getUpdatesState()).toEqual([1, 2, 3, 4]) + }) + + it('should store and return channel pts', async () => { + await s.setManyChannelPts( + new Map([ + [1, 2], + [3, 4], + ]), + ) + + expect(await s.getChannelPts(1)).toEqual(2) + expect(await s.getChannelPts(3)).toEqual(4) + expect(await s.getChannelPts(2)).toBeNull() + }) + + it('should be null after reset', async () => { + expect(await s.getUpdatesState()).toBeNull() + }) + }) +} + +export function testStateStorage(s: IStateStorage) { + describe('key-value state', () => { + beforeAll(() => void vi.useFakeTimers()) + afterAll(() => void vi.useRealTimers()) + + it('should store and return state', async () => { + await s.setState('a', 'b') + await s.setState('c', 'd') + await s.setState('e', 'f') + + expect(await s.getState('a')).toEqual('b') + expect(await s.getState('c')).toEqual('d') + expect(await s.getState('e')).toEqual('f') + }) + + it('should remove state', async () => { + await s.setState('a', 'b') + await s.setState('c', 'd') + await s.setState('e', 'f') + + await s.deleteState('a') + await s.deleteState('c') + await s.deleteState('e') + + expect(await s.getState('a')).toBeNull() + expect(await s.getState('c')).toBeNull() + expect(await s.getState('e')).toBeNull() + }) + + it('should expire state', async () => { + await s.setState('a', 'b', 1) + await s.setState('c', 'd', 1) + await s.setState('e', 'f', 1) + + vi.advanceTimersByTime(10000) + + expect(await s.getState('a')).toBeNull() + expect(await s.getState('c')).toBeNull() + expect(await s.getState('e')).toBeNull() + }) + }) + + describe('scenes', () => { + it('should store and return scenes', async () => { + await s.setCurrentScene('a', 'b') + await s.setCurrentScene('c', 'd') + await s.setCurrentScene('e', 'f') + + expect(await s.getCurrentScene('a')).toEqual('b') + expect(await s.getCurrentScene('c')).toEqual('d') + expect(await s.getCurrentScene('e')).toEqual('f') + }) + + it('should remove scenes', async () => { + await s.setCurrentScene('a', 'b') + await s.setCurrentScene('c', 'd') + await s.setCurrentScene('e', 'f') + + await s.deleteCurrentScene('a') + await s.deleteCurrentScene('c') + await s.deleteCurrentScene('e') + + expect(await s.getCurrentScene('a')).toBeNull() + expect(await s.getCurrentScene('c')).toBeNull() + expect(await s.getCurrentScene('e')).toBeNull() + }) + }) + + describe('rate limit', () => { + beforeAll(() => void vi.useFakeTimers()) + afterAll(() => void vi.useRealTimers()) + + const check = () => s.getRateLimit('test', 3, 1) + + it('should implement basic rate limiting', async () => { + vi.setSystemTime(0) + + expect(await check()).toEqual([3, 1000]) + expect(await check()).toEqual([2, 1000]) + expect(await check()).toEqual([1, 1000]) + expect(await check()).toEqual([0, 1000]) + + vi.setSystemTime(1001) + + expect(await check()).toEqual([3, 2001]) + }) + + it('should allow resetting rate limit', async () => { + vi.setSystemTime(0) + + await check() + await check() + + await s.resetRateLimit('test') + expect(await check()).toEqual([3, 1000]) + }) + }) +} diff --git a/packages/core/src/utils/async-lock.test.ts b/packages/core/src/utils/async-lock.test.ts new file mode 100644 index 00000000..54e879c1 --- /dev/null +++ b/packages/core/src/utils/async-lock.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { AsyncLock } from './async-lock.js' +import { sleep } from './misc-utils.js' + +describe('AsyncLock', () => { + it('should correctly lock execution', async () => { + const lock = new AsyncLock() + + const log: number[] = [] + await Promise.all( + Array.from({ length: 10 }, (_, idx) => + lock.with(async () => { + await sleep(10 - idx) + log.push(idx) + }), + ), + ) + + expect(log).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + }) + + it('should correctly propagate errors', async () => { + const lock = new AsyncLock() + + await expect(async () => { + await lock.with(() => { + throw new Error('test') + }) + }).rejects.toThrow('test') + }) +}) diff --git a/packages/core/src/utils/bigint-utils.test.ts b/packages/core/src/utils/bigint-utils.test.ts index 54dd72af..44a24a94 100644 --- a/packages/core/src/utils/bigint-utils.test.ts +++ b/packages/core/src/utils/bigint-utils.test.ts @@ -2,7 +2,28 @@ import { describe, expect, it } from 'vitest' import { hexDecodeToBuffer } from '@mtcute/tl-runtime' -import { bigIntToBuffer, bufferToBigInt } from './index.js' +import { + bigIntBitLength, + bigIntGcd, + bigIntModInv, + bigIntModPow, + bigIntToBuffer, + bufferToBigInt, + randomBigInt, + randomBigIntBits, + randomBigIntInRange, + twoMultiplicity, +} from './index.js' + +describe('bigIntBitLength', () => { + it('should correctly calculate bit length', () => { + expect(bigIntBitLength(0n)).eq(0) + expect(bigIntBitLength(1n)).eq(1) + expect(bigIntBitLength(2n)).eq(2) + expect(bigIntBitLength(255n)).eq(8) + expect(bigIntBitLength(256n)).eq(9) + }) +}) describe('bigIntToBuffer', () => { it('should handle writing to BE', () => { @@ -63,3 +84,111 @@ describe('bufferToBigInt', () => { expect(bufferToBigInt(buf.reverse(), true).toString()).eq(num.toString()) }) }) + +describe('randomBigInt', () => { + it('should return a random bigint', () => { + const a = randomBigInt(32) + const b = randomBigInt(32) + + expect(a).not.toEqual(b) + }) + + it('should return a random bigint up to specified byte length', () => { + const a = randomBigInt(32) + const b = randomBigInt(64) + + expect(bigIntBitLength(a)).toBeLessThanOrEqual(32 * 8) + expect(bigIntBitLength(b)).toBeLessThanOrEqual(64 * 8) + }) +}) + +describe('randomBigIntBits', () => { + it('should return a random bigint', () => { + const a = randomBigIntBits(32) + const b = randomBigIntBits(32) + + expect(a).not.toEqual(b) + }) + + it('should return a random bigint up to specified bit length', () => { + const a = randomBigIntBits(32) + const b = randomBigIntBits(64) + + expect(bigIntBitLength(a)).toBeLessThanOrEqual(32) + expect(bigIntBitLength(b)).toBeLessThanOrEqual(64) + }) +}) + +describe('randomBigIntInRange', () => { + it('should return a random bigint', () => { + const a = randomBigIntInRange(10000n) + const b = randomBigIntInRange(10000n) + + expect(a).not.toEqual(b) + }) + + it('should return a bigint within a given range', () => { + const a = randomBigIntInRange(200n, 100n) + + expect(a).toBeGreaterThanOrEqual(100n) + expect(a).toBeLessThan(200n) + }) +}) + +describe('twoMultiplicity', () => { + it('should return the multiplicity of 2 in the prime factorization of n', () => { + expect(twoMultiplicity(0n)).toEqual(0n) + expect(twoMultiplicity(1n)).toEqual(0n) + expect(twoMultiplicity(2n)).toEqual(1n) + expect(twoMultiplicity(4n)).toEqual(2n) + expect(twoMultiplicity(65536n)).toEqual(16n) + expect(twoMultiplicity(65537n)).toEqual(0n) + }) +}) + +describe('bigIntGcd', () => { + it('should return the greatest common divisor of a and b', () => { + expect(bigIntGcd(123n, 456n)).toEqual(3n) + }) + + it('should correctly handle zeros', () => { + expect(bigIntGcd(0n, 0n)).toEqual(0n) + expect(bigIntGcd(0n, 1n)).toEqual(1n) + expect(bigIntGcd(1n, 0n)).toEqual(1n) + }) + + it('should correctly handle equal values', () => { + expect(bigIntGcd(1n, 1n)).toEqual(1n) + }) +}) + +describe('bigIntModPow', () => { + it('should correctly calculate modular exponentiation', () => { + expect(bigIntModPow(2n, 3n, 5n)).toEqual(3n) + expect(bigIntModPow(2n, 3n, 6n)).toEqual(2n) + expect(bigIntModPow(2n, 3n, 7n)).toEqual(1n) + expect(bigIntModPow(2n, 3n, 8n)).toEqual(0n) + }) + + it('should correctly handle very large numbers', () => { + // calculating this with BigInt would either take forever or error with "Maximum BigInt size exceeded + expect(bigIntModPow(2n, 100000000000n, 100n)).toEqual(76n) + }) +}) + +describe('bigIntModInv', () => { + it('should correctly calculate modular inverse', () => { + expect(bigIntModInv(2n, 5n)).toEqual(3n) + expect(bigIntModInv(2n, 7n)).toEqual(4n) + }) + + it("should error if there's no modular inverse", () => { + expect(() => bigIntModInv(2n, 6n)).toThrow(RangeError) + expect(() => bigIntModInv(2n, 8n)).toThrow(RangeError) + }) + + it('should correctly handle very large numbers', () => { + // calculating this with BigInt would either take forever or error with "Maximum BigInt size exceeded + expect(bigIntModInv(123123123123n, 1829n)).toEqual(318n) + }) +}) diff --git a/packages/core/src/utils/buffer-utils.test.ts b/packages/core/src/utils/buffer-utils.test.ts index da722589..6cd025f0 100644 --- a/packages/core/src/utils/buffer-utils.test.ts +++ b/packages/core/src/utils/buffer-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { hexEncode, utf8Decode, utf8EncodeToBuffer } from '@mtcute/tl-runtime' @@ -121,6 +121,19 @@ describe('concatBuffers', () => { buf[0] = 0xff expect(buf1[0]).not.eql(0xff) }) + + it('should work without native Buffer', () => { + vi.stubGlobal('Buffer', undefined) + const buf1 = new Uint8Array([1, 2, 3]) + const buf2 = new Uint8Array([4, 5, 6]) + const buf = concatBuffers([buf1, buf2]) + + buf1[0] = 0xff + + expect([...buf]).eql([1, 2, 3, 4, 5, 6]) + }) + + afterEach(() => void vi.unstubAllGlobals()) }) describe('bufferToReversed', () => { diff --git a/packages/core/src/utils/condition-variable.test.ts b/packages/core/src/utils/condition-variable.test.ts new file mode 100644 index 00000000..eb0e312a --- /dev/null +++ b/packages/core/src/utils/condition-variable.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { ConditionVariable } from './condition-variable.js' + +describe('ConditionVariable', () => { + it('should correctly unlock execution', async () => { + const cv = new ConditionVariable() + + setTimeout(() => cv.notify(), 10) + + await cv.wait() + + expect(true).toBeTruthy() + }) + + it('should correctly time out', async () => { + const cv = new ConditionVariable() + + await cv.wait(10) + + expect(true).toBeTruthy() + }) + + it('should only unlock once', async () => { + const cv = new ConditionVariable() + + setTimeout(() => { + cv.notify() + cv.notify() + }, 10) + + await cv.wait() + + expect(true).toBeTruthy() + }) +}) diff --git a/packages/core/src/utils/controllable-promise.test.ts b/packages/core/src/utils/controllable-promise.test.ts new file mode 100644 index 00000000..a638de51 --- /dev/null +++ b/packages/core/src/utils/controllable-promise.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { createControllablePromise } from './controllable-promise.js' + +describe('createControllablePromise', () => { + it('should resolve', async () => { + const p = createControllablePromise() + p.resolve(1) + await expect(p).resolves.toBe(1) + }) + + it('should reject', async () => { + const p = createControllablePromise() + p.reject(1) + await expect(p).rejects.toBe(1) + }) +}) diff --git a/packages/core/src/utils/crypto/crypto.test-utils.ts b/packages/core/src/utils/crypto/crypto.test-utils.ts index 6cd5b6ce..e9614f11 100644 --- a/packages/core/src/utils/crypto/crypto.test-utils.ts +++ b/packages/core/src/utils/crypto/crypto.test-utils.ts @@ -1,8 +1,10 @@ import { beforeAll, expect, it } from 'vitest' +import { gzipSync, inflateSync } from 'zlib' import { hexDecodeToBuffer, hexEncode, utf8EncodeToBuffer } from '@mtcute/tl-runtime' import { ICryptoProvider } from './abstract.js' +import { factorizePQSync } from './factorization.js' export function testCryptoProvider(c: ICryptoProvider): void { beforeAll(() => c.initialize?.()) @@ -93,4 +95,46 @@ export function testCryptoProvider(c: ICryptoProvider): void { ), ).to.eq('99706487a1cde613bc6de0b6f24b1c7aa448c8b9c3403e3467a8cad89340f53b') }) + + it( + 'should decompose PQ to prime factors P and Q', + () => { + const testFactorization = (pq: string, p: string, q: string) => { + const [p1, q1] = factorizePQSync(hexDecodeToBuffer(pq)) + expect(hexEncode(p1)).eq(p.toLowerCase()) + expect(hexEncode(q1)).eq(q.toLowerCase()) + } + + // from samples at https://core.telegram.org/mtproto/samples-auth_key + testFactorization('17ED48941A08F981', '494C553B', '53911073') + // random example + testFactorization('14fcab4dfc861f45', '494c5c99', '494c778d') + }, + // since PQ factorization relies on RNG, it may take a while (or may not!) + { timeout: 10000 }, + ) + + it('should correctly gzip', () => { + const data = new Uint8Array(1000).fill(0x42) + + const compressed = c.gzip(data, 100) + + expect(compressed).not.toBeNull() + + const decompressed = inflateSync(compressed!) + + expect(compressed!.length).toBeLessThan(data.length) + // eslint-disable-next-line no-restricted-globals + expect(decompressed).toEqual(Buffer.from(data)) + }) + + it('should correctly gunzip', () => { + const data = new Uint8Array(1000).fill(0x42) + + const compressed = gzipSync(data) + const decompressed = c.gunzip(compressed) + + // eslint-disable-next-line no-restricted-globals + expect(Buffer.from(decompressed)).toEqual(Buffer.from(data)) + }) } diff --git a/packages/core/src/utils/crypto/factorization.test.ts b/packages/core/src/utils/crypto/factorization.test.ts deleted file mode 100644 index 665891f3..00000000 --- a/packages/core/src/utils/crypto/factorization.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { hexDecodeToBuffer, hexEncode } from '@mtcute/tl-runtime' - -import { factorizePQSync } from './factorization.js' - -describe( - 'prime factorization', - function () { - it('should decompose PQ to prime factors P and Q', () => { - const testFactorization = (pq: string, p: string, q: string) => { - const [p1, q1] = factorizePQSync(hexDecodeToBuffer(pq)) - expect(hexEncode(p1)).eq(p.toLowerCase()) - expect(hexEncode(q1)).eq(q.toLowerCase()) - } - - // from samples at https://core.telegram.org/mtproto/samples-auth_key - testFactorization('17ED48941A08F981', '494C553B', '53911073') - // random example - testFactorization('14fcab4dfc861f45', '494c5c99', '494c778d') - }) - }, - { timeout: 10000 }, -) // since PQ factorization relies on RNG, it may take a while (or may not!) diff --git a/packages/core/src/utils/crypto/keys.test.ts b/packages/core/src/utils/crypto/keys.test.ts index a0c9a508..965160b5 100644 --- a/packages/core/src/utils/crypto/keys.test.ts +++ b/packages/core/src/utils/crypto/keys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parsePublicKey } from '../index.js' +import { findKeyByFingerprints, parsePublicKey } from '../index.js' import { NodeCryptoProvider } from './node.js' const crypto = new NodeCryptoProvider() @@ -27,4 +27,23 @@ XGF710w9lwCGNbmNxNYhtIkdqfsEcwR5JwIDAQAB old: false, }) }) + + it('should be able to find a key by its fingerprint', () => { + expect(findKeyByFingerprints(['b25898df208d2603'])).toMatchInlineSnapshot(` + { + "exponent": "010001", + "fingerprint": "b25898df208d2603", + "modulus": "c8c11d635691fac091dd9489aedced2932aa8a0bcefef05fa800892d9b52ed03200865c9e97211cb2ee6c7ae96d3fb0e15aeffd66019b44a08a240cfdd2868a85e1f54d6fa5deaa041f6941ddf302690d61dc476385c2fa655142353cb4e4b59f6e5b6584db76fe8b1370263246c010c93d011014113ebdf987d093f9d37c2be48352d69a1683f8f6e6c2167983c761e3ab169fde5daaa12123fa1beab621e4da5935e9c198f82f35eae583a99386d8110ea6bd1abb0f568759f62694419ea5f69847c43462abef858b4cb5edc84e7b9226cd7bd7e183aa974a712c079dde85b9dc063b8a5c08e8f859c0ee5dcd824c7807f20153361a7f63cfd2a433a1be7f5", + "old": false, + } + `) + }) + + it('should prefer new keys over old', () => { + expect(findKeyByFingerprints(['71e025b6c76033e3', 'b25898df208d2603'])?.fingerprint).toEqual('b25898df208d2603') + }) + + it('should return null if not found', () => { + expect(findKeyByFingerprints(['deadbeefdeadbeef'])).toBeNull() + }) }) diff --git a/packages/core/src/utils/crypto/password.test.ts b/packages/core/src/utils/crypto/password.test.ts new file mode 100644 index 00000000..020b96c6 --- /dev/null +++ b/packages/core/src/utils/crypto/password.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { tl } from '@mtcute/tl' +import { hexDecodeToBuffer, hexEncode, utf8EncodeToBuffer } from '@mtcute/tl-runtime' + +import { computePasswordHash, defaultCryptoProviderFactory } from './index.js' + +// a real-world request from an account with "qwe123" password +const fakeAlgo: tl.RawPasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow = { + _: 'passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow', + salt1: hexDecodeToBuffer('9b3accc457c0d5288e8cff31eb21094048bc11902f6614dbb9afb839ee7641c37619537d8ebe749e'), + salt2: hexDecodeToBuffer('6c619bb0786dc4ed1bf211d23f6e4065'), + g: 3, + p: hexDecodeToBuffer( + 'c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f' + + '48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c37' + + '20fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f64' + + '2477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4' + + 'a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754' + + 'fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4' + + 'e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f' + + '0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b', + ), +} +// const fakeRequest: tl.account.RawPassword = { +// _: 'account.password', +// hasRecovery: false, +// hasSecureValues: false, +// hasPassword: true, +// currentAlgo: fakeAlgo, +// srpB: hexDecodeToBuffer( +// '1476a7b5991d7f028bbee33b3455cad3f2cd0eb3737409fcce92fa7d4cd5c733' + +// 'ec6d2cb3454e587d4c17eda2fd7ef9a57327215f38292cc8bd5dc77d3e1d31cd' + +// 'dae2652f8347c4b0093f7c78242f70e6cc13137ee7acc257a49855a63113db8f' + +// '163992b9101551f3b6f7eb5d196cee3647c359553b1bcbe82ba8933c0fb1ac35' + +// '0243c535b8e634613e1f626ba8a6d141ef957c859e71a117b557c0298bfbb107' + +// 'c91f71f5b4275fded58289aa1e87c612f44b7aa0b5e0de7def4458f58db80019' + +// 'd2e7b181eb66dc270374af2d160dd0c53edd677b2701694d71ea8718c49df6a9' + +// 'dbe2cbae051ffc1986336cd26f11a8ab426dfe0813d7b3f4eedf4e34182ccc3a', +// ), +// srpId: Long.fromBits(-2046015018, 875006452), +// newAlgo: { +// _: 'passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow', +// salt1: hexDecodeToBuffer('9b3accc457c0d528'), +// salt2: hexDecodeToBuffer('6c619bb0786dc4ed1bf211d23f6e4065'), +// g: 3, +// p: hexDecodeToBuffer( +// 'c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f' + +// '48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c37' + +// '20fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f64' + +// '2477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4' + +// 'a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754' + +// 'fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4' + +// 'e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f' + +// '0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b', +// ), +// }, +// newSecureAlgo: { +// _: 'securePasswordKdfAlgoPBKDF2HMACSHA512iter100000', +// salt: hexDecodeToBuffer('fdd59abc0bffb24d'), +// }, +// secureRandom: new Uint8Array(), // unused +// } +const password = utf8EncodeToBuffer('qwe123') + +describe('computePasswordHash', () => { + const crypto = defaultCryptoProviderFactory() + + it('should correctly compute password hash as defined by MTProto', async () => { + const actual = await computePasswordHash(crypto, password, fakeAlgo.salt1, fakeAlgo.salt2) + + expect(hexEncode(actual)).toEqual('750f1fe282965e63ce17b98427b35549fb864465211840f6a7c1f2fb657cc33b') + }) + + // todo: computeNewPasswordHash and computeSrpParams both require predictable random +}) diff --git a/packages/core/src/utils/deque.test.ts b/packages/core/src/utils/deque.test.ts new file mode 100644 index 00000000..62be647f --- /dev/null +++ b/packages/core/src/utils/deque.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' + +import { Deque } from './deque.js' + +describe('Deque', () => { + function setupWrapping() { + const d = new Deque(Infinity, /* capacity: */ 8) + + for (let i = 1; i <= 7; i++) { + d.pushBack(i) + } + + // [1, 2, 3, 4, 5, 6, 7, _] + + d.popFront() + d.popFront() + d.popFront() + + // [_, _, _, 4, 5, 6, 7, _] + + d.pushBack(8) + d.pushBack(9) + d.pushBack(10) + + // [9, 10, _, 4, 5, 6, 7, 8] + return d + } + + it('should push items to the end correctly', () => { + const d = new Deque() + + d.pushBack(1) + d.pushBack(2) + d.pushBack(3) + + expect(d.toArray()).eql([1, 2, 3]) + expect([...d.iter()]).eql([1, 2, 3]) + }) + + it('should push items to the start correctly', () => { + const d = new Deque() + + d.pushFront(1) + d.pushFront(2) + d.pushFront(3) + + expect(d.toArray()).eql([3, 2, 1]) + }) + + it('should pop items from the end correctly', () => { + const d = new Deque() + + d.pushBack(1) + d.pushBack(2) + d.pushBack(3) + + expect(d.popBack()).eql(3) + expect(d.popBack()).eql(2) + expect(d.popBack()).eql(1) + }) + + it('should pop items from the start correctly', () => { + const d = new Deque() + + d.pushFront(1) + d.pushFront(2) + d.pushFront(3) + + expect(d.popFront()).eql(3) + expect(d.popFront()).eql(2) + expect(d.popFront()).eql(1) + }) + + it('should return the correct length', () => { + const d = new Deque() + + d.pushBack(1) + d.pushBack(2) + d.pushBack(3) + + expect(d.length).eql(3) + }) + + it('accessors should correctly wrap around', () => { + const d = setupWrapping() + + expect(d.toArray()).eql([4, 5, 6, 7, 8, 9, 10]) + expect([...d.iter()]).eql([4, 5, 6, 7, 8, 9, 10]) + expect(d.at(6)).eql(10) + }) + + it('should handle maxLength by removing items from the other end', () => { + const d = new Deque(4) + + d.pushBack(1) + d.pushBack(2) + d.pushBack(3) + d.pushBack(4) + + d.pushBack(5) + expect(d.toArray()).eql([2, 3, 4, 5]) + + d.pushFront(6) + expect(d.toArray()).eql([6, 2, 3, 4]) + }) + + it('should correctly resize', () => { + const d = new Deque(Infinity, /* capacity: */ 8) + + for (let i = 0; i <= 16; i++) { + d.pushBack(i) + } + + const expected = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + + expect(d.length).eql(17) + expect(d.toArray()).eql(expected) + expect([...d.iter()]).eql(expected) + expect(d.at(16)).eql(16) + }) + + it('should correctly remove items', () => { + const d = new Deque(Infinity, /* capacity: */ 16) + + d.pushBack(1) + d.pushBack(2) + d.pushBack(3) + d.pushBack(4) + d.pushBack(5) + + d.remove(1) + d.remove(4) + + expect(d.toArray()).eql([2, 3, 5]) + }) + + it('should correctly remove items by predicate (the first matched)', () => { + const d = new Deque(Infinity, /* capacity: */ 4) + + d.pushBack(1) + d.pushBack(2) + d.pushBack(3) + d.pushBack(4) + d.pushBack(5) + + d.removeBy((it) => it >= 2) + + expect(d.toArray()).eql([1, 3, 4, 5]) + }) + + it('should correctly peek at edges', () => { + const d = new Deque() + + d.pushBack(1) + d.pushBack(2) + + expect(d.peekFront()).eql(1) + expect(d.peekBack()).eql(2) + }) + + it('should correctly clear', () => { + const d = new Deque() + + d.pushBack(1) + d.pushBack(2) + + d.clear() + + expect(d.length).eql(0) + expect(d.toArray()).eql([]) + expect([...d.iter()]).eql([]) + }) +}) diff --git a/packages/core/src/utils/deque.ts b/packages/core/src/utils/deque.ts index 99e2262f..10c8205f 100644 --- a/packages/core/src/utils/deque.ts +++ b/packages/core/src/utils/deque.ts @@ -24,14 +24,16 @@ export class Deque { protected _capacity: number constructor( - minCapacity = MIN_INITIAL_CAPACITY, readonly maxLength = Infinity, + minCapacity = maxLength === Infinity ? MIN_INITIAL_CAPACITY : maxLength, ) { let capacity = minCapacity - if (capacity >= MIN_INITIAL_CAPACITY) { + if (capacity < MIN_INITIAL_CAPACITY) { + capacity = MIN_INITIAL_CAPACITY + } + if (capacity !== MIN_INITIAL_CAPACITY) { // Find the best power of two to hold elements. - // >= because array can't be full capacity |= capacity >>> 1 capacity |= capacity >>> 2 capacity |= capacity >>> 4 @@ -135,6 +137,8 @@ export class Deque { toArray(): T[] { const sz = this.length + if (sz === 0) return [] + const arr = new Array(sz) if (this._head < this._tail) { @@ -220,6 +224,7 @@ export class Deque { *iter(): IterableIterator { const sz = this.length + if (sz === 0) return if (this._head < this._tail) { // head to tail diff --git a/packages/core/src/utils/flood-control.ts b/packages/core/src/utils/flood-control.ts deleted file mode 100644 index 1a66ab85..00000000 --- a/packages/core/src/utils/flood-control.ts +++ /dev/null @@ -1,74 +0,0 @@ -// based on https://github.dev/tdlib/td/blob/master/tdutils/td/utils/FloodControlStrict.h - -interface FloodControlLimit { - duration: number - count: number - pos: number -} - -/** - * Flood limiter, based on TDlib - */ -export class FloodControl { - private _wakeupAt = 1 - private _withoutUpdate = 0 - private _events: number[] = [] - // pair: duration, count - private _limits: FloodControlLimit[] = [] - - // no more than count in each duration - addLimit(duration: number, count: number): void { - this._limits.push({ duration, count, pos: 0 }) - this._withoutUpdate = 0 - } - - addEvent(now: number): void { - this._events.push(now) - - if (this._withoutUpdate > 0) { - this._withoutUpdate -= 1 - } else { - this._update(now) - } - } - - clear(): void { - this._events.length = 0 - this._withoutUpdate = 0 - this._wakeupAt = 1 - this._limits.forEach((limit) => (limit.pos = 0)) - } - - get wakeupAt(): number { - return this._wakeupAt - } - - private _update(now: number): void { - let minPos = this._events.length - this._withoutUpdate = Infinity - - this._limits.forEach((limit) => { - if (limit.count < this._events.length - limit.pos) { - limit.pos = this._events.length - limit.count - } - - while (limit.pos < this._events.length && this._events[limit.pos] + limit.duration < now) { - limit.pos += 1 - } - - if (limit.count + limit.pos < this._events.length) { - this._wakeupAt = Math.max(this._wakeupAt, this._events[limit.pos] + limit.duration) - this._withoutUpdate = 0 - } else { - this._withoutUpdate = Math.min(this._withoutUpdate, limit.count + limit.pos - this._events.length - 1) - } - - minPos = Math.min(minPos, limit.pos) - }) - - if (minPos * 2 > this._events.length) { - this._limits.forEach((limit) => (limit.pos -= minPos)) - this._events.splice(0, minPos) - } - } -} diff --git a/packages/core/src/utils/function-utils.test.ts b/packages/core/src/utils/function-utils.test.ts new file mode 100644 index 00000000..0492d8fd --- /dev/null +++ b/packages/core/src/utils/function-utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { ConditionVariable } from './condition-variable.js' +import { throttle } from './function-utils.js' + +describe('throttle', () => { + it('should throttle', async () => { + const cv = new ConditionVariable() + let count = 0 + + const func = () => { + count++ + cv.notify() + } + + const throttled = throttle(func, 10) + + throttled() + throttled() + throttled() + throttled() + + await cv.wait() + + expect(count).eq(1) + }) +}) diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 77254166..3cffd965 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -7,7 +7,6 @@ export * from './crypto/index.js' export * from './default-dcs.js' export * from './deque.js' export * from './early-timer.js' -export * from './flood-control.js' export * from './function-utils.js' export * from './linked-list.js' export * from './links/index.js' diff --git a/packages/core/src/utils/linked-list.test.ts b/packages/core/src/utils/linked-list.test.ts new file mode 100644 index 00000000..378ed14f --- /dev/null +++ b/packages/core/src/utils/linked-list.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { SortedLinkedList } from './linked-list.js' + +describe('SortedLinkedList', () => { + const ascendingComparator = (a: number, b: number) => a - b + + it('should add items in the correct order', () => { + const list = new SortedLinkedList(ascendingComparator) + + list.add(3) + list.add(1) + list.add(2) + + expect(list.popFront()).eq(1) + expect(list.popFront()).eq(2) + expect(list.popFront()).eq(3) + }) + + it('should allow deleting nodes using _remove', () => { + const list = new SortedLinkedList(ascendingComparator) + + list.add(3) + list.add(1) + list.add(2) + + const node = list._first!.n! + + list._remove(node) + + expect(list.popFront()).eq(1) + expect(list.popFront()).eq(3) + }) + + it('should clear', () => { + const list = new SortedLinkedList(ascendingComparator) + + list.add(3) + list.add(1) + list.add(2) + + list.clear() + + expect(list.length).eq(0) + expect(list.popFront()).eq(undefined) + }) +}) diff --git a/packages/core/src/utils/logger.test.ts b/packages/core/src/utils/logger.test.ts new file mode 100644 index 00000000..c1a4813e --- /dev/null +++ b/packages/core/src/utils/logger.test.ts @@ -0,0 +1,173 @@ +import Long from 'long' +import { describe, expect, it, vi } from 'vitest' + +import { LogManager } from './logger.js' + +describe('logger', () => { + const createManager = () => { + const mgr = new LogManager() + + const spy = vi.fn>() + mgr.handler = spy + + return [mgr, spy] as const + } + + it('should only log messages below the set level', () => { + const [mgr, spy] = createManager() + + mgr.level = LogManager.INFO + mgr.error('test error') + mgr.warn('test warn') + mgr.info('test info') + mgr.debug('test debug') + mgr.verbose('test verbose') + + expect(spy).toHaveBeenCalledTimes(3) + expect(spy).toHaveBeenCalledWith(3, 1, 'base', 'test error', []) + expect(spy).toHaveBeenCalledWith(3, 2, 'base', 'test warn', []) + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test info', []) + }) + + it('should create child loggers', () => { + const [mgr, spy] = createManager() + + mgr.create('child').info('test info') + + expect(spy).toHaveBeenCalledWith(0, 3, 'child', 'test info', []) + }) + + it('should apply filtering by tags', () => { + const [mgr, spy] = createManager() + + const test1 = mgr.create('test1') + const test2 = mgr.create('test2') + + mgr.filter((tag) => tag === 'test1') + + test1.info('test1 info') + test2.info('test2 info') + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(5, 3, 'test1', 'test1 info', []) + }) + + it('should recursively add prefixes', () => { + const [mgr, spy] = createManager() + + const test = mgr.create('test1') + const test2 = test.create('test2') + + test.prefix = '[TEST] ' + test2.prefix = '[TEST2] ' + + test2.create('test3').info('test info') + + expect(spy).toHaveBeenCalledWith(1, 3, 'test3', '[TEST] [TEST2] test info', []) + }) + + describe('formatting', () => { + it('should pass generic format strings through', () => { + const [mgr, spy] = createManager() + + mgr.info('test %s', 'info') + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test %s', ['info']) + }) + + describe('%h', () => { + it('should format buffers as hex strings', () => { + const [mgr, spy] = createManager() + + mgr.info('test %h', new Uint8Array([0x01, 0x02, 0x03])) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test 010203', []) + }) + + it('should format numbers as hex', () => { + const [mgr, spy] = createManager() + + mgr.info('test %h', 0x010203) + mgr.info('test bigint %h', 0x010203n) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test 10203', []) + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test bigint 10203', []) + }) + + it('should format everything else as normal strings', () => { + const [mgr, spy] = createManager() + + mgr.info('test %h', {}) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test [object Object]', []) + }) + }) + + describe('%b', () => { + it('should format booleans as strings', () => { + const [mgr, spy] = createManager() + + mgr.info('test %b', true) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test true', []) + }) + + it('should coerce everything to boolean', () => { + const [mgr, spy] = createManager() + + mgr.info('test %b', 123) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test true', []) + }) + }) + + describe('%j', () => { + it('should format objects as JSON', () => { + const [mgr, spy] = createManager() + + mgr.info('test %j', { a: 1 }) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test {"a":1}', []) + }) + + it('should format buffers inside as hex strings', () => { + const [mgr, spy] = createManager() + + // eslint-disable-next-line no-restricted-globals + mgr.info('test %j', { a: Buffer.from([1, 2, 3]) }) + mgr.info('test Uint8Array %j', { a: new Uint8Array([1, 2, 3]) }) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test {"a":"010203"}', []) + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test Uint8Array {"a":"010203"}', []) + }) + + it('should trim long buffers', () => { + const [mgr, spy] = createManager() + + const _150hexZeros = Array(150).fill('00').join('') + + mgr.info('test %j', { a: new Uint8Array(300) }) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', `test {"a":"${_150hexZeros}..."}`, []) + }) + + it('should have %J variation accepting iterators', () => { + const [mgr, spy] = createManager() + + mgr.info('test %J', new Set([1, 2, 3])) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test [1,2,3]', []) + }) + }) + + describe('%l', () => { + it('should format Longs as strings', () => { + const [mgr, spy] = createManager() + + mgr.info('test %l', Long.fromInt(123)) + + expect(spy).toHaveBeenCalledWith(3, 3, 'base', 'test 123', []) + }) + }) + }) +}) diff --git a/packages/core/src/utils/logger.ts b/packages/core/src/utils/logger.ts index 431855d9..b42fb78a 100644 --- a/packages/core/src/utils/logger.ts +++ b/packages/core/src/utils/logger.ts @@ -4,6 +4,7 @@ import { _defaultLoggingHandler } from './platform/logging.js' let defaultLogLevel = 3 +/* c8 ignore start */ if (typeof process !== 'undefined') { const envLogLevel = parseInt(process.env.MTCUTE_LOG_LEVEL ?? '') @@ -17,6 +18,7 @@ if (typeof process !== 'undefined') { defaultLogLevel = localLogLevel } } +/* c8 ignore end */ const FORMATTER_RE = /%[a-zA-Z]/g @@ -79,7 +81,7 @@ export class Logger { if (m === '%h') { if (ArrayBuffer.isView(val)) return hexEncode(val as Uint8Array) - if (typeof val === 'number') return val.toString(16) + if (typeof val === 'number' || typeof val === 'bigint') return val.toString(16) return String(val) } @@ -165,10 +167,6 @@ export class LogManager extends Logger { level = defaultLogLevel handler = _defaultLoggingHandler - disable(): void { - this.level = 0 - } - /** * Create a {@link Logger} with the given tag * diff --git a/packages/core/src/utils/long-utils.test.ts b/packages/core/src/utils/long-utils.test.ts new file mode 100644 index 00000000..f5d0784d --- /dev/null +++ b/packages/core/src/utils/long-utils.test.ts @@ -0,0 +1,221 @@ +import Long from 'long' +import { describe, expect, it } from 'vitest' + +import { + longFromBuffer, + longFromFastString, + LongMap, + LongSet, + longToFastString, + randomLong, + removeFromLongArray, +} from './long-utils.js' + +describe('randomLong', () => { + it('should return a random Long', () => { + const long = randomLong() + const long2 = randomLong() + + expect(long).toBeInstanceOf(Long) + expect(long.eq(long2)).toBeFalsy() + }) +}) + +describe('longFromBuffer', () => { + it('should correctly read LE longs', () => { + expect(longFromBuffer(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]))).toEqual(Long.fromInt(0)) + expect(longFromBuffer(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual(Long.fromBits(0x04030201, 0x08070605)) + }) + + it('should correctly read BE longs', () => { + expect(longFromBuffer(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]), false, false)).toEqual(Long.fromInt(0)) + expect(longFromBuffer(new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1]), false, false)).toEqual( + Long.fromBits(0x04030201, 0x08070605), + ) + }) +}) + +describe('removeFromLongArray', () => { + it('should remove a Long from an array', () => { + const arr = [Long.fromInt(1), Long.fromInt(2), Long.fromInt(3)] + + expect(removeFromLongArray(arr, Long.fromInt(2))).toBeTruthy() + expect(arr).toEqual([Long.fromInt(1), Long.fromInt(3)]) + }) + + it('should return false if the Long was not found', () => { + const arr = [Long.fromInt(1), Long.fromInt(2), Long.fromInt(3)] + + expect(removeFromLongArray(arr, Long.fromInt(4))).toBeFalsy() + expect(arr).toEqual([Long.fromInt(1), Long.fromInt(2), Long.fromInt(3)]) + }) + + it('should only remove one matching element', () => { + const arr = [Long.fromInt(1), Long.fromInt(2), Long.fromInt(2), Long.fromInt(3)] + + expect(removeFromLongArray(arr, Long.fromInt(2))).toBeTruthy() + expect(arr).toEqual([Long.fromInt(1), Long.fromInt(2), Long.fromInt(3)]) + }) +}) + +describe('longToFastString', () => { + it('should correctly serialize a Long', () => { + expect(longToFastString(Long.fromInt(0))).toEqual('0|0') + expect(longToFastString(Long.fromBits(123, 456))).toEqual('123|456') + }) + + it('should work with negative numbers', () => { + expect(longToFastString(Long.fromInt(-1))).toEqual('-1|-1') + expect(longToFastString(Long.fromBits(-123, -456))).toEqual('-123|-456') + }) +}) + +describe('longFromFastString', () => { + it('should correctly deserialize a Long', () => { + expect(longFromFastString('0|0')).toEqual(Long.fromInt(0)) + expect(longFromFastString('123|456')).toEqual(Long.fromBits(123, 456)) + }) + + it('should work with negative numbers', () => { + expect(longFromFastString('-1|-1')).toEqual(Long.fromInt(-1)) + expect(longFromFastString('-123|-456')).toEqual(Long.fromBits(-123, -456)) + }) + + it('should throw on invalid strings', () => { + expect(() => longFromFastString('0')).toThrow() + expect(() => longFromFastString('0|0|0')).toThrow() + expect(() => longFromFastString('abc|def')).toThrow() + }) +}) + +describe('LongMap', () => { + it('should set and get values', () => { + const map = new LongMap() + + map.set(Long.fromInt(123), 'test') + + expect(map.get(Long.fromInt(123))).toEqual('test') + }) + + it('should return undefined for non-existing keys', () => { + const map = new LongMap() + + expect(map.get(Long.fromInt(123))).toEqual(undefined) + }) + + it('should check for existing keys', () => { + const map = new LongMap() + + map.set(Long.fromInt(123), 'test') + + expect(map.has(Long.fromInt(123))).toBeTruthy() + expect(map.has(Long.fromInt(456))).toBeFalsy() + }) + + it('should delete keys', () => { + const map = new LongMap() + + map.set(Long.fromInt(123), 'test') + map.delete(Long.fromInt(123)) + + expect(map.has(Long.fromInt(123))).toBeFalsy() + }) + + it('should iterate over keys', () => { + const map = new LongMap() + + map.set(Long.fromInt(123), 'test') + map.set(Long.fromInt(456), 'test2') + + expect([...map.keys()]).toEqual([Long.fromInt(123), Long.fromInt(456)]) + }) + + it('should iterate over values', () => { + const map = new LongMap() + + map.set(Long.fromInt(123), 'test') + map.set(Long.fromInt(456), 'test2') + + expect([...map.values()]).toEqual(['test', 'test2']) + }) + + it('should clear', () => { + const map = new LongMap() + + map.set(Long.fromInt(123), 'test') + map.set(Long.fromInt(456), 'test2') + + map.clear() + + expect(map.has(Long.fromInt(123))).toBeFalsy() + expect(map.has(Long.fromInt(456))).toBeFalsy() + }) + + it('should return the size', () => { + const map = new LongMap() + + map.set(Long.fromInt(123), 'test') + map.set(Long.fromInt(456), 'test2') + + expect(map.size()).toEqual(2) + }) +}) + +describe('LongSet', () => { + it('should add and check for values', () => { + const set = new LongSet() + + set.add(Long.fromInt(123)) + + expect(set.has(Long.fromInt(123))).toBeTruthy() + }) + + it('should remove values', () => { + const set = new LongSet() + + set.add(Long.fromInt(123)) + set.add(Long.fromInt(456)) + set.delete(Long.fromInt(123)) + + expect(set.has(Long.fromInt(123))).toBeFalsy() + }) + + it('should return the size', () => { + const set = new LongSet() + + set.add(Long.fromInt(123)) + set.add(Long.fromInt(456)) + + expect(set.size).toEqual(2) + }) + + it('should clear', () => { + const set = new LongSet() + + set.add(Long.fromInt(123)) + set.add(Long.fromInt(456)) + + set.clear() + + expect(set.has(Long.fromInt(123))).toBeFalsy() + expect(set.has(Long.fromInt(456))).toBeFalsy() + }) + + it('should return the size', () => { + const set = new LongSet() + + set.add(Long.fromInt(123)) + set.add(Long.fromInt(456)) + + expect(set.size).toEqual(2) + }) + + it('should convert to array', () => { + const set = new LongSet() + + set.add(Long.fromInt(123)) + set.add(Long.fromInt(456)) + + expect(set.toArray()).toEqual([Long.fromInt(123), Long.fromInt(456)]) + }) +}) diff --git a/packages/core/src/utils/lru-map.test.ts b/packages/core/src/utils/lru-map.test.ts index 778cfddd..8a8859b7 100644 --- a/packages/core/src/utils/lru-map.test.ts +++ b/packages/core/src/utils/lru-map.test.ts @@ -3,42 +3,111 @@ import { describe, expect, it } from 'vitest' import { LruMap } from './lru-map.js' describe('LruMap', () => { - it('Map backend', () => { + it('should maintain maximum size by removing oldest added', () => { const lru = new LruMap(2) lru.set('first', 1) - expect(lru.has('first')).true - expect(lru.has('second')).false - expect(lru.get('first')).eq(1) - - lru.set('first', 42) - expect(lru.has('first')).true - expect(lru.has('second')).false - expect(lru.get('first')).eq(42) - lru.set('second', 2) - expect(lru.has('first')).true - expect(lru.has('second')).true - expect(lru.get('first')).eq(42) - expect(lru.get('second')).eq(2) - lru.set('third', 3) - expect(lru.has('first')).false - expect(lru.has('second')).true - expect(lru.has('third')).true - expect(lru.get('first')).eq(undefined) - expect(lru.get('second')).eq(2) - expect(lru.get('third')).eq(3) - lru.get('second') // update lru so that last = third - lru.set('fourth', 4) - expect(lru.has('first')).false - expect(lru.has('second')).true - expect(lru.has('third')).false - expect(lru.has('fourth')).true - expect(lru.get('first')).eq(undefined) - expect(lru.get('second')).eq(2) - expect(lru.get('third')).eq(undefined) - expect(lru.get('fourth')).eq(4) + expect(lru.has('first')).toBeFalsy() }) + + it('should update the last added item', () => { + const lru = new LruMap(2) + + lru.set('first', 1) + lru.set('second', 2) + lru.set('first', 42) + lru.set('third', 3) + + expect(lru.get('first')).toEqual(42) + expect(lru.has('second')).toBeFalsy() + }) + + it('should update the last used item', () => { + const lru = new LruMap(2) + + lru.set('first', 1) + lru.set('second', 2) + lru.get('first') + lru.set('third', 3) + lru.get('third') + + expect(lru.get('first')).toEqual(1) + expect(lru.has('second')).toBeFalsy() + }) + + it('should allow deleting items', () => { + const lru = new LruMap(2) + + lru.set('first', 1) + lru.set('second', 2) + lru.set('third', 3) // first is now deleted + lru.delete('second') + + expect(lru.has('first')).toBeFalsy() + expect(lru.has('second')).toBeFalsy() + expect(lru.has('third')).toBeTruthy() + }) + + it('should handle deleting all items', () => { + const lru = new LruMap(2) + + lru.set('first', 1) + lru.set('second', 2) + lru.delete('second') + lru.delete('first') + + expect(lru.has('first')).toBeFalsy() + expect(lru.has('second')).toBeFalsy() + }) + + it('should return undefined for non-existing items', () => { + const lru = new LruMap(2) + + lru.set('first', 1) + lru.set('second', 2) + + expect(lru.get('third')).toEqual(undefined) + }) + + // it('Map backend', () => { + // const lru = new LruMap(2) + // + // lru.set('first', 1) + // expect(lru.has('first')).true + // expect(lru.has('second')).false + // expect(lru.get('first')).eq(1) + // + // lru.set('first', 42) + // expect(lru.has('first')).true + // expect(lru.has('second')).false + // expect(lru.get('first')).eq(42) + // + // lru.set('second', 2) + // expect(lru.has('first')).true + // expect(lru.has('second')).true + // expect(lru.get('first')).eq(42) + // expect(lru.get('second')).eq(2) + // + // lru.set('third', 3) + // expect(lru.has('first')).false + // expect(lru.has('second')).true + // expect(lru.has('third')).true + // expect(lru.get('first')).eq(undefined) + // expect(lru.get('second')).eq(2) + // expect(lru.get('third')).eq(3) + // + // lru.get('second') // update lru so that last = third + // lru.set('fourth', 4) + // expect(lru.has('first')).false + // expect(lru.has('second')).true + // expect(lru.has('third')).false + // expect(lru.has('fourth')).true + // expect(lru.get('first')).eq(undefined) + // expect(lru.get('second')).eq(2) + // expect(lru.get('third')).eq(undefined) + // expect(lru.get('fourth')).eq(4) + // }) }) diff --git a/packages/core/src/utils/peer-utils.test.ts b/packages/core/src/utils/peer-utils.test.ts new file mode 100644 index 00000000..5816ad22 --- /dev/null +++ b/packages/core/src/utils/peer-utils.test.ts @@ -0,0 +1,153 @@ +import Long from 'long' +import { describe, expect, it } from 'vitest' + +import { createStub } from '@mtcute/test' + +import { + getAllPeersFrom, + getBarePeerId, + getBasicPeerType, + getMarkedPeerId, + markedPeerIdToBare, + toggleChannelIdMark, +} from './peer-utils.js' + +const SOME_CHANNEL_ID = 1183945448 +const SOME_CHANNEL_ID_MARKED = -1001183945448 + +describe('toggleChannelIdMark', () => { + it('should turn marked channel id into bare', () => { + expect(toggleChannelIdMark(SOME_CHANNEL_ID_MARKED)).toEqual(SOME_CHANNEL_ID) + }) + + it('should turn bare channel id into marked', () => { + expect(toggleChannelIdMark(SOME_CHANNEL_ID)).toEqual(SOME_CHANNEL_ID_MARKED) + }) +}) + +describe('getBarePeerId', () => { + it('should return bare peer id from Peer', () => { + expect(getBarePeerId({ _: 'peerUser', userId: 123 })).toEqual(123) + expect(getBarePeerId({ _: 'peerChat', chatId: 456 })).toEqual(456) + expect(getBarePeerId({ _: 'peerChannel', channelId: 789 })).toEqual(789) + }) +}) + +describe('getMarkedPeerId', () => { + it('should return marked peer id from bare and type', () => { + expect(getMarkedPeerId(123, 'user')).toEqual(123) + expect(getMarkedPeerId(456, 'chat')).toEqual(-456) + expect(getMarkedPeerId(SOME_CHANNEL_ID, 'channel')).toEqual(SOME_CHANNEL_ID_MARKED) + }) + + it('should throw on invalid type', () => { + // eslint-disable-next-line + expect(() => getMarkedPeerId(123, 'invalid' as any)).toThrow() + }) + + it('should return marked peer id from Peer', () => { + expect(getMarkedPeerId({ _: 'peerUser', userId: 123 })).toEqual(123) + expect(getMarkedPeerId({ _: 'peerChat', chatId: 456 })).toEqual(-456) + expect(getMarkedPeerId({ _: 'peerChannel', channelId: SOME_CHANNEL_ID })).toEqual(SOME_CHANNEL_ID_MARKED) + }) + + it('should return marked peer id from Input*', () => { + expect(getMarkedPeerId({ _: 'inputPeerUser', userId: 123, accessHash: Long.ZERO })).toEqual(123) + expect(getMarkedPeerId({ _: 'inputUser', userId: 123, accessHash: Long.ZERO })).toEqual(123) + expect(getMarkedPeerId({ _: 'inputPeerChat', chatId: 456 })).toEqual(-456) + expect(getMarkedPeerId({ _: 'inputPeerChannel', channelId: SOME_CHANNEL_ID, accessHash: Long.ZERO })).toEqual( + SOME_CHANNEL_ID_MARKED, + ) + expect(getMarkedPeerId({ _: 'inputChannel', channelId: SOME_CHANNEL_ID, accessHash: Long.ZERO })).toEqual( + SOME_CHANNEL_ID_MARKED, + ) + }) + + it('should throw if peer does not have id', () => { + expect(() => getMarkedPeerId({ _: 'inputPeerSelf' })).toThrow() + }) +}) + +describe('getBasicPeerType', () => { + it('should return basic peer type from Peer', () => { + expect(getBasicPeerType({ _: 'peerUser', userId: 123 })).toEqual('user') + expect(getBasicPeerType({ _: 'peerChat', chatId: 456 })).toEqual('chat') + expect(getBasicPeerType({ _: 'peerChannel', channelId: SOME_CHANNEL_ID })).toEqual('channel') + }) + + it('should return basic peer type from marked id', () => { + expect(getBasicPeerType(123)).toEqual('user') + expect(getBasicPeerType(-456)).toEqual('chat') + expect(getBasicPeerType(SOME_CHANNEL_ID_MARKED)).toEqual('channel') + }) + + it('should throw for invalid marked ids', () => { + expect(() => getBasicPeerType(0)).toThrow('Invalid marked peer id') + + // secret chats are not supported yet + expect(() => getBasicPeerType(-1997852516400)).toThrow('Secret chats are not supported') + }) +}) + +describe('markedPeerIdToBare', () => { + it('should return bare peer id from marked id', () => { + expect(markedPeerIdToBare(123)).toEqual(123) + expect(markedPeerIdToBare(-456)).toEqual(456) + expect(markedPeerIdToBare(SOME_CHANNEL_ID_MARKED)).toEqual(SOME_CHANNEL_ID) + }) +}) + +describe('getAllPeersFrom', () => { + const stubUser1 = createStub('user', { id: 123 }) + const stubUser2 = createStub('user', { id: 456 }) + const stubChat = createStub('chat', { id: 789 }) + const stubChannel = createStub('channel', { id: 101112 }) + + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + it('should find all peers from objects containing users/chats fields', () => { + expect([...getAllPeersFrom({ users: [], chats: [] } as any)]).toEqual([]) + expect([ + ...getAllPeersFrom({ + users: [stubUser1, stubUser2], + chats: [stubChat, stubChannel], + } as any), + ]).toEqual([stubUser1, stubUser2, stubChat, stubChannel]) + }) + + it('should extract peers from objects containing user/chat fields', () => { + expect([ + ...getAllPeersFrom({ + user: stubUser2, + chat: stubChat, + channel: stubChannel, + } as any), + ]).toEqual([stubUser2, stubChat, stubChannel]) + }) + + it('should work for arrays', () => { + expect([...getAllPeersFrom([{ user: stubUser1 }, { user: stubUser2 }] as any)]).toEqual([stubUser1, stubUser2]) + }) + + it('should work for peer objects directly', () => { + expect([...getAllPeersFrom(stubUser1)]).toEqual([stubUser1]) + }) + + it('should ignore *Empty', () => { + expect([ + ...getAllPeersFrom({ + users: [createStub('userEmpty')], + chats: [createStub('chatEmpty')], + } as any), + ]).toEqual([]) + expect([...getAllPeersFrom({ user: createStub('userEmpty'), chat: createStub('chatEmpty') } as any)]).toEqual( + [], + ) + expect([...getAllPeersFrom([createStub('userEmpty'), createStub('chatEmpty')])]).toEqual([]) + }) + + it('should correctly handle users/chats fields of type number[]', () => { + expect([...getAllPeersFrom({ users: [123, 456], chats: [123, 456] } as any)]).toEqual([]) + }) + + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ +}) diff --git a/packages/core/src/utils/peer-utils.ts b/packages/core/src/utils/peer-utils.ts index e0fbc111..7b0d46f0 100644 --- a/packages/core/src/utils/peer-utils.ts +++ b/packages/core/src/utils/peer-utils.ts @@ -72,14 +72,18 @@ export function getMarkedPeerId( switch (peer._) { case 'peerUser': case 'inputPeerUser': + case 'inputPeerUserFromMessage': case 'inputUser': + case 'inputUserFromMessage': return peer.userId case 'peerChat': case 'inputPeerChat': return -peer.chatId case 'peerChannel': case 'inputPeerChannel': + case 'inputPeerChannelFromMessage': case 'inputChannel': + case 'inputChannelFromMessage': return ZERO_CHANNEL_ID - peer.channelId } @@ -110,7 +114,7 @@ export function getBasicPeerType(peer: tl.TypePeer | number): BasicPeerType { return 'channel' } - if (MAX_SECRET_CHAT_ID <= peer && peer !== ZERO_SECRET_CHAT_ID) { + if (MAX_SECRET_CHAT_ID >= peer && peer !== ZERO_SECRET_CHAT_ID) { // return 'secret' throw new MtUnsupportedError('Secret chats are not supported') } diff --git a/packages/core/src/utils/sorted-array.test.ts b/packages/core/src/utils/sorted-array.test.ts new file mode 100644 index 00000000..586377e7 --- /dev/null +++ b/packages/core/src/utils/sorted-array.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { SortedArray } from './sorted-array.js' + +describe('SortedArray', () => { + const ascendingComparator = (a: number, b: number) => a - b + + it('should insert items in accordance with comparator', () => { + const arr = new SortedArray([], ascendingComparator) + + arr.insert(1) + arr.insert(2) + arr.insert(5) + arr.insert(3) + arr.insert(4) + + expect(arr.raw).toEqual([1, 2, 3, 4, 5]) + }) + + it('should insert arrays', () => { + const arr = new SortedArray([], ascendingComparator) + + arr.insert([1, 2, 5, 3, 4]) + + expect(arr.raw).toEqual([1, 2, 3, 4, 5]) + }) + + it('should return length', () => { + const arr = new SortedArray([], ascendingComparator) + + arr.insert(1) + arr.insert(2) + arr.insert(5) + + expect(arr.length).toEqual(3) + }) + + it('should return index of the item', () => { + const arr = new SortedArray([], ascendingComparator) + + arr.insert(1) + arr.insert(2) + arr.insert(5) + + expect(arr.index(1)).toEqual(0) + expect(arr.index(2)).toEqual(1) + expect(arr.index(5)).toEqual(2) + expect(arr.index(10)).toEqual(-1) + }) + + it('should return closest index for an item not in the array', () => { + const arr = new SortedArray([], ascendingComparator) + + arr.insert(1) + arr.insert(2) + arr.insert(5) + + expect(arr.index(3, true)).toEqual(2) + expect(arr.index(4, true)).toEqual(2) // still 2, becuse left-hand side + expect(arr.index(6, true)).toEqual(3) + }) + + it('should remove items by value', () => { + const arr = new SortedArray([], ascendingComparator) + + arr.insert(1) + arr.insert(2) + arr.insert(5) + arr.remove(2) + + expect(arr.raw).toEqual([1, 5]) + }) + + it('should check if item is in array', () => { + const arr = new SortedArray([], ascendingComparator) + + arr.insert(1) + arr.insert(2) + arr.insert(5) + + expect(arr.includes(1)).toBeTruthy() + expect(arr.includes(2)).toBeTruthy() + expect(arr.includes(5)).toBeTruthy() + expect(arr.includes(10)).toBeFalsy() + }) +}) diff --git a/packages/core/src/utils/string-session.test.ts b/packages/core/src/utils/string-session.test.ts new file mode 100644 index 00000000..4036822d --- /dev/null +++ b/packages/core/src/utils/string-session.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' + +import { createStub } from '@mtcute/test' +import { __tlReaderMap } from '@mtcute/tl/binary/reader.js' +import { __tlWriterMap } from '@mtcute/tl/binary/writer.js' + +import { defaultProductionDc } from './default-dcs.js' +import { readStringSession, writeStringSession } from './string-session.js' + +const stubAuthKey = new Uint8Array(32) +const stubDcs = { + main: createStub('dcOption', defaultProductionDc.main), + media: createStub('dcOption', defaultProductionDc.media), +} +const stubDcsSameMedia = { + main: stubDcs.main, + media: stubDcs.main, +} + +describe('writeStringSession', () => { + it('should write production string session without user', () => { + expect( + writeStringSession(__tlWriterMap, { + version: 2, + testMode: false, + primaryDcs: stubDcs, + authKey: stubAuthKey, + }), + ).toMatchInlineSnapshot( + '"AgQAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAAA2htxgCAAAAAgAAAA8xNDkuMTU0LjE2Ny4yMjK7AQAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', + ) + }) + it('should write production string session without user with same dc for media', () => { + expect( + writeStringSession(__tlWriterMap, { + version: 2, + testMode: false, + primaryDcs: stubDcsSameMedia, + authKey: stubAuthKey, + }), + ).toMatchInlineSnapshot( + '"AgAAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', + ) + }) + + it('should write production string session with user', () => { + expect( + writeStringSession(__tlWriterMap, { + version: 2, + testMode: false, + primaryDcs: stubDcs, + authKey: stubAuthKey, + self: { userId: 12345, isBot: false }, + }), + ).toMatchInlineSnapshot( + '"AgUAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAAA2htxgCAAAAAgAAAA8xNDkuMTU0LjE2Ny4yMjK7AQAAOTAAAAAAAAA3l3m8IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', + ) + }) + + it('should write test dc string session with user', () => { + expect( + writeStringSession(__tlWriterMap, { + version: 2, + testMode: true, + primaryDcs: stubDcs, + authKey: stubAuthKey, + self: { userId: 12345, isBot: false }, + }), + ).toMatchInlineSnapshot( + '"AgcAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAAA2htxgCAAAAAgAAAA8xNDkuMTU0LjE2Ny4yMjK7AQAAOTAAAAAAAAA3l3m8IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', + ) + }) +}) + +describe('readStringSession', () => { + describe('v2', () => { + it('should read production string session without user', () => { + expect( + readStringSession( + __tlReaderMap, + 'AgQAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAAA2htxgCAAAAAgAAAA8xNDkuMTU0LjE2Ny4yMjK7AQAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + ).toEqual({ + version: 2, + testMode: false, + primaryDcs: stubDcs, + authKey: stubAuthKey, + self: null, + }) + }) + + it('should read production string session without user with same dc for media', () => { + expect( + readStringSession( + __tlReaderMap, + 'AgAAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + ).toEqual({ + version: 2, + testMode: false, + primaryDcs: stubDcsSameMedia, + authKey: stubAuthKey, + self: null, + }) + }) + + it('should read production string session with user', () => { + expect( + readStringSession( + __tlReaderMap, + 'AgUAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAAA2htxgCAAAAAgAAAA8xNDkuMTU0LjE2Ny4yMjK7AQAAOTAAAAAAAAA3l3m8IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + ).toEqual({ + version: 2, + testMode: false, + primaryDcs: stubDcs, + authKey: stubAuthKey, + self: { userId: 12345, isBot: false }, + }) + }) + + it('should read test dc string session with user', () => { + expect( + readStringSession( + __tlReaderMap, + 'AgcAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAAA2htxgCAAAAAgAAAA8xNDkuMTU0LjE2Ny4yMjK7AQAAOTAAAAAAAAA3l3m8IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + ).toEqual({ + version: 2, + testMode: true, + primaryDcs: stubDcs, + authKey: stubAuthKey, + self: { userId: 12345, isBot: false }, + }) + }) + }) + + describe('v1', () => { + it('should read string session with user', () => { + expect( + readStringSession( + __tlReaderMap, + 'AQEAAAANobcYAAAAAAIAAAAOMTQ5LjE1NC4xNjcuNTAAuwEAADkwAAAAAAAAN5d5vCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + ).toEqual({ + version: 1, + testMode: false, + // v1 didn't have separate media dc + primaryDcs: stubDcsSameMedia, + authKey: stubAuthKey, + self: { userId: 12345, isBot: false }, + }) + }) + }) +}) diff --git a/packages/core/src/utils/string-session.ts b/packages/core/src/utils/string-session.ts index cf15a21f..e4d7d89b 100644 --- a/packages/core/src/utils/string-session.ts +++ b/packages/core/src/utils/string-session.ts @@ -41,11 +41,14 @@ export function writeStringSession(writerMap: TlWriterMap, data: StringSessionDa writer.uint8View[0] = version writer.pos += 1 + if (version >= 2 && data.primaryDcs.media !== data.primaryDcs.main) { + flags |= 4 + } + writer.int(flags) writer.object(data.primaryDcs.main) if (version >= 2 && data.primaryDcs.media !== data.primaryDcs.main) { - flags |= 4 writer.object(data.primaryDcs.media) } @@ -97,7 +100,7 @@ export function readStringSession(readerMap: TlReaderMap, data: string): StringS const key = reader.bytes() return { - version: 1, + version, testMode, primaryDcs: { main: primaryDc, diff --git a/packages/core/src/utils/tl-json.test.ts b/packages/core/src/utils/tl-json.test.ts new file mode 100644 index 00000000..b2c90a5b --- /dev/null +++ b/packages/core/src/utils/tl-json.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' + +import { jsonToTlJson, tlJsonToJson } from './tl-json.js' + +describe('jsonToTlJson', () => { + it('should correctly handle null/undefined', () => { + expect(jsonToTlJson(null)).toEqual({ _: 'jsonNull' }) + expect(jsonToTlJson(undefined)).toEqual({ _: 'jsonNull' }) + }) + + it('should correctly handle booleans', () => { + expect(jsonToTlJson(true)).toEqual({ _: 'jsonBool', value: true }) + expect(jsonToTlJson(false)).toEqual({ _: 'jsonBool', value: false }) + }) + + it('should correctly handle numbers', () => { + expect(jsonToTlJson(123)).toEqual({ _: 'jsonNumber', value: 123 }) + expect(jsonToTlJson(0)).toEqual({ _: 'jsonNumber', value: 0 }) + expect(jsonToTlJson(-123)).toEqual({ _: 'jsonNumber', value: -123 }) + }) + + it('should correctly handle strings', () => { + expect(jsonToTlJson('hello')).toEqual({ _: 'jsonString', value: 'hello' }) + expect(jsonToTlJson('')).toEqual({ _: 'jsonString', value: '' }) + }) + + it('should correcly handle arrays', () => { + expect(jsonToTlJson([1, 2, 3])).toEqual({ + _: 'jsonArray', + value: [ + { _: 'jsonNumber', value: 1 }, + { _: 'jsonNumber', value: 2 }, + { _: 'jsonNumber', value: 3 }, + ], + }) + }) + + it('should correctly handle objects', () => { + expect(jsonToTlJson({ a: 1, b: 2 })).toEqual({ + _: 'jsonObject', + value: [ + { _: 'jsonObjectValue', key: 'a', value: { _: 'jsonNumber', value: 1 } }, + { _: 'jsonObjectValue', key: 'b', value: { _: 'jsonNumber', value: 2 } }, + ], + }) + }) + + it('should error on unsupported types', () => { + expect(() => jsonToTlJson(Symbol('test'))).toThrow() + }) +}) + +describe('tlJsonToJson', () => { + it('should correctly handle null/undefined', () => { + expect(tlJsonToJson({ _: 'jsonNull' })).toEqual(null) + }) + + it('should correctly handle booleans', () => { + expect(tlJsonToJson({ _: 'jsonBool', value: true })).toEqual(true) + expect(tlJsonToJson({ _: 'jsonBool', value: false })).toEqual(false) + }) + + it('should correctly handle numbers', () => { + expect(tlJsonToJson({ _: 'jsonNumber', value: 123 })).toEqual(123) + expect(tlJsonToJson({ _: 'jsonNumber', value: 0 })).toEqual(0) + expect(tlJsonToJson({ _: 'jsonNumber', value: -123 })).toEqual(-123) + }) + + it('should correctly handle strings', () => { + expect(tlJsonToJson({ _: 'jsonString', value: 'hello' })).toEqual('hello') + expect(tlJsonToJson({ _: 'jsonString', value: '' })).toEqual('') + }) + + it('should correcly handle arrays', () => { + expect( + tlJsonToJson({ + _: 'jsonArray', + value: [ + { _: 'jsonNumber', value: 1 }, + { _: 'jsonNumber', value: 2 }, + { _: 'jsonNumber', value: 3 }, + ], + }), + ).toEqual([1, 2, 3]) + }) + + it('should correctly handle objects', () => { + expect( + tlJsonToJson({ + _: 'jsonObject', + value: [ + { _: 'jsonObjectValue', key: 'a', value: { _: 'jsonNumber', value: 1 } }, + { _: 'jsonObjectValue', key: 'b', value: { _: 'jsonNumber', value: 2 } }, + ], + }), + ).toEqual({ a: 1, b: 2 }) + }) +}) diff --git a/packages/core/src/utils/type-assertions.test.ts b/packages/core/src/utils/type-assertions.test.ts new file mode 100644 index 00000000..e1fd6166 --- /dev/null +++ b/packages/core/src/utils/type-assertions.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { + assertTypeIs, + assertTypeIsNot, + hasPresentKey, + hasValueAtKey, + isPresent, + mtpAssertTypeIs, +} from './type-assertions.js' + +describe('isPresent', () => { + it('should return true for non-null values', () => { + expect(isPresent(1)).toBe(true) + expect(isPresent('')).toBe(true) + expect(isPresent({})).toBe(true) + expect(isPresent([])).toBe(true) + }) + + it('should return false for null/undefined', () => { + expect(isPresent(null)).toBe(false) + expect(isPresent(undefined)).toBe(false) + }) +}) + +describe('hasPresentKey', () => { + it('should return true for objects with present keys', () => { + expect(hasPresentKey('a')({ a: 1 })).toBe(true) + expect(hasPresentKey('a')({ a: 1, b: 2 })).toBe(true) + }) + + it('should return false for objects with undefined/null keys', () => { + expect(hasPresentKey('a')({ a: undefined })).toBe(false) + expect(hasPresentKey('a')({ a: null })).toBe(false) + expect(hasPresentKey('a')({ a: undefined, b: 2 })).toBe(false) + expect(hasPresentKey('a')({ a: null, b: 2 })).toBe(false) + }) + + it('should return false for objects without the key', () => { + expect(hasPresentKey('a')({ b: 2 })).toBe(false) + }) +}) + +describe('hasValueAtKey', () => { + it('should return true for objects with the correct value', () => { + expect(hasValueAtKey('a', 1)({ a: 1 })).toBe(true) + expect(hasValueAtKey('a', 1)({ a: 1, b: 2 })).toBe(true) + }) + + it('should return false for objects with the wrong value', () => { + expect(hasValueAtKey('a', 1)({ a: 2 })).toBe(false) + expect(hasValueAtKey('a', 1)({ a: 2, b: 2 })).toBe(false) + }) + + it('should return false for objects without the key', () => { + // @ts-expect-error - we want to make sure the key is not present at runtime + expect(hasValueAtKey('a', 1)({ b: 2 })).toBe(false) + }) +}) + +describe('assertTypeIs', () => { + it('should not throw for correct types', () => { + assertTypeIs('peerUser', { _: 'peerUser', userId: 1 }, 'peerUser') + mtpAssertTypeIs('peerUser', { _: 'mt_rpc_answer_unknown' }, 'mt_rpc_answer_unknown') + }) + + it('should throw for incorrect types', () => { + // eslint-disable-next-line + expect(() => assertTypeIs('peerUser', { _: 'peerChannel', channelId: 1 } as any, 'peerUser')).toThrow() + // eslint-disable-next-line + expect(() => mtpAssertTypeIs('peerUser', { _: 'mt_rpc_answer_unknown' } as any, 'peerUser')).toThrow() + }) +}) + +describe('assertTypeIsNot', () => { + it('should not throw for correct types', () => { + // eslint-disable-next-line + assertTypeIsNot('peerUser', { _: 'peerChannel', channelId: 1 } as any, 'peerUser') + }) + + it('should throw for incorrect types', () => { + expect(() => assertTypeIsNot('peerUser', { _: 'peerUser', userId: 1 }, 'peerUser')).toThrow() + }) +}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d5f18899..63770709 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "outDir": "./dist/esm", "rootDir": "./src", - "paths": { - "@mtcute/dispatcher": ["../dispatcher/src/state/storage.ts"], - } }, "include": [ "./src", diff --git a/packages/test/src/stub.test.ts b/packages/test/src/stub.test.ts index 6d3f311a..fcc55134 100644 --- a/packages/test/src/stub.test.ts +++ b/packages/test/src/stub.test.ts @@ -34,6 +34,14 @@ describe('stub', () => { }) }) + it('should correctly generate stubs for optional vectors', () => { + expect(createStub('updateChannelPinnedTopics')).toEqual({ + _: 'updateChannelPinnedTopics', + channelId: 0, + order: [], + }) + }) + it('should correctly generate stubs for nested types', () => { expect(createStub('messageActionGroupCallScheduled', { scheduleDate: 123 })).toEqual({ _: 'messageActionGroupCallScheduled', diff --git a/packages/test/src/utils.test.ts b/packages/test/src/utils.test.ts new file mode 100644 index 00000000..985e365c --- /dev/null +++ b/packages/test/src/utils.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { markedIdToPeer } from './utils.js' + +describe('markedIdToPeer', () => { + it('should correctly convert user ids', () => { + expect(markedIdToPeer(12345)).toEqual({ _: 'peerUser', userId: 12345 }) + }) + + it('should correctly convert chat ids', () => { + expect(markedIdToPeer(-12345)).toEqual({ _: 'peerChat', chatId: 12345 }) + }) + + it('should correctly convert channel ids', () => { + expect(markedIdToPeer(-1000000012345)).toEqual({ _: 'peerChannel', channelId: 12345 }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcb53be5..c4402755 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: specifier: 5.2.3 version: 5.2.3 devDependencies: + '@mtcute/dispatcher': + specifier: workspace:^ + version: link:../dispatcher + '@mtcute/test': + specifier: workspace:^ + version: link:../test '@types/ws': specifier: 8.5.4 version: 8.5.4