From 8965273172b085e2ebe5a760ee3588cfc3fc13f8 Mon Sep 17 00:00:00 2001 From: Alina Sireneva Date: Sun, 19 Nov 2023 04:30:16 +0300 Subject: [PATCH] test(dispatcher): some tests --- packages/dispatcher/package.json | 1 + packages/dispatcher/src/filters/bots.test.ts | 145 +++++++++++++ packages/dispatcher/src/filters/bots.ts | 14 +- packages/dispatcher/src/filters/logic.test.ts | 195 ++++++++++++++++++ packages/test/src/client.ts | 22 ++ pnpm-lock.yaml | 3 + 6 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 packages/dispatcher/src/filters/bots.test.ts create mode 100644 packages/dispatcher/src/filters/logic.test.ts diff --git a/packages/dispatcher/package.json b/packages/dispatcher/package.json index 446995c5..62904eef 100644 --- a/packages/dispatcher/package.json +++ b/packages/dispatcher/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@mtcute/client": "workspace:^", + "@mtcute/test": "workspace:^", "events": "3.2.0" } } diff --git a/packages/dispatcher/src/filters/bots.test.ts b/packages/dispatcher/src/filters/bots.test.ts new file mode 100644 index 00000000..163e67a7 --- /dev/null +++ b/packages/dispatcher/src/filters/bots.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' + +import { Message, PeersIndex, tl } from '@mtcute/client' +import { createStub, StubTelegramClient } from '@mtcute/test' + +import { MessageContext } from '../index.js' +import { command, deeplink } from './bots.js' + +const peers = new PeersIndex() +peers.users.set(1, createStub('user', { id: 1 })) +peers.chats.set(1, createStub('channel', { id: 1 })) +const createMessageContext = (partial: Partial) => + new MessageContext( + StubTelegramClient.full(), // eslint-disable-line + new Message(createStub('message', partial), peers, false), + ) + +describe('filters.command', () => { + const getParsedCommand = (text: string, ...params: Parameters) => { + const ctx = createMessageContext({ + message: text, + }) + ctx.client.getAuthState = () => ({ + isBot: true, + userId: 0, + selfUsername: 'testbot', + }) + + // eslint-disable-next-line + if (command(...params)(ctx)) return (ctx as any).command + + return null + } + + it('should only parse given commands', () => { + expect(getParsedCommand('/start', 'start')).toEqual(['start']) + expect(getParsedCommand('/start', 'stop')).toEqual(null) + expect(getParsedCommand('/start', ['start', 'stop'])).toEqual(['start']) + }) + + it('should only parse commands to the current bot', () => { + expect(getParsedCommand('/start@testbot', 'start')).toEqual(['start']) + expect(getParsedCommand('/start@otherbot', 'start')).toEqual(null) + }) + + it('should parse command arguments', () => { + expect(getParsedCommand('/start foo bar baz', 'start')).toEqual(['start', 'foo', 'bar', 'baz']) + expect(getParsedCommand('/start@testbot foo bar baz', 'start')).toEqual(['start', 'foo', 'bar', 'baz']) + }) + + it('should parse quoted command arguments', () => { + expect(getParsedCommand('/start foo "bar baz"', 'start')).toEqual(['start', 'foo', 'bar baz']) + expect(getParsedCommand('/start foo "bar \\" baz"', 'start')).toEqual(['start', 'foo', 'bar " baz']) + expect(getParsedCommand('/start foo "bar \\\\" baz"', 'start')).toEqual(['start', 'foo', 'bar \\" baz']) + }) + + it('should parse custom prefixes', () => { + expect(getParsedCommand('!start foo "bar baz"', 'start', { prefixes: '!' })).toEqual([ + 'start', + 'foo', + 'bar baz', + ]) + }) + + it('should be case insensitive by default', () => { + expect(getParsedCommand('/START foo', 'start')).toEqual(['start', 'foo']) + expect(getParsedCommand('/START foo BAR', 'start')).toEqual(['start', 'foo', 'BAR']) + }) + + it('should be case sensitive if asked', () => { + expect(getParsedCommand('/START foo', 'start', { caseSensitive: true })).toEqual(null) + expect(getParsedCommand('/START foo', 'START', { caseSensitive: true })).toEqual(['START', 'foo']) + }) + + it('should accept multiple commands to match', () => { + expect(getParsedCommand('/foo', ['foo', 'bar'])).toEqual(['foo']) + expect(getParsedCommand('/bar', ['foo', 'bar'])).toEqual(['bar']) + expect(getParsedCommand('/baz', ['foo', 'bar'])).toEqual(null) + }) +}) + +describe('filters.deeplink', () => { + it('should only match given param', () => { + const ctx = createMessageContext({ + message: '/start foo', + peerId: { _: 'peerUser', userId: 1 }, + }) + + expect(deeplink('bar')(ctx)).toEqual(false) + expect(deeplink('foo')(ctx)).toEqual(true) + // eslint-disable-next-line + expect((ctx as any).command).toEqual(['start', 'foo']) + }) + + it('should add regex matches', () => { + const ctx = createMessageContext({ + message: '/start foo_123', + peerId: { _: 'peerUser', userId: 1 }, + }) + + expect(deeplink(/^foo_(\d+)$/)(ctx)).toEqual(true) + // eslint-disable-next-line + expect((ctx as any).command).toEqual(['start', 'foo_123', '123']) + }) + + it('should accept multiple params', () => { + const ctx = createMessageContext({ + message: '/start foo', + peerId: { _: 'peerUser', userId: 1 }, + }) + + expect(deeplink(['foo', 'bar'])(ctx)).toEqual(true) + // eslint-disable-next-line + expect((ctx as any).command).toEqual(['start', 'foo']) + }) + + it('should accept multiple regex params', () => { + const ctx = createMessageContext({ + message: '/start foo', + peerId: { _: 'peerUser', userId: 1 }, + }) + + expect(deeplink([/foo/, /bar/])(ctx)).toEqual(true) + // eslint-disable-next-line + expect((ctx as any).command).toEqual(['start', 'foo']) + }) + + it('should fail for >1 arguments', () => { + const ctx = createMessageContext({ + message: '/start foo bar', + peerId: { _: 'peerUser', userId: 1 }, + }) + + expect(deeplink('foo')(ctx)).toEqual(false) + }) + + it('should fail for non-pm messages', () => { + const ctx = createMessageContext({ + message: '/start foo', + peerId: { _: 'peerChannel', channelId: 1 }, + }) + + expect(deeplink('foo')(ctx)).toEqual(false) + }) +}) diff --git a/packages/dispatcher/src/filters/bots.ts b/packages/dispatcher/src/filters/bots.ts index 4f79d219..c1fd539c 100644 --- a/packages/dispatcher/src/filters/bots.ts +++ b/packages/dispatcher/src/filters/bots.ts @@ -23,12 +23,19 @@ import { UpdateFilter } from './types.js' */ export const command = ( commands: MaybeArray, - prefixes: MaybeArray | null = '/', - caseSensitive = false, + { + prefixes = '/', + caseSensitive = false, + }: { + prefixes?: MaybeArray | null + caseSensitive?: boolean + } = {}, ): UpdateFilter => { if (!Array.isArray(commands)) commands = [commands] - commands = commands.map((i) => (typeof i === 'string' ? i.toLowerCase() : i)) + if (!caseSensitive) { + commands = commands.map((i) => (typeof i === 'string' ? i.toLowerCase() : i)) + } const argumentsRe = /(["'])(.*?)(? { diff --git a/packages/dispatcher/src/filters/logic.test.ts b/packages/dispatcher/src/filters/logic.test.ts new file mode 100644 index 00000000..28efb8c4 --- /dev/null +++ b/packages/dispatcher/src/filters/logic.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' + +import { and, not, or } from './logic.js' + +describe('filters.not', () => { + it('should negate a given sync filter', () => { + const filter = vi.fn().mockReturnValue(true) + const negated = not(filter) + + expect(negated(1)).toBe(false) + expect(filter).toHaveBeenCalledTimes(1) + expect(filter).toHaveBeenCalledWith(1, undefined) + }) + + it('should negate a given async filter', async () => { + const filter = vi.fn().mockResolvedValue(true) + const negated = not(filter) + + await expect(negated(1)).resolves.toBe(false) + expect(filter).toHaveBeenCalledTimes(1) + expect(filter).toHaveBeenCalledWith(1, undefined) + }) +}) + +describe('filters.and', () => { + it.each([ + ['sync', 'sync'], + ['sync', 'async'], + ['async', 'sync'], + ['async', 'async'], + ])('should combine %s and %s filters', async (aType, bType) => { + const a = vi.fn().mockReturnValue(aType === 'sync' ? true : Promise.resolve(true)) + const b = vi.fn().mockReturnValue(bType === 'sync' ? true : Promise.resolve(true)) + + const combined = and(a, b) + + expect(await combined(1)).toBe(true) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(1, undefined) + }) + + it.each([ + ['sync', 'sync'], + ['sync', 'async'], + ['async', 'sync'], + ['async', 'async'], + ])('should not continue execution after false (%s and %s filters)', async (aType, bType) => { + const a = vi.fn().mockReturnValue(aType === 'sync' ? false : Promise.resolve(false)) + const b = vi.fn().mockReturnValue(bType === 'sync' ? true : Promise.resolve(true)) + + const combined = and(a, b) + + expect(await combined(1)).toBe(false) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).not.toHaveBeenCalled() + }) + + it.each([ + ['sync', 'sync', 'sync'], + ['sync', 'sync', 'async'], + ['sync', 'async', 'sync'], + ['sync', 'async', 'async'], + ['async', 'sync', 'sync'], + ['async', 'sync', 'async'], + ['async', 'async', 'sync'], + ['async', 'async', 'async'], + ])('should combine %s, %s and %s filters', async (aType, bType) => { + const a = vi.fn().mockReturnValue(aType === 'sync' ? true : Promise.resolve(true)) + const b = vi.fn().mockReturnValue(bType === 'sync' ? true : Promise.resolve(true)) + const c = vi.fn().mockReturnValue(bType === 'sync' ? true : Promise.resolve(true)) + + const combined = and(a, b, c) + + expect(await combined(1)).toBe(true) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(1, undefined) + expect(c).toHaveBeenCalledTimes(1) + expect(c).toHaveBeenCalledWith(1, undefined) + }) + + it.each([ + ['sync', 'sync', 'sync'], + ['sync', 'sync', 'async'], + ['sync', 'async', 'sync'], + ['sync', 'async', 'async'], + ['async', 'sync', 'sync'], + ['async', 'sync', 'async'], + ['async', 'async', 'sync'], + ['async', 'async', 'async'], + ])('should not continue execution after false (%s, %s and %s filters)', async (aType, bType) => { + const a = vi.fn().mockReturnValue(aType === 'sync' ? true : Promise.resolve(true)) + const b = vi.fn().mockReturnValue(bType === 'sync' ? false : Promise.resolve(false)) + const c = vi.fn().mockReturnValue(bType === 'sync' ? true : Promise.resolve(true)) + + const combined = and(a, b, c) + + expect(await combined(1)).toBe(false) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(1, undefined) + expect(c).not.toHaveBeenCalled() + }) +}) + +describe('filters.or', () => { + it.each([ + ['sync', 'sync'], + ['sync', 'async'], + ['async', 'sync'], + ['async', 'async'], + ])('should combine %s and %s filters', async (aType, bType) => { + const a = vi.fn().mockReturnValue(aType === 'sync' ? false : Promise.resolve(false)) + const b = vi.fn().mockReturnValue(bType === 'sync' ? false : Promise.resolve(false)) + + const combined = or(a, b) + + expect(await combined(1)).toBe(false) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(1, undefined) + }) + + it.each([ + ['sync', 'sync'], + ['sync', 'async'], + ['async', 'sync'], + ['async', 'async'], + ])('should not continue execution after true (%s and %s filters)', async (aType, bType) => { + const a = vi.fn().mockReturnValue(bType === 'sync' ? true : Promise.resolve(true)) + const b = vi.fn().mockReturnValue(aType === 'sync' ? false : Promise.resolve(false)) + + const combined = or(a, b) + + expect(await combined(1)).toBe(true) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).not.toHaveBeenCalled() + }) + + it.each([ + ['sync', 'sync', 'sync'], + ['sync', 'sync', 'async'], + ['sync', 'async', 'sync'], + ['sync', 'async', 'async'], + ['async', 'sync', 'sync'], + ['async', 'sync', 'async'], + ['async', 'async', 'sync'], + ['async', 'async', 'async'], + ])('should combine %s, %s and %s filters', async (aType, bType) => { + const a = vi.fn().mockReturnValue(aType === 'sync' ? false : Promise.resolve(false)) + const b = vi.fn().mockReturnValue(bType === 'sync' ? false : Promise.resolve(false)) + const c = vi.fn().mockReturnValue(bType === 'sync' ? false : Promise.resolve(false)) + + const combined = or(a, b, c) + + expect(await combined(1)).toBe(false) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(1, undefined) + expect(c).toHaveBeenCalledTimes(1) + expect(c).toHaveBeenCalledWith(1, undefined) + }) + + it.each([ + ['sync', 'sync', 'sync'], + ['sync', 'sync', 'async'], + ['sync', 'async', 'sync'], + ['sync', 'async', 'async'], + ['async', 'sync', 'sync'], + ['async', 'sync', 'async'], + ['async', 'async', 'sync'], + ['async', 'async', 'async'], + ])('should not continue execution after true (%s, %s and %s filters)', async (aType, bType) => { + const a = vi.fn().mockReturnValue(aType === 'sync' ? false : Promise.resolve(false)) + const b = vi.fn().mockReturnValue(bType === 'sync' ? true : Promise.resolve(true)) + const c = vi.fn().mockReturnValue(bType === 'sync' ? false : Promise.resolve(false)) + + const combined = or(a, b, c) + + expect(await combined(1)).toBe(true) + expect(a).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledWith(1, undefined) + expect(b).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(1, undefined) + expect(c).not.toHaveBeenCalled() + }) +}) diff --git a/packages/test/src/client.ts b/packages/test/src/client.ts index 2f8e72d9..c0c6b2da 100644 --- a/packages/test/src/client.ts +++ b/packages/test/src/client.ts @@ -65,6 +65,28 @@ export class StubTelegramClient extends BaseTelegramClient { return client } + /** + * Create a fake "full" client (i.e. TelegramClient) + * + * Basically a proxy that returns an empty function for every unknown method + */ + static full() { + const client = new StubTelegramClient() + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return new Proxy(client, { + get(target, prop) { + if (typeof prop === 'string' && !(prop in target)) { + return () => {} + } + + return target[prop as keyof typeof target] + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + // i don't want to type this properly since it would require depending test utils on client + } + // some fake peers handling // readonly _knownChats = new Map() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca780838..5e7b075d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: '@mtcute/client': specifier: workspace:^ version: link:../client + '@mtcute/test': + specifier: workspace:^ + version: link:../test events: specifier: 3.2.0 version: 3.2.0