test(core): improved test coverage

This commit is contained in:
alina 🌸 2023-11-11 18:34:33 +03:00
parent 59a4a7553f
commit e31ecbd3d1
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
41 changed files with 2262 additions and 173 deletions

View file

@ -215,7 +215,13 @@ module.exports = {
'@typescript-eslint/no-dynamic-delete': 'off', '@typescript-eslint/no-dynamic-delete': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-member-access': 'off',
'no-restricted-globals': ['error', 'Buffer', '__dirname', 'require'], '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, reportUnusedDisableDirectives: false,
settings: { settings: {
@ -229,7 +235,12 @@ module.exports = {
files: ['**/scripts/**', '*.test.ts', 'packages/create-*/**', '**/build.config.cjs'], files: ['**/scripts/**', '*.test.ts', 'packages/create-*/**', '**/build.config.cjs'],
rules: { rules: {
'no-console': 'off', 'no-console': 'off',
'no-restricted-imports': 'off', 'no-restricted-imports': [
'error',
{
patterns: ['@mtcute/*/dist/**'],
},
],
}, },
}, },
{ {

View file

@ -13,10 +13,10 @@ module.exports = {
} }
return [ return [
...[...modifiedPackages].map((pkg) => `pnpm -C packages/${pkg} exec tsc --build`),
`prettier --write ${filenames.join(' ')}`, `prettier --write ${filenames.join(' ')}`,
`eslint -c ${eslintCiConfig} --fix ${filenames.join(' ')}`, `eslint -c ${eslintCiConfig} --fix ${filenames.join(' ')}`,
'pnpm run lint:dpdm', 'pnpm run lint:dpdm',
...[...modifiedPackages].map((pkg) => `pnpm -C packages/${pkg} run --if-present build --noEmit`)
] ]
} }
} }

View file

@ -53,6 +53,8 @@
"devDependencies": { "devDependencies": {
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"node-forge": "1.3.1", "node-forge": "1.3.1",
"@mtcute/test": "workspace:^",
"@mtcute/dispatcher": "workspace:^",
"ws": "8.13.0" "ws": "8.13.0"
} }
} }

View file

@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import { hexDecodeToBuffer, hexEncode } from '@mtcute/tl-runtime' 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', () => { describe('IntermediatePacketCodec', () => {
it('should return correct tag', () => { it('should return correct tag', () => {
@ -76,4 +77,35 @@ describe('IntermediatePacketCodec', () => {
codec.reset() codec.reset()
codec.feed(hexDecodeToBuffer('050000000102030405')) 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])
})
}) })

View file

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

View file

@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-return */
import { tl } from '@mtcute/tl'
import { base64DecodeToBuffer, base64Encode } from '@mtcute/tl-runtime' import { base64DecodeToBuffer, base64Encode } from '@mtcute/tl-runtime'
import { longFromFastString, longToFastString } from '../utils/long-utils.js'
import { MemorySessionState, MemoryStorage } from './memory.js' import { MemorySessionState, MemoryStorage } from './memory.js'
/** /**
@ -16,25 +14,26 @@ export class JsonMemoryStorage extends MemoryStorage {
switch (key) { switch (key) {
case 'authKeys': case 'authKeys':
case 'authKeysTemp': { case 'authKeysTemp': {
const ret: Record<string, Uint8Array> = {} const ret = new Map<string | number, Uint8Array>()
;(value as string).split('|').forEach((pair: string) => { ;(value as string).split('|').forEach((pair: string) => {
const [dcId, b64] = pair.split(',') const [dcId, b64] = pair.split(',')
ret[dcId] = base64DecodeToBuffer(b64) const mapKey = key === 'authKeysTemp' ? dcId : parseInt(dcId)
ret.set(mapKey, base64DecodeToBuffer(b64))
}) })
return ret return ret
} }
case 'authKeysTempExpiry': case 'authKeysTempExpiry':
case 'entities':
case 'phoneIndex': case 'phoneIndex':
case 'usernameIndex': case 'usernameIndex':
case 'pts': case 'pts':
case 'fsm': case 'fsm':
case 'rl': case 'rl':
return new Map(Object.entries(value as Record<string, string>)) return new Map(Object.entries(value as Record<string, string>))
case 'accessHash': case 'entities':
return longFromFastString(value as string) return new Map()
} }
return value return value
@ -55,15 +54,14 @@ export class JsonMemoryStorage extends MemoryStorage {
.join('|') .join('|')
} }
case 'authKeysTempExpiry': case 'authKeysTempExpiry':
case 'entities':
case 'phoneIndex': case 'phoneIndex':
case 'usernameIndex': case 'usernameIndex':
case 'pts': case 'pts':
case 'fsm': case 'fsm':
case 'rl': case 'rl':
return Object.fromEntries([...(value as Map<string, string>).entries()]) return Object.fromEntries([...(value as Map<string, string>).entries()])
case 'accessHash': case 'entities':
return longToFastString(value as tl.Long) return {}
} }
return value return value

View file

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

View file

@ -16,11 +16,14 @@ export class LocalstorageStorage extends JsonMemoryStorage {
load(): void { load(): void {
try { try {
this._loadJson(localStorage[this._key] as string) const val = localStorage.getItem(this._key)
if (val === null) return
this._loadJson(val)
} catch (e) {} } catch (e) {}
} }
save(): void { save(): void {
localStorage[this._key] = this._saveJson() localStorage.setItem(this._key, this._saveJson())
} }
} }

View file

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

View file

@ -1,6 +1,6 @@
import { IStateStorage } from '@mtcute/dispatcher'
import { tl } from '@mtcute/tl' import { tl } from '@mtcute/tl'
import { MaybeAsync } from '../types/index.js'
import { LruMap, toggleChannelIdMark } from '../utils/index.js' import { LruMap, toggleChannelIdMark } from '../utils/index.js'
import { ITelegramStorage } from './abstract.js' import { ITelegramStorage } from './abstract.js'
@ -56,7 +56,7 @@ export interface MemorySessionState {
const USERNAME_TTL = 86400000 // 24 hours const USERNAME_TTL = 86400000 // 24 hours
export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ { export class MemoryStorage implements ITelegramStorage, IStateStorage {
protected _state!: MemorySessionState protected _state!: MemorySessionState
private _cachedInputPeers: LruMap<number, tl.TypeInputPeer> = new LruMap(100) private _cachedInputPeers: LruMap<number, tl.TypeInputPeer> = new LruMap(100)
@ -131,11 +131,11 @@ export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ {
// populate indexes if needed // populate indexes if needed
let populate = false let populate = false
if (!obj.phoneIndex) { if (!obj.phoneIndex?.size) {
obj.phoneIndex = new Map() obj.phoneIndex = new Map()
populate = true populate = true
} }
if (!obj.usernameIndex) { if (!obj.usernameIndex?.size) {
obj.usernameIndex = new Map() obj.usernameIndex = new Map()
populate = true populate = true
} }
@ -229,7 +229,7 @@ export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ {
} }
} }
updatePeers(peers: PeerInfoWithUpdated[]): MaybeAsync<void> { updatePeers(peers: PeerInfoWithUpdated[]): void {
for (const peer of peers) { for (const peer of peers) {
this._cachedFull.set(peer.id, peer.full) this._cachedFull.set(peer.id, peer.full)
@ -326,26 +326,26 @@ export class MemoryStorage implements ITelegramStorage /*, IStateStorage*/ {
return this._state.pts.get(entityId) ?? null 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 return this._state.gpts ?? null
} }
setUpdatesPts(val: number): MaybeAsync<void> { setUpdatesPts(val: number): void {
if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0]
this._state.gpts[0] = val this._state.gpts[0] = val
} }
setUpdatesQts(val: number): MaybeAsync<void> { setUpdatesQts(val: number): void {
if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0]
this._state.gpts[1] = val this._state.gpts[1] = val
} }
setUpdatesDate(val: number): MaybeAsync<void> { setUpdatesDate(val: number): void {
if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0]
this._state.gpts[2] = val this._state.gpts[2] = val
} }
setUpdatesSeq(val: number): MaybeAsync<void> { setUpdatesSeq(val: number): void {
if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0] if (!this._state.gpts) this._state.gpts = [0, 0, 0, 0]
this._state.gpts[3] = val this._state.gpts[3] = val
} }

View file

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

View file

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

View file

@ -2,7 +2,28 @@ import { describe, expect, it } from 'vitest'
import { hexDecodeToBuffer } from '@mtcute/tl-runtime' 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', () => { describe('bigIntToBuffer', () => {
it('should handle writing to BE', () => { it('should handle writing to BE', () => {
@ -63,3 +84,111 @@ describe('bufferToBigInt', () => {
expect(bufferToBigInt(buf.reverse(), true).toString()).eq(num.toString()) 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)
})
})

View file

@ -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' import { hexEncode, utf8Decode, utf8EncodeToBuffer } from '@mtcute/tl-runtime'
@ -121,6 +121,19 @@ describe('concatBuffers', () => {
buf[0] = 0xff buf[0] = 0xff
expect(buf1[0]).not.eql(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', () => { describe('bufferToReversed', () => {

View file

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

View file

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

View file

@ -1,8 +1,10 @@
import { beforeAll, expect, it } from 'vitest' import { beforeAll, expect, it } from 'vitest'
import { gzipSync, inflateSync } from 'zlib'
import { hexDecodeToBuffer, hexEncode, utf8EncodeToBuffer } from '@mtcute/tl-runtime' import { hexDecodeToBuffer, hexEncode, utf8EncodeToBuffer } from '@mtcute/tl-runtime'
import { ICryptoProvider } from './abstract.js' import { ICryptoProvider } from './abstract.js'
import { factorizePQSync } from './factorization.js'
export function testCryptoProvider(c: ICryptoProvider): void { export function testCryptoProvider(c: ICryptoProvider): void {
beforeAll(() => c.initialize?.()) beforeAll(() => c.initialize?.())
@ -93,4 +95,46 @@ export function testCryptoProvider(c: ICryptoProvider): void {
), ),
).to.eq('99706487a1cde613bc6de0b6f24b1c7aa448c8b9c3403e3467a8cad89340f53b') ).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))
})
} }

View file

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

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parsePublicKey } from '../index.js' import { findKeyByFingerprints, parsePublicKey } from '../index.js'
import { NodeCryptoProvider } from './node.js' import { NodeCryptoProvider } from './node.js'
const crypto = new NodeCryptoProvider() const crypto = new NodeCryptoProvider()
@ -27,4 +27,23 @@ XGF710w9lwCGNbmNxNYhtIkdqfsEcwR5JwIDAQAB
old: false, 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()
})
}) })

View file

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

View file

@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import { Deque } from './deque.js'
describe('Deque', () => {
function setupWrapping() {
const d = new Deque<number>(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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>(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<number>(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<number>(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<number>(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<number>()
d.pushBack(1)
d.pushBack(2)
expect(d.peekFront()).eql(1)
expect(d.peekBack()).eql(2)
})
it('should correctly clear', () => {
const d = new Deque<number>()
d.pushBack(1)
d.pushBack(2)
d.clear()
expect(d.length).eql(0)
expect(d.toArray()).eql([])
expect([...d.iter()]).eql([])
})
})

View file

@ -24,14 +24,16 @@ export class Deque<T> {
protected _capacity: number protected _capacity: number
constructor( constructor(
minCapacity = MIN_INITIAL_CAPACITY,
readonly maxLength = Infinity, readonly maxLength = Infinity,
minCapacity = maxLength === Infinity ? MIN_INITIAL_CAPACITY : maxLength,
) { ) {
let capacity = minCapacity 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. // Find the best power of two to hold elements.
// >= because array can't be full
capacity |= capacity >>> 1 capacity |= capacity >>> 1
capacity |= capacity >>> 2 capacity |= capacity >>> 2
capacity |= capacity >>> 4 capacity |= capacity >>> 4
@ -135,6 +137,8 @@ export class Deque<T> {
toArray(): T[] { toArray(): T[] {
const sz = this.length const sz = this.length
if (sz === 0) return []
const arr = new Array(sz) const arr = new Array(sz)
if (this._head < this._tail) { if (this._head < this._tail) {
@ -220,6 +224,7 @@ export class Deque<T> {
*iter(): IterableIterator<T> { *iter(): IterableIterator<T> {
const sz = this.length const sz = this.length
if (sz === 0) return
if (this._head < this._tail) { if (this._head < this._tail) {
// head to tail // head to tail

View file

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

View file

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

View file

@ -7,7 +7,6 @@ export * from './crypto/index.js'
export * from './default-dcs.js' export * from './default-dcs.js'
export * from './deque.js' export * from './deque.js'
export * from './early-timer.js' export * from './early-timer.js'
export * from './flood-control.js'
export * from './function-utils.js' export * from './function-utils.js'
export * from './linked-list.js' export * from './linked-list.js'
export * from './links/index.js' export * from './links/index.js'

View file

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

View file

@ -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<Parameters<typeof mgr.handler>>()
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', [])
})
})
})
})

View file

@ -4,6 +4,7 @@ import { _defaultLoggingHandler } from './platform/logging.js'
let defaultLogLevel = 3 let defaultLogLevel = 3
/* c8 ignore start */
if (typeof process !== 'undefined') { if (typeof process !== 'undefined') {
const envLogLevel = parseInt(process.env.MTCUTE_LOG_LEVEL ?? '') const envLogLevel = parseInt(process.env.MTCUTE_LOG_LEVEL ?? '')
@ -17,6 +18,7 @@ if (typeof process !== 'undefined') {
defaultLogLevel = localLogLevel defaultLogLevel = localLogLevel
} }
} }
/* c8 ignore end */
const FORMATTER_RE = /%[a-zA-Z]/g const FORMATTER_RE = /%[a-zA-Z]/g
@ -79,7 +81,7 @@ export class Logger {
if (m === '%h') { if (m === '%h') {
if (ArrayBuffer.isView(val)) return hexEncode(val as Uint8Array) 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) return String(val)
} }
@ -165,10 +167,6 @@ export class LogManager extends Logger {
level = defaultLogLevel level = defaultLogLevel
handler = _defaultLoggingHandler handler = _defaultLoggingHandler
disable(): void {
this.level = 0
}
/** /**
* Create a {@link Logger} with the given tag * Create a {@link Logger} with the given tag
* *

View file

@ -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<string>()
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<string>()
expect(map.get(Long.fromInt(123))).toEqual(undefined)
})
it('should check for existing keys', () => {
const map = new LongMap<string>()
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<string>()
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<string>()
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<string>()
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<string>()
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<string>()
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)])
})
})

View file

@ -3,42 +3,111 @@ import { describe, expect, it } from 'vitest'
import { LruMap } from './lru-map.js' import { LruMap } from './lru-map.js'
describe('LruMap', () => { describe('LruMap', () => {
it('Map backend', () => { it('should maintain maximum size by removing oldest added', () => {
const lru = new LruMap<string, number>(2) const lru = new LruMap<string, number>(2)
lru.set('first', 1) 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) 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) 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 expect(lru.has('first')).toBeFalsy()
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)
}) })
it('should update the last added item', () => {
const lru = new LruMap<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(2)
lru.set('first', 1)
lru.set('second', 2)
expect(lru.get('third')).toEqual(undefined)
})
// it('Map backend', () => {
// const lru = new LruMap<string, number>(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)
// })
}) })

View file

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

View file

@ -72,14 +72,18 @@ export function getMarkedPeerId(
switch (peer._) { switch (peer._) {
case 'peerUser': case 'peerUser':
case 'inputPeerUser': case 'inputPeerUser':
case 'inputPeerUserFromMessage':
case 'inputUser': case 'inputUser':
case 'inputUserFromMessage':
return peer.userId return peer.userId
case 'peerChat': case 'peerChat':
case 'inputPeerChat': case 'inputPeerChat':
return -peer.chatId return -peer.chatId
case 'peerChannel': case 'peerChannel':
case 'inputPeerChannel': case 'inputPeerChannel':
case 'inputPeerChannelFromMessage':
case 'inputChannel': case 'inputChannel':
case 'inputChannelFromMessage':
return ZERO_CHANNEL_ID - peer.channelId return ZERO_CHANNEL_ID - peer.channelId
} }
@ -110,7 +114,7 @@ export function getBasicPeerType(peer: tl.TypePeer | number): BasicPeerType {
return 'channel' 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' // return 'secret'
throw new MtUnsupportedError('Secret chats are not supported') throw new MtUnsupportedError('Secret chats are not supported')
} }

View file

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

View file

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

View file

@ -41,11 +41,14 @@ export function writeStringSession(writerMap: TlWriterMap, data: StringSessionDa
writer.uint8View[0] = version writer.uint8View[0] = version
writer.pos += 1 writer.pos += 1
if (version >= 2 && data.primaryDcs.media !== data.primaryDcs.main) {
flags |= 4
}
writer.int(flags) writer.int(flags)
writer.object(data.primaryDcs.main) writer.object(data.primaryDcs.main)
if (version >= 2 && data.primaryDcs.media !== data.primaryDcs.main) { if (version >= 2 && data.primaryDcs.media !== data.primaryDcs.main) {
flags |= 4
writer.object(data.primaryDcs.media) writer.object(data.primaryDcs.media)
} }
@ -97,7 +100,7 @@ export function readStringSession(readerMap: TlReaderMap, data: string): StringS
const key = reader.bytes() const key = reader.bytes()
return { return {
version: 1, version,
testMode, testMode,
primaryDcs: { primaryDcs: {
main: primaryDc, main: primaryDc,

View file

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

View file

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

View file

@ -3,9 +3,6 @@
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/esm", "outDir": "./dist/esm",
"rootDir": "./src", "rootDir": "./src",
"paths": {
"@mtcute/dispatcher": ["../dispatcher/src/state/storage.ts"],
}
}, },
"include": [ "include": [
"./src", "./src",

View file

@ -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', () => { it('should correctly generate stubs for nested types', () => {
expect(createStub('messageActionGroupCallScheduled', { scheduleDate: 123 })).toEqual({ expect(createStub('messageActionGroupCallScheduled', { scheduleDate: 123 })).toEqual({
_: 'messageActionGroupCallScheduled', _: 'messageActionGroupCallScheduled',

View file

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

View file

@ -127,6 +127,12 @@ importers:
specifier: 5.2.3 specifier: 5.2.3
version: 5.2.3 version: 5.2.3
devDependencies: devDependencies:
'@mtcute/dispatcher':
specifier: workspace:^
version: link:../dispatcher
'@mtcute/test':
specifier: workspace:^
version: link:../test
'@types/ws': '@types/ws':
specifier: 8.5.4 specifier: 8.5.4
version: 8.5.4 version: 8.5.4